Home > Blockchain >  Grouping CoreData by Date() in SwiftUI List as sections
Grouping CoreData by Date() in SwiftUI List as sections

Time:12-17

My goal:

I want to be able to group CoreData Todo items by their dueDate ranges. ("Today", "Tomorrow", "Next 7 Days", Future")

What I attempted...

I tried using @SectionedFetchRequest but the sectionIdentifier is expecting a String. If it's stored in coreData as a Date() how do I convert it for use? I received many errors and suggestions that didn't help. This also doesn't solve for the date ranges like "Next 7 Days". Additionally I don't seem to even be accessing the entity's dueDate as it points to my ViewModel form instead.

    @Environment(\.managedObjectContext) private var viewContext
    
    //Old way of fetching Todos without the section fetch
    //@FetchRequest(sortDescriptors: []) var todos: FetchedResults<Todo>
    
    @SectionedFetchRequest<String, Todo>(
        entity: Todo.entity(), sectionIdentifier: \Todo.dueDate,
        SortDescriptors: [SortDescriptor(\.Todo.dueDate, order: .forward)]
    ) var todos: SectionedFetchResults<String, Todo>
Cannot convert value of type 'KeyPath<Todo, Date?>' to expected argument type 'KeyPath<Todo, String>'

Value of type 'NSObject' has no member 'Todo'

Ask

Is there another solution that would work better in my case than @SectionedFetchRequest? if not, I'd like to be shown how to group the data appropriately.

CodePudding user response:

It is throwing the error because that is what you told it to do. @SectionedFetchRequest sends a tuple of the type of the section identifier and the entity to the SectionedFetchResults, so the SectionedFetchResults tuple you designate has to match. In your case, you wrote:

SectionedFetchResults<String, Todo>

but what you want to do is pass a date, so it should be:

SectionedFetchResults<Date, Todo>

lorem ipsum beat me to the second, and more important part of using a computed variable in the extension to supply the section identifier. Based on his answer, you should be back to:

SectionedFetchResults<String, Todo>

Please accept lorem ipsum's answer, but realize you need to handle this as well.

On to the sectioning by "Today", "Tomorrow", "Next 7 Days", etc.

My recommendation is to use a RelativeDateTimeFormatter and let Apple do most or all of the work. To create a computed variable to section with, you need to create an extension on Todo like this:

extension Todo {
    
    @objc
    public var sections: String {
        // I used the base Xcode core data app which has timestamp as an optional.
        // You can remove the unwrapping if your dates are not optional.
        if let timestamp = timestamp {
            // This sets up the RelativeDateTimeFormatter
            let rdf = RelativeDateTimeFormatter()
            // This gives the verbose response that you are looking for.
            rdf.unitsStyle = .spellOut
            // This gives the relative time in names like today".
            rdf.dateTimeStyle = .named

            // If you are happy with Apple's choices. uncomment the line below
            // and remove everything else.
  //        return rdf.localizedString(for: timestamp, relativeTo: Date())
            
            // You could also intercept Apple's labels for you own
            switch rdf.localizedString(for: timestamp, relativeTo: Date()) {
            case "now":
                return "today"
            case "in two days", "in three days", "in four days", "in five days", "in six days", "in seven days":
                return "this week"
            default:
                return rdf.localizedString(for: timestamp, relativeTo: Date())
            }
        }
        // This is only necessary with an optional date.
        return "undated"
    }
}

You MUST label the variable as @objc, or else Core Data will cause a crash. I think Core Data will be the last place that Obj C lives, but we can pretty easily interface Swift code with it like this.

Back in your view, your @SectionedFetchRequest looks like this:

@SectionedFetchRequest(
    sectionIdentifier: \.sections,
    sortDescriptors: [NSSortDescriptor(keyPath: \Todo.timestamp, ascending: true)],
    animation: .default)
private var todos: SectionedFetchResults<String, Todo>

Then your list looks like this:

 List {
      ForEach(todos) { section in
          Section(header: Text(section.id.capitalized)) {
               ForEach(section) { todo in
               ...
               }
          }
      }
  }

CodePudding user response:

You can make your own sectionIdentifier in your entity extension that works with @SectionedFetchRequest

The return variable just has to return something your range has in common for it to work.

extension Todo{
    ///Return the string representation of the relative date for the supported range (year, month, and day)
    ///The ranges include today, tomorrow, overdue, within 7 days, and future
    @objc
    var dueDateRelative: String{
        var result = ""
        if self.dueDate != nil{
            //Order matters here so you can avoid overlapping
            if Calendar.current.isDateInToday(self.dueDate!){
                result = "today"//You can localize here if you support it
            }else if Calendar.current.isDateInTomorrow(self.dueDate!){
                result = "tomorrow"//You can localize here if you support it
            }else if Calendar.current.dateComponents([.day], from: Date(), to: self.dueDate!).day ?? 8 <= 0{
                result = "overdue"//You can localize here if you support it
            }else if Calendar.current.dateComponents([.day], from: Date(), to: self.dueDate!).day ?? 8 <= 7{
                result = "within 7 days"//You can localize here if you support it
            }else{
                result = "future"//You can localize here if you support it
            }
        }else{
            result =  "unknown"//You can localize here if you support it
        }
        return result
    }
}

Then use it with your @SectionedFetchRequest like this

@SectionedFetchRequest(entity: Todo.entity(), sectionIdentifier: \.dueDateRelative, sortDescriptors: [NSSortDescriptor(keyPath: \Todo.dueDate, ascending: true)], predicate: nil, animation: Animation.linear)
var sections: SectionedFetchResults<String, Todo>

Look at this question too

You can use Date too but you have to pick a date to be the section header. In this scenario you can use the upperBound date of your range, just the date not the time because the time could create other sections if they don't match.

extension Todo{
    ///Return the upperboud date of the available range (year, month, and day)
    ///The ranges include today, tomorrow, overdue, within 7 days, and future
    @objc
    var upperBoundDueDate: Date{
        //The return value has to be identical for the sections to match
        //So instead of returning the available date you return a date with only year, month and day
        //We will comprare the result to today's components
        let todayComp = Calendar.current.dateComponents([.year,.month,.day], from: Date())
        var today = Calendar.current.date(from: todayComp) ?? Date()
        if self.dueDate != nil{
            //Use the methods available in calendar to identify the ranges
            //Today
            if Calendar.current.isDateInToday(self.dueDate!){
                //The result variable is already setup to today
                //result = result
            }else if Calendar.current.isDateInTomorrow(self.dueDate!){
                //Add one day to today
                today = Calendar.current.date(byAdding: .day, value: 1, to: today)!
            }else if Calendar.current.dateComponents([.day], from: today, to: self.dueDate!).day ?? 8 <= 0{
                //Reduce one day to today to return yesterday
                today = Calendar.current.date(byAdding: .day, value: -1, to: today)!
            }else if Calendar.current.dateComponents([.day], from: today, to: self.dueDate!).day ?? 8 <= 7{
                //Return the date in 7 days
                today = Calendar.current.date(byAdding: .day, value: 7, to: today)!
            }else{
                today = Date.distantFuture
            }
        }else{
            //This is something that needs to be handled. What do you want as the default if the date is nil
            today = Date.distantPast
        }
        return today
    }
}

And then the request will look like this...

@SectionedFetchRequest(entity: Todo.entity(), sectionIdentifier: \.upperBoundDueDate, sortDescriptors: [NSSortDescriptor(keyPath: \Todo.dueDate, ascending: true)], predicate: nil, animation: Animation.linear)
var sections: SectionedFetchResults<Date, Todo>

Based on the info you have provided you can test this code by pasting the extensions I have provided into a .swift file in your project and replacing your fetch request with the one you want to use

  • Related