Reputation: 2856
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
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
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
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
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