How to group core data items by date in SwiftUI?
Asked Answered
V

4

3

What I have in my iOS app is:

TO DO ITEMS

To do item 3
24/03/2020
------------
To do item 2
24/03/2020
------------
To do item 1
23/03/2020
------------

What I would like to have is:

TO DO ITEMS


24/03

To do item 3
24/03/2020
------------
To do item 2
24/03/2020
------------


23/03

To do item 1
23/03/2020
------------

===============

What I have so far:

I am using Core Data and have 1 Entity: Todo. Module: Current Product Module. Codegen: Class Definition. This entity has 2 attributes: title (String), date (Date).

ContentView.swift Displays the list.

import SwiftUI

struct ContentView: View {
    @Environment(\.managedObjectContext) var moc
    @State private var date = Date()
    @FetchRequest(
        entity: Todo.entity(),
        sortDescriptors: [
            NSSortDescriptor(keyPath: \Todo.date, ascending: true)
        ]
    ) var todos: FetchedResults<Todo>

    @State private var show_modal: Bool = false

    var dateFormatter: DateFormatter {
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        return formatter
    }

    // func to group items per date. Seemed to work at first, but crashes the app if I try to add new items using .sheet
    func update(_ result : FetchedResults<Todo>)-> [[Todo]]{
        return  Dictionary(grouping: result){ (element : Todo)  in
            dateFormatter.string(from: element.date!)
        }.values.map{$0}
    }

    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(update(todos), id: \.self) { (section: [Todo]) in
                        Section(header: Text( self.dateFormatter.string(from: section[0].date!))) {
                            ForEach(section, id: \.self) { todo in
                                HStack {
                                    Text(todo.title ?? "")
                                    Text("\(todo.date ?? Date(), formatter: self.dateFormatter)")
                                }
                            }
                        }
                    }.id(todos.count)

                    // With this loop there is no crash, but it doesn't group items
                    //ForEach(Array(todos.enumerated()), id: \.element) {(i, todo) in
                    //    HStack {
                    //        Text(todo.title ?? "")
                    //        Text("\(todo.date ?? Date(), formatter: self.dateFormatter)")
                    //    }
                    //}

                }
            }
            .navigationBarTitle(Text("To do items"))
            .navigationBarItems(
                trailing:
                Button(action: {
                    self.show_modal = true
                }) {
                    Text("Add")
                }.sheet(isPresented: self.$show_modal) {
                    TodoAddView().environment(\.managedObjectContext, self.moc)
                }
            )
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
        return ContentView().environment(\.managedObjectContext, context)
    }
}

TodoAddView.swift In this view I add new item.

import SwiftUI

struct TodoAddView: View {

    @Environment(\.presentationMode) var presentationMode
    @Environment(\.managedObjectContext) var moc

    static let dateFormat: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        return formatter
    }()

    @State private var showDatePicker = false
    @State private var title = ""
    @State private var date : Date = Date()

    var body: some View {
        NavigationView {

            VStack {
                HStack {
                    Button(action: {
                        self.showDatePicker.toggle()
                    }) {
                        Text("\(date, formatter: Self.dateFormat)")
                    }

                    Spacer()
                }

                if self.showDatePicker {
                    DatePicker(
                        selection: $date,
                        displayedComponents: .date,
                        label: { Text("Date") }
                    )
                        .labelsHidden()
                }

                TextField("title", text: $title)

                Spacer()

            }
            .padding()
            .navigationBarTitle(Text("Add to do item"))
            .navigationBarItems(
                leading:
                Button(action: {
                    self.presentationMode.wrappedValue.dismiss()
                }) {
                    Text("Cancel")
                },

                trailing:
                Button(action: {

                    let todo = Todo(context: self.moc)
                    todo.date = self.date
                    todo.title = self.title

                    do {
                        try self.moc.save()
                    }catch{
                        print(error)
                    }

                    self.presentationMode.wrappedValue.dismiss()
                }) {
                    Text("Done")
                }
            )
        }
    }
}

struct TodoAddView_Previews: PreviewProvider {
    static var previews: some View {
        TodoAddView()
    }
}

I have tried this: I have searched for some examples. One looked good: How to properly group a list fetched from CoreData by date? and I have used the update function and ForEach from there, but it doesn't work with .sheet in SwiftUI. When I open the .sheet (after tapping Add) the app crashes with an error:

Thread 1: Exception: "Attempt to create two animations for cell"

How to fix it? Or is there another way of grouping core data by date? I have been told that I should add grouping to my data model. And just show it later in UI. I don't know where to start.

Another guess is that I maybe could edit my @FetchRequest code to add grouping there. But I am searching for a solution few days without luck. I know there is a setPropertiesToGroupBy in Core Data, but I don't know if and how it works with @FetchRequest and SwiftUI.

Another guess: Is it possible to use Dictionary(grouping: attributeName) to group CoreData Entity instances in SwiftUI based on their attributes? Grouping arrays looks so easy: https://www.hackingwithswift.com/example-code/language/how-to-group-arrays-using-dictionaries , but I don't know if and how it works with Core Data and @FetchRequest.

Veritable answered 24/3, 2020 at 13:3 Comment(12)
If you want to group then you need to use a List I presume or you will have to do it manually.Kalfas
Thanks. I think that List is a good idea. But how to fetch the items from core data, group them and sort by id in each group? I have been told to make groups in data model. But this I really don't know how to start with :/Veritable
In your example you are grouping by date and if that is what you want then the linked answer should help. You also mention Category in your question and if that is what you want then you should add an entity for it to your core data model with a one-to-many relationship so that an Item can have one Category.Kalfas
This linked answer doesn't work with SwiftUI .sheet. It crashes the app. Let's forget about predefined categories for now. The minimum I would like to have is grouping by dateVeritable
Honestly I don't understand what .sheet has to do with it, isn't that just a type of window? Can't you have a List in a sheet or what is the issue? The linked answer is using SwiftUI so that claim must be wrong on your part.Kalfas
I don’t understand too why it doesn’t work. But it doesn’t. You can read comments there. It didn’t work with sheet for the person that asked the question too. I have tried their code and it works when I add new random items with the code provided. But if I want to add new item on a modal (where I could add todo title) the app crashes right after taping Add button to open modal (.sheet).Veritable
Oh, sorry I didn't read it that carefully. Well maybe there is some bug involved, SwiftUI is quite new after all. Maybe you need to prioritize then, created grouped data or use a sheet. Or as I said earlier, do it manually by adding some kind of separator each time date changes (assuming the array is sorted by date)Kalfas
@joakim You can see my code here: #60829478 I have just added sheet that opens after Add button is tapped. This modal works in my other projects without problems. But it doesn’t work with this code to group items. I asked for the reason of crash in this linked question.Veritable
Yes, thanks. Maybe it’s a bug. But I will try another solution for grouping. I need to use modal (.sheet) in this app. Will try another code that will hopefully workVeritable
SwiftUI sheets don't inherit the ManagedObjectContext, so you need to set it manually when you call it by writing .sheet().environment(\.managedObjectContext, self.managedObjectContext)Franciscofranciska
Also, is this considered a performant approach? Wouldn't calling .date on every object results in Core Data faulting every object just to refresh the UI?Franciscofranciska
@Franciscofranciska Thanks, I already have sheet(isPresented: self.$show_modal) {TodoAddView().environment(\.managedObjectContext, self.moc)} You mean I should add your code somewhere else? I don't understand this .date comment. Could you elaborate? If you know how I could improve performance, I am all ears :)Veritable
E
11

I'm just getting into SwiftUI myself, so this might be a misunderstanding, but I think the issue is that the update function is unstable, in the sense that it does not guarantee to return the groups in the same order each time. SwiftUI consequently gets confused when new items are added. I found that the errors were avoided by specifically sorting the array:

func update(_ result : FetchedResults<Todo>)-> [[Todo]]{
    return  Dictionary(grouping: result){ (element : Todo)  in
        dateFormatter.string(from: element.date!)
    }.values.sorted() { $0[0].date! < $1[0].date! }
}
Exobiology answered 28/3, 2020 at 10:2 Comment(0)
M
1

I'm also quite new to programming and the following solution might be a little less than elegant but ngl I'm quite proud to figure it out myself!

I added a bool to my object named lastOfDay that triggers a textview of the date on that object:

ForEach(allResults) { result in
    VStack(spacing: 0) {
        if currentMethod == .byDate {
            if result.lastOfDay {
                Text("\(result.date, formatter: dateFormatter)")
            }
        }
        ListView(result: result)
    }
}

Then I have an onAppear function that copies my fetched results to a separate, non-CoreData array, organizing them by date and checking whether the next result's day is different from the current object's day - and flipping the necessary bools. I hoped to achieve this through some version of .map but figured that it was necessary to account for situations when the list was empty or only had a single item.

if allResults.count != 0 {
    if allResults.count == 1 {
        allResults[0].lastOfDay = true
    }

    for i in 0..<(allResults.count-1) {

        if allResults.count > 1 {

            allResults[0].lastOfDay = true

            if allResults[i].date.hasSame(.day, as: allResults[i+1].date)  {
                allResults[i+1].lastOfDay = false
            } else {
                allResults[i+1].lastOfDay = true
            }
        }
    }
}

The hasSame date extension method I picked up on in this answer. I don't know how well this approach will work if you desire to let the user delete batches but it works perfectly for me because I only want to implement either singular deletes or delete all objects (however since I trigger the filtering process every time such a change happens - usage might get expensive w bigger data sets).

Mccue answered 3/1, 2021 at 20:35 Comment(0)
F
1

Embedding grouping into a managed object model is the way to go because it would be more robust and will work well with large data sets. I have provided an answer with a sample project on how to implement it.

When we are using init(grouping:by:) on Dictionary, we are likely recompiling the whole list, backed by a dictionary that does the grouping, every time we perform any list manipulation such as insertion or deletion, which is not performant and, in my case, causes jaggy animations. It doesn’t make sense performance-wise to fetch entities from the store sorted one way and then do the sorting locally again to divide them into sections.

Grouping with fetch request is not possible, as far as I know, because propertiesToGroupBy is the Core Data interface for using SQL GROUP BY query and is meant to be used with aggregate functions (e.g. min, max, sum) and not to divide data sets into sections.

Florescence answered 19/4, 2021 at 7:58 Comment(0)
J
1

Looks like @SectionedFetchRequest property wrapper exists just for such task. Below is an example made from boilerplate CoreData project. The key is that you have to mark a var you're sectioning by as @objc.

extension Todo {
    @objc
    var sect: String { date?.formatted(date: .abbreviated, time: .omitted) ?? "Undefined" }
}

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @SectionedFetchRequest(
        sectionIdentifier: \Todo.sect,
        sortDescriptors: [NSSortDescriptor(keyPath: \Todo.date, ascending: true)],
        animation: .default)
    private var items

    var body: some View {
        NavigationView {
            List {
                ForEach(items) { section in
                    Section(section.id) {
                        ForEach(section) { item in
                            NavigationLink {
                                Text("Item at \(item.date!, format: .dateTime.year().month().hour().minute().second())")
                            } label: {
                                Text(item.date!, formatter: itemFormatter)
                            }
                        }
                    }
                }
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
            Text("Select an item")
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = Todo(context: viewContext)
            newItem.date = Date()

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}

extension Todo {
    static var dateFormatter: DateFormatter {
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        return formatter
    }
}
Jerome answered 18/12, 2022 at 11:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.