0

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!

markb
  • 1,100
  • 1
  • 15
  • 40

2 Answers2

0

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

valosip
  • 3,167
  • 1
  • 14
  • 26
  • hmm.. i tried your code but it seems to overlap them. when i tried changing from VStack to HStack, I get the horizontal alignment but the items double up and aren't offsetting vertically anymore: [image](https://postimg.cc/0zVPmdFV) – markb Jun 18 '23 at 09:18
  • @markb Made a quick update – valosip Jun 18 '23 at 19:14
0

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

markb
  • 1,100
  • 1
  • 15
  • 40
  • Nice, thanks for coming back with an update! Now for the next challenge. Create and distribute it as a package. – valosip Jun 23 '23 at 23:59