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:
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:
- Events smaller than 12 minutes in duration had a default minimum height
- 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
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:
- The hour heights were flexible and stretched or squashed based on the window height
- Time was displayed in 12-hour or 24-hour format based on the System Preferences on the clock
- 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.
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:
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:
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.
If you found this article helpful, you can keep the ideas flowing by supporting me. Buy me a coffee or check out my apps to help me create more content like this!
Coffee Check out my apps