TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SOLVED: Creating a daily view calendar with overlapping events

Forums > SwiftUI

Hi everyone! I am trying to create a SwiftUI view where I can list all the events for that day.

I currently have the view working where it shows the information as long as none of the events overlap.

I'm struggling to find out / figure out how to make the events split horizontally when the hour or minutes overlap for an event.

This is where I am at now, some of the code is just helper data or functions:

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]
    let events: [Event] = [
        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)
    }
}

This results in something like this:

Code output

However, as you can slightly see, "Event 1" and "Event 2" are over lapped. What I would like is for them to be side-by-side so it would look like this:

intention output

And of course, if more events overlapped, then it would split into 1/3, 1/4, 1/5, etc.

Hopefully someone can help or provide guidance!

1      

@markb would like a date with a view specialist!

I'm struggling to find out / figure out how to make the events split horizontally when the hour or minutes overlap for an event.

Thanks for posting your code!

Your approach gives us an opportunity to review some of SwiftUI's guiding principles namely: Data Models and Declarative Views!

Your Code

// This is the view factory that builds each calendar event.
ForEach(events) { event in
    // Interpretation: Build a single view, for this ONE event. 
    // Also, it will be this high.
    eventCell(event, hourHeight: hourHeight)
}

I like to think of the ForEach() as a view factory. You provide parts of your data model, and the view factory publishes views that SwiftUI will stack up and add to the presentation layer that your users sees!

Let's review your code. In the view factory snip, I interpret it like this: Build a single eventCell() for each event I have. Indeed, this is what you've coded, and what SwiftUI builds in the screenshot you provided.

But SwiftUI is also a declarative language. That is you need to declare what you want to view!

New Approach

Instead, what you probably want to declare is: Layout all the events in a row for thisHour.

This will require you to change your data model a bit. But that's the beauty of structs and rapid development. Take your business logic out of your views and stuff it into your data models where it belongs.

In your calendarEvents data model, you'll want a computed var that is a Set of startTimes. These are the unique startTimes of all the events for a given day. It's a set because Sets can only store unique values.

Next you'll want func that returns an array of all your events that start at a given time. This will be a collection of events. Give this a clever name for example:

// Return an array of Events that all start at the same hour.  9:00 AM.  9:15 AM, etc.
func allEvents(startingAt: Date) -> [Event] {  // clever code here }

Finally, you'll modify your ForEach() view factory to build an eventCell() with a different signature. Your view factory will loop over the startTimes collection. For each startTime, you'll generate an array of events using the allEvents() func. Also note, you're not creating an event cell anymore. It could be a collection of cells. Consider renaming this method eventCells().

Now you know how many events start at the same time, and your code within eventCells() can accommodate your use cases.

// New approach. DECLARE what you want to see!
ForEach( eventDataModel.startTimes, id:\.self ) { eventStartTime in
    let eventsStartingAtTheSameTime = allEvents(startingAt: eventStartTime) // <-- often just one, but could be several
    // New View struct. Pass in the array of events, instead of a single event
    eventCells( eventsStartingAtTheSameTime, cellHeight: hourHeight). // <-- new method signature

Keep coding!

These are broad concepts and guidance, you'll need to adopt to your needs. Please return here and let us all know how you solved this!

Related links

Here are a few articles I wrote about view factories. They may be fun reads!

See -> View Factory
or See -> View Factory
or See -> View Factory

1      

More about your Data Model

Please consider your data model and the calendar view you are trying to build as two separate parts of your application.

Think of two smart people in TWO different offices. Office One only sees "data". This office has ALL the events for all the days. It also knows which day the user has selected. It probably knows other information about your application's data.

Office One, the Data Modeler

When your user taps on a new date, or selects a date from a pick list, you might think about first notifying your Data Model. Send a note to Office One and let the Data Model know that the user's date selection has changed.

Then the person in this office will get really busy. He'll create other lists (computed vars!) that will extract all the events for a given day. He'll also extract a list of start times for each activity. He's an organizing fool. Give him the tasks that organize your data. Then let him pass this to the View Painter. That is, he'll @Publish the new data!

// Data Model
class CoolActivities: Identifiable, Observable {
     var id: UUID() 
     private var selectedDate: Date  // When this changes, recalculate lists for the View Painter
     private var allActivities: [Event]?  // All the events 

     // Use this function in your VIEW code.
     // When the user selects a NEW date, inform the DATAMODEL about this change!
     public func userSelected(newDate: Date) {
         selectedDate = newDate
     }

     // This is a published var. When this changes, any view observing this will also get updated.
     @Published var selectedEvents: [Event]? { // <-- This returns an optional array of events
          // select all the events from your collection for the date selected by your user
          return allEvents.filter{ $0  // Here you need to filter for events on the selectedDate
      }

      // more class functions
 }

Office Two, the View Painter

The second office is where you'll find the view painter. When the Data Modeler updates the @Published vars with new data, the View Painter will get notes to repaint, and will generate a brand new view for your user to enjoy.

The View Painter should not need to know the rules of Events, or the internal workings of start and end times.

If she needs to add two events to the same hour, let her know the details.

Here are two events. Paint them side by side with these window constraints. Titles and extra info are included.

Make her job easy! Just tell her what data she needs for the given hour, and she'll use the rules laid out in the view to lay out one, or several activies.

1      

Hacking with Swift is sponsored by String Catalog.

SPONSORED Get accurate app localizations in minutes using AI. Choose your languages & receive translations for 40+ markets!

Localize My App

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.