skip to Main Content

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!

2

Answers


  1. Chosen as BEST ANSWER

    So I wanted to expand on valosip's answeras it helped me get to my end result. But I wanted to flesh it out for future users who might want to see some more in-depth workings.

    I have written about it in more detail here in case anyone wants to read the creation of below

    First I was working with this model:

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

    And created these mockEvents for testing:

    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")
        )
      ]
    }
    

    I also used this Date helper to format string dates into proper dates:

    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)!
      }
    }
    

    The main view - which you can put into your other SwiftUI views looked like this:

    /// 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
        )
      }
    }
    

    The CalendarView looked like the below. I added as much comments as I could to help explain what was going on:

    /// 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))
      }
    }
    

    But the most important part of the whole code, which solved the collisions was this EventProcessor function:

    /// 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
      }
    }
    

    In the end, I had something that looked like this:

    Again, if there is anyone that wants to see or read more of how I got to this place with the help of the StackOverflow answer read it all here


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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search