iosswiftswiftuiipados

How can I make SwiftUI events split horizontally when there's overlap?


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:

incorrect overlapping

correct splitting

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!


Solution

  • 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.

    output