mallow
mallow

Reputation: 2856

How to group core data items by date in SwiftUI?

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.

Upvotes: 3

Views: 5575

Answers (4)

danylo.net
danylo.net

Reputation: 263

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

Upvotes: 1

Paul B
Paul B

Reputation: 5125

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

Upvotes: 1

Gene Bogdanovich
Gene Bogdanovich

Reputation: 1014

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.

Upvotes: 1

pbasdf
pbasdf

Reputation: 21536

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

Upvotes: 11

Related Questions