11

I'm trying to recreate a view that matches the event list in the stock iOS Calendar app. I have the following code which generates a list of events with each event separated into its own section by Date:

var body: some View {
    NavigationView {
        List {
            ForEach(userData.occurrences) { occurrence in
                Section(header: Text("\(occurrence.start, formatter: Self.dateFormatter)")) {
                    NavigationLink(
                        destination: OccurrenceDetail(occurrence: occurrence)
                            .environmentObject(self.userData)
                    ) {
                        OccurrenceRow(occurrence: occurrence)
                    }
                }
            }
        }
        .navigationBarTitle(Text("Events"))
    }.onAppear(perform: populate)
}

The problem with this code is that if there are two events on the same date, they are separated into different sections with the same title instead of being grouped together into the same section.

As a Swift novice, my instinct is to do something like this:

ForEach(userData.occurrences) { occurrence in
                if occurrence.start != self.date {
                    Section(header: Text("\(occurrence.start, formatter: Self.dateFormatter)")) {
                        NavigationLink(
                            destination: OccurrenceDetail(occurrence: occurrence)
                                .environmentObject(self.userData)
                        ) {
                            OccurrenceRow(occurrence: occurrence)
                        }
                    }
                } else {
                    NavigationLink(
                        destination: OccurrenceDetail(occurrence: occurrence)
                            .environmentObject(self.userData)
                    ) {
                        OccurrenceRow(occurrence: occurrence)
                    }
                }
                self.date = occurrence.start

But in Swift, this gives me the error "Unable to infer complex closure return type; add explicit type to disambiguate" because I'm calling arbitrary code (self.date = occurrence.start) inside ForEach{}, which isn't allowed.

What's the correct way to implement this? Is there a more dynamic way to execute this, or do I need to abstract the code outside of ForEach{} somehow?

Edit: The Occurrence object looks like this:

struct Occurrence: Hashable, Codable, Identifiable {
    var id: Int
    var title: String
    var description: String
    var location: String
    var start: Date
    var end: String
    var cancelled: Bool
    var public_occurrence: Bool
    var created: String
    var last_updated: String

    private enum CodingKeys : String, CodingKey {
        case id, title, description, location, start, end, cancelled, public_occurrence = "public", created, last_updated
    }
}

Update: The following code got me a dictionary which contains arrays of occurrences keyed by the same date:

let myDict = Dictionary( grouping: value ?? [], by: { occurrence -> String in
                            let dateFormatter = DateFormatter()
                            dateFormatter.dateStyle = .medium
                            dateFormatter.timeStyle = .none
                            return dateFormatter.string(from: occurrence.start)
                        })
            self.userData.latestOccurrences = myDict

However, if I try and use this in my View as follows:

ForEach(self.occurrencesByDate) { occurrenceSameDate in
//                    Section(header: Text("\(occurrenceSameDate[0].start, formatter: Self.dateFormatter)")) {
                    ForEach(occurrenceSameDate, id: occurrenceSameDate.id){ occurrence in
                        NavigationLink(
                            destination: OccurrenceDetail(occurrence: occurrence)
                                .environmentObject(self.userData)
                        ) {
                            OccurrenceRow(occurrence: occurrence)
                            }
                        }
//                    }
                }

(Section stuff commented out while I get the main bit working)

I get this error: Cannot convert value of type '_.Element' to expected argument type 'Occurrence'

Spielo
  • 481
  • 1
  • 6
  • 17
  • Could you include your definition of occurence please? – LuLuGaGa Oct 26 '19 at 21:44
  • 1
    You probably want to reformat the data that you are passing to the ForEach, so that it is already sectioned by date before trying to display it. – Andrew Oct 26 '19 at 21:55
  • @LuLuGaGa, updated with Occurrence. It's a simple object which gets populated with JSON data via codable. – Spielo Oct 26 '19 at 22:39
  • @Andrew, do you have an example of this? I tried this earlier by creating functions which return NavigationLink objects, but I couldn't get it to work. – Spielo Oct 26 '19 at 22:40

2 Answers2

30

In reference to my comment on your question, the data should be put into sections before being displayed.

The idea would be to have an array of objects, where each object contains an array of occurrences. So we simplify your occurrence object (for this example) and create the following:

struct Occurrence: Identifiable {
    let id = UUID()
    let start: Date
    let title: String
}

Next we need an object to represent all the occurrences that occur on a given day. We'll call it a Day object, however the name is not too important for this example.

struct Day: Identifiable {
    let id = UUID()
    let title: String
    let occurrences: [Occurrence]
    let date: Date
}

So what we have to do is take an array of Occurrence objects and convert them into an array of Day objects.

I have created a simple struct that performs all the tasks that are needed to make this happen. Obviously you would want to modify this so that it matches the data that you have, but the crux of it is that you will have an array of Day objects that you can then easily display. I have added comments through the code so that you can clearly see what each thing is doing.

struct EventData {
    let sections: [Day]

    init() {
        // create some events
        let first = Occurrence(start: EventData.constructDate(day: 5, month: 5, year: 2019), title: "First Event")
        let second = Occurrence(start: EventData.constructDate(day: 5, month: 5, year: 2019, hour: 10), title: "Second Event")
        let third = Occurrence(start: EventData.constructDate(day: 5, month: 6, year: 2019), title: "Third Event")

        // Create an array of the occurrence objects and then sort them
        // this makes sure that they are in ascending date order
        let events = [third, first, second].sorted { $0.start < $1.start }

        // create a DateFormatter 
        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .medium
        dateFormatter.timeStyle = .none

        // We use the Dictionary(grouping:) function so that all the events are 
        // group together, one downside of this is that the Dictionary keys may 
        // not be in order that we require, but we can fix that
        let grouped = Dictionary(grouping: events) { (occurrence: Occurrence) -> String in
            dateFormatter.string(from: occurrence.start)
        }

        // We now map over the dictionary and create our Day objects
        // making sure to sort them on the date of the first object in the occurrences array
        // You may want a protection for the date value but it would be 
        // unlikely that the occurrences array would be empty (but you never know)
        // Then we want to sort them so that they are in the correct order
        self.sections = grouped.map { day -> Day in
            Day(title: day.key, occurrences: day.value, date: day.value[0].start)
        }.sorted { $0.date < $1.date }
    }

    /// This is a helper function to quickly create dates so that this code will work. You probably don't need this in your code.
    static func constructDate(day: Int, month: Int, year: Int, hour: Int = 0, minute: Int = 0) -> Date {
        var dateComponents = DateComponents()
        dateComponents.year = year
        dateComponents.month = month
        dateComponents.day = day
        dateComponents.timeZone = TimeZone(abbreviation: "GMT")
        dateComponents.hour = hour
        dateComponents.minute = minute

        // Create date from components
        let userCalendar = Calendar.current // user calendar
        let someDateTime = userCalendar.date(from: dateComponents)
        return someDateTime!
    }

}

This then allows the ContentView to simply be two nested ForEach.

struct ContentView: View {

    // this mocks your data
    let events = EventData()

    var body: some View {
        NavigationView {
            List {
                ForEach(events.sections) { section in
                    Section(header: Text(section.title)) {
                        ForEach(section.occurrences) { occurrence in
                            NavigationLink(destination: OccurrenceDetail(occurrence: occurrence)) {
                                OccurrenceRow(occurrence: occurrence)
                            }
                        }
                    }
                }
            }.navigationBarTitle(Text("Events"))
        }
    }
}

// These are sample views so that the code will work
struct OccurrenceDetail: View {
    let occurrence: Occurrence

    var body: some View {
        Text(occurrence.title)
    }
}

struct OccurrenceRow: View {
    let occurrence: Occurrence

    var body: some View {
        Text(occurrence.title)
    }
}

This is the end result.

Nested Views

Andrew
  • 26,706
  • 9
  • 85
  • 101
  • 1
    Thanks for making this so easy to follow! Between this and E.Com's answer, I developed a much better understanding of good practice when working in Swift and got something working exactly how I wanted it. Thanks again! – Spielo Oct 28 '19 at 13:52
  • How would one implement onDelete with this? I've tried to add it to either ForEach but it messes up the IndexSet and deletes the wrong items. – fasoh Oct 31 '19 at 20:21
  • @fasoh You would need to track the section index and the row index to make sure that you delete the correct item. Ideally you would want the `.onDelete` on the inner most `ForEach` – Andrew Oct 31 '19 at 21:40
  • Thank you, I figured out how to track the indices properly but it brought up another question. In this example we're fetching the occurrences as an array which, to my understanding, should be the @ObservedObject used to allow adding and editing items. Knowing both the section and occurrence index, how would one implement the actual deletion? From my understanding I should remove from the original occurrences array in order to properly update and propagate the sections to the view. – fasoh Nov 01 '19 at 20:59
  • @fasoh In this example, the data object that I created was to give a working example, it isn't particularly relevant to the question other than how to take a list of items and convert them into a grouped list. Whether using an observed object or not was not particularly important at the time as the original question poster already had a data source. – Andrew Nov 01 '19 at 21:40
  • @fasoh It is clear that you have further questions. I would suggest writing up the relevant parts into a new question so that you can clearly express everything that you need to do, as the comments on SO do not really allow a detailed discussion to take place. For one, code does not format nicely here; and secondly, you are limited to 600 characters, so it makes it hard to express oneself fully. – Andrew Nov 01 '19 at 21:41
  • This looks exactly what I need for my current project. But how to use it with CoreData? Those structs should be entities or one should be extension of another? – mallow Mar 26 '20 at 23:55
  • @mallow I would suggest writing what you have into a new question as the comments are not a place for this type of discussion. Comments have limited space and code formatting does not work well. I would include in your question what you have done so far, what you have tried, and express clearly what you are trying to accomplish. Remember to include a [MVE](https://stackoverflow.com/help/minimal-reproducible-example) so that those trying to help you are better placed to do so. – Andrew Mar 27 '20 at 07:49
  • Thank you, @Andrew. I have created a question before: https://stackoverflow.com/questions/60831611/updated-how-to-group-core-data-items-by-date-in-swiftui But now, following your advice, I have added code that I already have and added more examples of what I have tried already. Thanks for advice! I hope after this I will have more chance for an answer – mallow Mar 27 '20 at 08:20
2

This is actually two questions.

In the data part, please upgrade userData.occurrences from [Occurrence] to [[ Occurrence ]] (I called it latestOccurrences, here)

   var self.userData.latestOccurrences = Dictionary(grouping: userData.occurrences) { (occurrence: Occurrence) -> String in
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
return dateFormatter.string(from:  occurrence.start)
 }.values

In the swiftUI, you just reorganize the latest data:

    NavigationView {
    List {
        ForEach(userData.latestOccurrences, id:\.self) { occurrenceSameDate in
            Section(header: Text("\(occurrenceSameDate[0].start, formatter: DateFormatter.init())")) {
                ForEach(occurrenceSameDate){ occurrence in
                NavigationLink(
                    destination: OccurrenceDetail(occurrence: occurrence)
                        .environmentObject(self.userData)
                ) {
                    OccurrenceRow(occurrence: occurrence)
                    }
                }
            }
        }
    }
    .navigationBarTitle(Text("Events"))
}.onAppear(perform: populate)
E.Coms
  • 11,065
  • 2
  • 23
  • 35
  • That's going to create a DateFormatter for each item in the occurrences array. Formatters are expensive to create, it would be better to create the formatter outside of the grouping. – Andrew Oct 27 '19 at 17:05
  • @E.Coms Thanks very much for this, it has put me on the right path. I couldn't get your example to work as-is, it gave me errors like "Cannot assign value of type 'Dictionary.Values' to type '[String : [Occurrence]]'" and I couldn't work out how to make the variable conform correctly. I'll update the question with my current status. – Spielo Oct 27 '19 at 17:57
  • You don’t need the dict but the values of the dict which is [[occurrence]] – E.Coms Oct 27 '19 at 20:35
  • That is a 2d array – E.Coms Oct 27 '19 at 20:37
  • Ah! I think I see, I was reaching that conclusion but couldn't quite see how to proceed with it. I'll give it a try tomorrow! Thanks again. – Spielo Oct 27 '19 at 20:42
  • Got it working! Thanks so much to both you and Andrew. Your answer/example gave me a much better understanding of what's going on behind the scenes. – Spielo Oct 28 '19 at 13:50