Building a Daily List calendar in SwiftUI


My first ever published app was a meeting room display app which is only used for internal office purposes.

The functionality of the original design was intentionally basic because honestly, I really had no idea what I was doing, or how to even get there.

It also lacked information that was accessible for the user - and I made a lot of assumptions on how to interpret the UI.

Every day I would walk past these designs and feel pretty down about it. I knew I could do better, and I had done better on some of my other apps.

Introduction

I like to think a nice clean UI makes an app go from generic to being an experience.

I think moving from Storyboards into SwiftUI also meant that I had so much more available to me in a framework that was easier to understand - particularly coming from web development.

In this post, I will show you how to create a daily view of a calendar in SwiftUI.

I initially thought it wouldn't be too difficult in SwiftUI since we have ZStack and HStack which would allow me to overlay events and easily space them.

However, it turned out to be more challenging than I anticipated, especially when handling collisions, overlapping events, and aligning them.

But I think the challenges is what makes this work fun.

This is what I ended up with:

Out result CalendarView
Out result CalendarView

Breakdown

The first thing I needed to do was break down how the calendar system currently works on the Calendar.app for the daily view. Here are a few things I noticed:

  1. Events smaller than 12 minutes in duration had a default minimum height
  2. Sometimes events split when clashing, other times they would overlay
    • If the event was 15 minutes in duration, it would always split
    • If the event was 30 minutes in duration, the split would only occur if the next event started within 12 minutes of the start time
    • There was some multiplication of the 12 minute threshold, but didn't delve too deep
Testing collisions in Apple's calendar
Testing collisions in Apple's calendar

To replicate this behaviour, I needed to understand the math behind it. However, instead of replicating it exactly, I wanted to use it as inspiration and create my own design.

Design Aspects

Before diving into the code, I observed some design aspects in the Calendar.app:

  1. The hour heights were flexible and stretched or squashed based on the window height
  2. Time was displayed in 12-hour or 24-hour format based on the System Preferences on the clock
  3. Each event had a small curve and left border, and the border disappeared when selected

At this point, I was only scratching the surface of the design and intricacies. I thought it was a small and simple job that didn't require much thought.

How the lines work in Apple's calendar
How the lines work in Apple's calendar

Little did I know that there was more to it.

Research

When I started looking into creating a daily calendar view in SwiftUI, I came across two types of results: lots of resources about creating a month view or references to CalendarKit.

CalendarKit is a powerful library that closely emulates Apple's internal calendar system and provides full control over the UI.

It was pretty tempting to use CalendarKit, but I decided to try building it from scratch to understand the intricacies of implementation.

Apart from the existing resources, I also looked for inspiration from other developers who might have tackled this type of calendar view.

Surprisingly, I found limited discussions on creating a daily calendar view. This made me wonder if I was either overestimating the complexity or if it was so simple that it wasn't discussed extensively.

Below are some designs I found that were similar to Apple's calendar app or had slight variations but were in the same ballpark:

Alberto Colopi on Dribbble
Alberto Colopi on Dribbble
Hijin Nam on Dribbble
Hijin Nam on Dribbble
Microsoft Outlook calendar
Microsoft Outlook calendar
Yao Liu on Dribbble
Yao Liu on Dribbble

The Code

If you're reading this, you're probably here for the code and instructions on how to create the daily calendar. So, let's get started!

Model

I started by creating a model for the events. In this example, I used a simple Event struct with properties such as title, startDateTime, and endDateTime:

struct Event: Identifiable {
  let id: UUID = UUID()
  let title: String
  let startDateTime: Date
  let endDateTime: Date
}

To test the code, I also created some mock events using a mockEvents extension:

extension Event {
  static let mockEvents: [Event] = [
    Event(
      title: "Induction",
      startDateTime: .from("2023-06-20 7:05"),
      endDateTime: .from("2023-06-20 8:10")
    ),
    Event(
      title: "Product meeting",
      startDateTime: .from("2023-06-20 8:10"),
      endDateTime: .from("2023-06-20 8:30")
    ),
    Event(
      title: "Potential Call",
      startDateTime: .from("2023-06-20 9:15"),
      endDateTime: .from("2023-06-20 15:45")
    ),
    Event(
      title: "Offsite scope",
      startDateTime: .from("2023-06-20 12:00"),
      endDateTime: .from("2023-06-20 13:30")
    ),
    Event(
      title: "Presentation",
      startDateTime: .from("2023-06-20 17:00"),
      endDateTime: .from("2023-06-20 18:30")
    )
  ]
}

Helpers

To make working with dates easier, I created a Date extension with a helper function to convert a string into a Date object:

extension Date {
  static func from(_ dateString: String, format: String = "yyyy-MM-dd HH:mm") -> Date {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = format
    return dateFormatter.date(from: dateString)!
  }
}

CalendarView

Next, I created the time or hour lines using SwiftUI. I encapsulated the calendar view in a CalendarView struct:

struct CalendarView: View {
  let startHour: Int
  let endHour: Int
  let calendarHeight: CGFloat
  let events: [Event]

  // ... code goes here

  var body: some View {
    VStack(spacing: 0) {
      // ... view code goes here
    }
  }
}

To calculate the height of each hour line, I divided the calendarHeight by the number of hours:

private var hourHeight: CGFloat {
  calendarHeight / CGFloat(endHour - startHour)
}

I used a VStack to arrange the hour lines vertically and a ForEach loop to iterate over the hours and create the hour lines:

ForEach(startHour ... endHour, id: \.self) { hour in
  HStack(spacing: 10) {
    // ... view code goes here
  }
}

Inside the HStack, I displayed the hour label and a horizontal line using a Text view and a Rectangle view, respectively.

Adding Events

To display the events, I created an EventCell view. Each event will be represented by an EventCell:

struct EventCell: View {
  let event: Event
  let hourHeight: CGFloat
  let startHour: Int

  // ... code goes here

  var body: some View {
    Text(event.title)
      // ... view code goes here
      .offset(y: CGFloat(offset))
  }
}

The EventCell view takes an event parameter of type Event, along with other parameters such as hourHeight and startHour.

Within the EventCell view, I calculated the duration and height of the event, extracted the hour and minute components from the startDateTime, and calculated the offset for positioning the cell vertically.

Finally, I displayed the event title, adjusted the frame height, set the minimum scale factor, and added a background and offset.

Event Collision Handling

To handle event collisions, I created an EventProcessor struct that processes an array of events and groups them based on their start and end times:

fileprivate struct EventProcessor {
  static func processEvents(_ events: [Event]) -> [[Event]] {
    // ... code goes here
  }
}

The processEvents function sorts the events based on their startDateTime, iterates over the sorted events, and groups events that overlap into separate arrays.

Putting It All Together

In the CalendarView struct, I added a computed property called overlappingEventGroups to store the overlapping event groups:

private var overlappingEventGroups: [[Event]] {
  EventProcessor.processEvents(events)
}

I then used this property in the body of the view to iterate over the overlapping event groups and create an HStack for each group. Inside the HStack, I used a nested ForEach to iterate over the events within each group and display the EventCell for each event.

Final Result

Once all the components have been put together, this is the final code:

Information

I have added some extra functionality into the final code which allows for 12-hour or 24-hour in the list hours

import SwiftUI

/// A view representing the main content of the app.
struct MainView: View {
  var body: some View {

    /// Display the calendar view with the specified parameters.
    CalendarView(
      startHour: 0,
      endHour: 23,
      calendarHeight: 600,
      events: Event.mockEvents,
      use24HourFormat: false
    )
  }
}

/// A struct representing an event.
struct Event: Identifiable, Hashable {
  let id: UUID = UUID()
  let title: String
  let startDateTime: Date
  let endDateTime: Date
}

extension Event {

  /// An array of mock events for testing purposes.
  static let mockEvents: [Event] = [
    Event(
      title: "Induction",
      startDateTime: .from("2023-06-20 7:05"),
      endDateTime: .from("2023-06-20 8:10")
    ),
    Event(
      title: "Product meeting",
      startDateTime: .from("2023-06-20 8:10"),
      endDateTime: .from("2023-06-20 8:30")
    ),
    Event(
      title: "Potential Call",
      startDateTime: .from("2023-06-20 9:15"),
      endDateTime: .from("2023-06-20 15:45")
    ),
    Event(
      title: "Offsite scope",
      startDateTime: .from("2023-06-20 12:00"),
      endDateTime: .from("2023-06-20 13:30")
    ),
    Event(
      title: "Presentation",
      startDateTime: .from("2023-06-20 17:00"),
      endDateTime: .from("2023-06-20 18:30")
    )
  ]
}

/// A view representing the calendar display.
struct CalendarView: View {
  var startHour: Int
  var endHour: Int
  let calendarHeight: CGFloat
  let events: [Event]
  var use24HourFormat: Bool

  private let hourLabel: CGSize = .init(width: 38, height: 38)
  private let offsetPadding: Double = 10

  /// The height of each hour in the calendar.
  private var hourHeight: CGFloat {
    calendarHeight / CGFloat(endHour - startHour + 1)
  }

  /// Groups the overlapping events together.
  private var overlappingEventGroups: [[Event]] {
    EventProcessor.processEvents(events)
  }

  var body: some View {
    ScrollView(.vertical, showsIndicators: false) {
      ZStack(alignment: .topLeading) {
        timeHorizontalLines

        ForEach(overlappingEventGroups, id: \.self) { overlappingEvents in
          HStack(alignment: .top, spacing: 0) {
            ForEach(overlappingEvents) { event in
              eventCell(for: event)
            }
          }
        }
        .offset(x: hourLabel.width + offsetPadding)
        .padding(.trailing, hourLabel.width + offsetPadding)
      }
    }
    .frame(minHeight: calendarHeight, alignment: .bottom)
  }

  /// A view displaying the horizontal time lines in the calendar.
  private var timeHorizontalLines: some View {
    VStack(spacing: 0) {
      ForEach(startHour ... endHour, id: \.self) { hour in
        HStack(spacing: 10) {
          /// Display the formatted hour label.
          Text(formattedHour(hour))
            .font(.caption2)
            .monospacedDigit()
            .frame(width: hourLabel.width, height: hourLabel.height, alignment: .trailing)
          Rectangle()
            .fill(.gray.opacity(0.6))
            .frame(height: 1)
        }
        .foregroundColor(.gray)
        .frame(height: hourHeight, alignment: .top)
      }
    }
  }

  /// Formats the hour string based on the 24-hour format setting.
  ///
  /// - Parameter hour: The hour value to format.
  /// - Returns: The formatted hour string.
  private func formattedHour(_ hour: Int) -> String {
    if use24HourFormat {
      return String(format: "%02d:00", hour)
    } else {
      switch hour {
      case 0, 12:
        return "12 \(hour == 0 ? "am" : "pm")"
      case 13...23:
        return "\(hour - 12) pm"
      default:
        return "\(hour) am"
      }
    }
  }

  /// Creates a view representing an event cell in the calendar.
  ///
  /// - Parameter event: The event to display.
  /// - Returns: A view representing the event cell.
  private func eventCell(for event: Event) -> some View {
    let offsetPadding: CGFloat = 10

    var duration: Double {
      event.endDateTime.timeIntervalSince(event.startDateTime)
    }

    var height: Double {
      let timeHeight = (duration / 60 / 60) * Double(hourHeight)
      return timeHeight < 16 ? 16 : timeHeight
    }

    let calendar = Calendar.current

    var hour: Int {
      calendar.component(.hour, from: event.startDateTime)
    }

    var minute: Int {
      calendar.component(.minute, from: event.startDateTime)
    }

    var offset: Double {
      (Double(hour - startHour) * Double(hourHeight)) +
        (Double(minute) / 60 * Double(hourHeight)) +
        offsetPadding
    }

    return Text(event.title)
      .bold()
      .padding()
      .frame(maxWidth: .infinity, alignment: .leading)
      .frame(height: CGFloat(height))
      .minimumScaleFactor(0.6)
      .multilineTextAlignment(.leading)
      .background(
        Rectangle()
          .fill(Color.mint.opacity(0.6))
          .padding(1)
      )
      .offset(y: CGFloat(offset))
  }
}

/// A helper struct for processing events and grouping overlapping events.
fileprivate struct EventProcessor {

  /// Groups the given events based on overlapping time intervals.
  ///
  /// - Parameter events: The events to process.
  /// - Returns: An array of event groups where each group contains overlapping events.
  static func processEvents(_ events: [Event]) -> [[Event]] {
    let sortedEvents = events.sorted {
      $0.startDateTime < $1.startDateTime
    }
    var processedEvents: [[Event]] = []
    var currentEvents: [Event] = []
    for event in sortedEvents {
      if let latestEndTimeInCurrentEvents = currentEvents.map({ $0.endDateTime }).max(),
         event.startDateTime < latestEndTimeInCurrentEvents {
        currentEvents.append(event)
      } else {
        if !currentEvents.isEmpty {
          processedEvents.append(currentEvents)
        }
        currentEvents = [event]
      }
    }
    if !currentEvents.isEmpty {
      processedEvents.append(currentEvents)
    }
    return processedEvents
  }
}

extension Date {

  /// Creates a `Date` object from the given string representation.
  ///
  /// - Parameters:
  ///   - dateString: The string representing the date.
  ///   - format: The format of the date string. Default is "yyyy-MM-dd HH:mm".
  /// - Returns: A `Date` object created from the string representation.
  static func from(_ dateString: String, format: String = "yyyy-MM-dd HH:mm") -> Date {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = format
    return dateFormatter.date(from: dateString)!
  }
}

This code creates a CalendarView with the specified parameters, including the start and end hours, the height of the calendar, the events array, and whether to use the 24-hour format or not.

Conclusion

Building a daily list calendar in SwiftUI turned out to be more challenging than expected. However, by breaking down the requirements, researching existing solutions, and implementing the necessary components, we were able to create a functional and visually appealing calendar view.

Creating a calendar app involves considering various design aspects, handling event collisions, and implementing smooth interactions.

It's an intricate task that requires attention to detail and understanding of user expectations.

I hope this blog post has provided insights into building a daily list calendar in SwiftUI and inspired you to explore further possibilities in app development.


Help me write and make more!

You can help me continue to provide valuable content like this. If you found this article helpful, please consider supporting me.

Coffee Pizza Dinner