18

For the sake of simplicity lets assume I want to create a simple todo app. I have an entity Todo in my xcdatamodeld with the properties id, title and date, and the following swiftui view (example pictured):

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>

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

  var body: some View {
    VStack {
      List {
        ForEach(todos, id: \.self) { todo in
          HStack {
            Text(todo.title ?? "")
            Text("\(todo.date ?? Date(), formatter: self.dateFormatter)")
          }
        }
      }
      Form {
        DatePicker(selection: $date, in: ...Date(), displayedComponents: .date) {
          Text("Datum")
        }
      }
      Button(action: {
        let newTodo = Todo(context: self.moc)
        newTodo.title = String(Int.random(in: 0 ..< 100))
        newTodo.date = self.date
        newTodo.id = UUID()
        try? self.moc.save()
      }, label: {
        Text("Add new todo")
      })
    }
  }
}

The todos are sorted by date upon fetching, and are displayed in a list like this:

What I got

I want to group the list based on each todos respective date as such (mockup):

What I want

From my understanding this could work with Dictionaries in the init() function, however I couldn't come up with anything remotely useful. Is there an efficient way to group data?

fasoh
  • 504
  • 4
  • 17

3 Answers3

19

Update for iOS 15

SwiftUI now has built-in support for Sectioned Fetch Requests in a List via the @SectionedFetchRequest property wrapper. This wrapper reduces the amount of boilerplate required to group Core Data lists.

Example code

@Environment(\.managedObjectContext) var moc
@State private var date = Date()
@SectionedFetchRequest( // Here we use SectionedFetchRequest
  entity: Todo.entity(),
  sectionIdentifier: \.dateString // Add this line
  sortDescriptors: [
    SortDescriptor(\.date, order: .forward)
  ]
) var todos: SectionedFetchResults<Todo>

var body: some View {
    VStack {
      List {
        ForEach(todos) { (section: [Todo]) in
            Section(section[0].dateString!))) {
                ForEach(section) { todo in
                    HStack {
                        Text(todo.title ?? "")
                        Text("\(todo.date ?? Date(), formatted: todo.dateFormatter)")
                    }
                }
            }
        }.id(todos.count)
      }
      Form {
        DatePicker(selection: $date, in: ...Date(), displayedComponents: .date) {
          Text("Datum")
        }
      }
      Button(action: {
        let newTodo = Todo(context: self.moc)
        newTodo.title = String(Int.random(in: 0 ..< 100))
        newTodo.date = self.date
        newTodo.id = UUID()
        try? self.moc.save()
      }, label: {
        Text("Add new todo")
      })
    }

The Todo class can also be refactored to contain the logic for getting the date string. As a bonus, we can also use the .formatted beta method on Date to produce the relevant String.

struct Todo {
  
  ...

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

  var dateString: String? {
    formatter.string(from: date)
  }
}
Daniel
  • 1,473
  • 3
  • 33
  • 63
Pranav Kasetti
  • 8,770
  • 2
  • 50
  • 71
16

You may try the following, It should work in your situation.

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

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

    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 {
    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)
      }
      Form {
        DatePicker(selection: $date, in: ...Date(), displayedComponents: .date) {
          Text("Datum")
        }
      }
      Button(action: {
        let newTodo = Todo(context: self.moc)
        newTodo.title = String(Int.random(in: 0 ..< 100))
        newTodo.date = self.date
        newTodo.id = UUID()
        try? self.moc.save()
      }, label: {
        Text("Add new todo")
      })
    }
  }
Wai Ha Lee
  • 8,598
  • 83
  • 57
  • 92
E.Coms
  • 11,065
  • 2
  • 23
  • 35
  • 1
    This looks really good! Thank you for taking the time to answer. Unfortunately I'm getting exceptions whenever I add or delete from the ManagedObjectContext and I can't seem to figure out why. Do you have any ideas? *** Assertion failure in -[_UITableViewUpdateSupport _setupAnimationsForNewlyInsertedCells], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKitCore/UIKit-3900.12.16/UITableViewSupport.m:1311 *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Attempt to create two animations for cell' – fasoh Dec 04 '19 at 19:02
  • Did you run with the exact code? Maybe you miss some thing from the above. like `id(todos.count)` – E.Coms Dec 04 '19 at 19:03
  • You're right, your example works fine. I was being overly optimistic and inserted a details page for each todo via a .sheet on the List-element. That seems to be the culprit. I haven't found a solution yet but will try and report back. – fasoh Dec 04 '19 at 19:18
  • I avoided the problem for now by replacing the sheet with a NavigationLink (which works better for what I have in mind anyways). Thank you for your help, learned a lot today. – fasoh Dec 04 '19 at 19:45
  • @E.Coms What would I need to do to make deleting entries work using .onDelete? Do I need to manually track the index to delete from the original list given by the fetch request? Is there a simpler way? – Ramen Mar 20 '20 at 18:52
  • I am getting the same "Assertion failure (...)". The code as it is presented worked. But if I change it to add new items on a .sheet, my app crashes and I am getting the error. Did anyone find a solution to this error? – mallow Mar 24 '20 at 10:21
  • Tried again with .sheet. The app crashes and I am getting an error: "Exception: "Attempt to create two animations for cell". Has anyone manage to use this solution with .sheet modals? – mallow Mar 27 '20 at 01:01
  • 4
    pbasdf helped me to solve this problem. All the credits to him. Changing `}.values.map{$0}` to `}.values.sorted() { $0[0].date! < $1[0].date! }` makes it work with .sheet and the errors are gone. – mallow Mar 28 '20 at 10:26
  • 1
    The biggest issue with this solution is that the Dictionary will have no sorting and whatever Sorting you did in the NSFetchRequest will be lost :/ – iSebbeYT Apr 04 '20 at 11:38
  • This works, so thankful I found this solution, but agree with @iSebbeYT that this solution is a bit fragile as there are two SoTs here, the one returned by `update:` and the original fetched results. :/ – SeeMeCode Aug 16 '20 at 20:39
5

To divide SwiftUI List backed by Core Data into sections, you can change your data model to support grouping. In this particular case, this can be achieved by introducing TodoSection entity to your managed object model. The entity would have a date attribute for sorting sections and a unique name string attribute that would serve as a section id, as well as a section header name. The unique quality can be enforced by using Core Data unique constraints on your managed object. Todos in each section can be modeled as a to many relationship to your Todo entity.

When saving a new Todo object, you would have to use Find or Create pattern to find out whether you already have a section in store or you would have to create a new one.

    let sectionName = dateFormatter.string(from: date)
    let sectionFetch: NSFetchRequest<TodoSection> = TodoSection.fetchRequest()
    sectionFetch.predicate = NSPredicate(format: "%K == %@", #keyPath(TodoSection.name), sectionName)
    
    let results = try! moc.fetch(sectionFetch)
    
    if results.isEmpty {
        // Section not found, create new section.
        let newSection = TodoSection(context: moc)
        newSection.name = sectionName
        newSection.date = date
        newSection.addToTodos(newTodo)
    } else {
        // Section found, use it.
        let existingSection = results.first!
        existingSection.addToTodos(newTodo)
    }

To display your sections and accompanying todos nested ForEach views can be used with Section in between. Core Data uses NSSet? for to many relationships so you would have to use an array proxy and conform Todo to Comparable for everything to work with SwiftUI.

    extension TodoSection {
        var todosArrayProxy: [Todo] {
            (todos as? Set<Todo> ?? []).sorted()
        }
    }
    
    extension Todo: Comparable {
        public static func < (lhs: Todo, rhs: Todo) -> Bool {
            lhs.title! < rhs.title!
        }
    }

If you need to delete a certain todo, bear in mind that the last removed todo in section should also delete the entire section object.

I tried using init(grouping:by:) on Dictionary, as it has been suggested here, and, in my case, it causes jaggy animations, which are probably the sign that we are going in the wrong direction. I’m guessing the whole list of items has to be recompiled when we delete a single item. Furthermore, embedding grouping into a data model would be more performant and future-proof as our data set grows.

I have provided a sample project if you need any further reference.

Gene Bogdanovich
  • 773
  • 1
  • 7
  • 21