Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to properly group a list fetched from CoreData by date?

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?

like image 302
fasoh Avatar asked Dec 04 '19 16:12

fasoh


3 Answers

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")
      })
    }
  }
like image 180
E.Coms Avatar answered Nov 14 '22 08:11

E.Coms


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)
  }
}
like image 18
Pranav Kasetti Avatar answered Nov 14 '22 08:11

Pranav Kasetti


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.

like image 3
Gene Bogdanovich Avatar answered Nov 14 '22 08:11

Gene Bogdanovich