I'm working on a SwiftUI view to display events in a calendar. Currently, the events are displayed vertically, and I have them working fine when there are no overlaps.
However, I'm struggling to figure out how to handle events that overlap in terms of their hours or minutes. I want these overlapping events to be displayed horizontally, side-by-side.
I have already implemented the basic functionality, and I've included some sample code to demonstrate the current state of my implementation.
import SwiftUI
struct Event: Identifiable, Decodable {
var id: UUID { .init() }
var startDate: Date
var endDate: Date
var title: String
}
extension Date {
static func dateFrom(_ day: Int, _ month: Int, _ year: Int, _ hour: Int, _ minute: Int) -> Date {
let calendar = Calendar.current
let dateComponents = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute)
return calendar.date(from: dateComponents) ?? .now
}
}
struct CalendarComponent: View {
var startHour: Int = 9
var endHour: Int = 17
// let events: [Event] // <--- would be passed in
let events: [Event] = [ // <--- mock entries
Event(startDate: .dateFrom(9, 5, 2023, 9, 15), endDate: .dateFrom(9, 5, 2023, 10, 15), title: "Event 1"),
Event(startDate: .dateFrom(9, 5, 2023, 9, 0), endDate: .dateFrom(9, 5, 2023, 10, 0), title: "Event 2"),
Event(startDate: .dateFrom(9, 5, 2023, 11, 0), endDate: .dateFrom(9, 5, 2023, 12, 00), title: "Event 3"),
Event(startDate: .dateFrom(9, 5, 2023, 13, 0), endDate: .dateFrom(9, 5, 2023, 14, 45), title: "Event 4"),
Event(startDate: .dateFrom(9, 5, 2023, 15, 0), endDate: .dateFrom(9, 5, 2023, 15, 45), title: "Event 5")
]
let calendarHeight: CGFloat // total height of calendar
private var hourHeight: CGFloat {
calendarHeight / CGFloat( endHour - startHour + 1)
}
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
ZStack(alignment: .topLeading) {
VStack(spacing: 0) {
ForEach(startHour ... endHour, id: \.self) { hour in
HStack(spacing: 10) {
Text("\(hour)")
.font(.caption2)
.foregroundColor(.gray)
.monospacedDigit()
.frame(width: 20, height: 20, alignment: .center)
Rectangle()
.fill(.gray.opacity(0.5))
.frame(height: 1)
}
.frame(height: hourHeight, alignment: .top)
}
}
ForEach(events) { event in
eventCell(event, hourHeight: hourHeight)
}
.frame(maxHeight: .infinity, alignment: .top)
.offset(x: 30, y: 10)
}
}
.frame(minHeight: calendarHeight, alignment: .bottom)
}
private func eventCell(_ event: Event, hourHeight: CGFloat) -> some View {
var duration: Double { event.endDate.timeIntervalSince(event.startDate) }
var height: Double { (duration / 60 / 60) * hourHeight }
let calendar = Calendar.current
var hour: Int { calendar.component(.hour, from: event.startDate) }
var minute: Int { calendar.component(.minute, from: event.startDate) }
// hour + minute + padding offset from top
var offset: Double {
((CGFloat(hour - 9) * hourHeight) + (CGFloat(minute / 60) * hourHeight) + 10)
}
return Text(event.title).bold()
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: height)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(.red.opacity(0.2))
.padding(.trailing, 30)
)
.offset(y: offset)
}
}
The code produces a calendar view where events are displayed vertically, but I would like them to be split horizontally when there is an overlap. For example, if "Event 1" and "Event 2" overlap, I want them to be displayed side-by-side.
I've attached two images to illustrate the current output and the desired outcome:
I'm seeking guidance on how to modify my code to achieve this horizontal splitting of events when there is an overlap. Targeting iOS / iPadOS 15.6 and above.
Any suggestions or insights would be greatly appreciated. Thank you in advance for your help!
There are a few ways to go about doing this. Personally I'd update the format of the events. You need to find the overlapping/collision events.
Add an extension to Events to compare one to another, or create a function that compares one Event to another. These are the simplest cases..
extension Event {
func overlaps(_ event: Event) -> Bool {
let leftRange = self.startDate ... self.endDate
let rightRange = event.startDate ... event.endDate
return leftRange.overlaps(rightRange)
}
}
OR
func doEventsOverlap(_ lhs: Event, _ rhs: Event) -> Bool {
let leftRange = lhs.startDate ... lhs.endDate
let rightRange = rhs.startDate ... rhs.endDate
return leftRange.overlaps(rightRange)
}
Create a function to group your events:
private func groupEvents(_ events: [Event]) -> [[Event]] {
var groupedEvents: [[Event]] = []
// You'll want to compare the events here using one of the above, and if they overlap, group them together. For my outcome I simply compared 2 events, but you'll want to make this more dynamic
// If you're having trouble with this part, update your questions with what the issues are
// ...
return groupedEvents
}
Update you UI to loop over your grouped events. Replace your ForEach(events)
with something like this:
ForEach(groupEvents(events), id: \.self) { list in
HStack {
ForEach(list) { event in
eventCell(event, hourHeight: hourHeight)
}
}
}
.frame(maxHeight: .infinity, alignment: .top)
.offset(x: 30, y: 10)
Below you'll see a quick output from my sudo code. It's quick and dirty just to give an idea.