UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Grouping @FetchRequest by date and display results in NavigationView

Forums > SwiftUI

I am struggling a lot with this one.

I am fetching a database with FetchRequest. Then I am displaying the results in NavigationView using ForEach to loop in NavigationLink with destination to data.

The code is this:

import SwiftUI
import CoreData

struct History: View {
    @Environment(\.managedObjectContext) var managedObjContext
    @FetchRequest(sortDescriptors: [SortDescriptor(\.date, order: .reverse)]) var food: FetchedResults<Food>

    @State private var showingAddView = false

    var body: some View {
        NavigationView {
            VStack(alignment: .center) {
                List {
                    ForEach(food) { food in
                        NavigationLink(destination: EditFoodView(food: food)) {
                            HStack {
                                VStack(alignment: .leading, spacing: 6) {
                                    Text(food.date!, style: .date)
                                    Text(food.name!)
                                        .bold()

                                    Text("\(Int(food.calories))") + Text(" calories").foregroundColor(.red)
                                    }
                                    Spacer()
                                    Text(calcTimeSince(date: food.date!))
                                        .foregroundColor(.gray)
                                        .italic()
                                }
                            }
                        }
                        .onDelete(perform: deleteFood)
                    }
                    .listStyle(.plain)
            }
            .toolbar {

                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()

                }

            }
            .sheet(isPresented: $showingAddView) {
                AddFoodView()
            }
        }
        .navigationViewStyle(.stack) // Removes sidebar on iPad

    }

This is what I have: list view screenshot

What the code does is it displays every meal from core data which is okay, but how to group the meals by their date and display the list with date headers?

2      

hi,

to do what you propose, you'll want to use this type of structure in the body using Section:

List {
  ForEach(sections) { section in
    Section(header: Text(title(for: section))) {
      ForEach(section.meals) { meal in
        // display the meal detail for this row
      }
    }
  }
}

where does the sections value of the outer ForEach come from? you define it as a computed variable in your view. and what should sections return? it is an array to drive the outer ForEach, where each element represents meals consumed on the same day ... which itself is an array.

so you need a computed variable:

private var sections: [[Food]] { /* computation here */ }

two items remain:

  • how do you break the array of meals into smaller lists of meals consumed on the same day?
  • how do you get the title of each section?

the second part is easy. if you have an array of meals consumed on the same day, you just have to report the day of any one of the meals, because they were all consumed on the same day. example:

var title(for section: [Food]) -> String {
  let firstMeal = section[0]  // each section will be non-empty; there will be a first element
  // code to format the date of firstMeal as a String
  // return the String
}

as for the first part, start with a function that takes an array and forms it into a dictionary based on some grouping criterion -- in your case, by date (the month-day-year, without the actual time of day)

let dictionary = Dictionary(grouping: food, by: { Calendar.current.startOfDay(for: $0.date) })

the keys of this dictionary are dates, the values in this dictionary are arrays of meals consumed on that date.

you use this in the sections computation ... but you'll need to change the dictionary into an array of its values and return that as the value of sections. one way to do that is to loop over the keys of the dictionary, just appending each of the values to what is initially an empty list of lists of meals.

var result = [[Food]]()
for key in dictionary.keys {
    result.append(dictionary[key]!)
}

that's just about everything, but you should probably sort the keys of the dictionary so that sections appears in the right order. and i will review all this stuff i have written for you shortly.

hope that helps,

DMG

4      

@delawareGuy and I were madly slinging code! His is excellent! 🏆
But here's my second place 🥈 entry.

As @delaware shows, take your entire returned list of meal objects and group them by date. Then create section objects for each date. Within the section object, list the meals corresponding to that date.

import SwiftUI

struct Meal: Identifiable {
    let id =          UUID()
    let consumedDate: Date
    let calories:     Int
    let meal:         Meal.name

    enum name: String {
        case breakfast = "Breakfast"
        case supper    = "Supper"
        case soup      = "Soup"
        case lunch     = "Lunch"
        case shake     = "Shake"
    }

    // Make it easy to view a meal's date
    var consumedDateAsString: String {
        consumedDate.formatted(date: .abbreviated, time: .omitted)
    }

    // Mock data. No need for CoreData while you're being creative with your views.
    static var sampleMeals = [
        Meal(consumedDate: Date(timeIntervalSinceNow: 150_000),
             calories: 100, meal: .breakfast),
        Meal(consumedDate: Date(timeIntervalSinceNow: 150_000),
             calories: 300, meal: .lunch),
        Meal(consumedDate: Date(timeIntervalSinceNow: 150_000),
             calories: 500, meal: .supper),
        Meal(consumedDate: Date(timeIntervalSinceNow: 250_000),
             calories: 100, meal: .soup),
        Meal(consumedDate: Date(timeIntervalSinceNow: 250_000),
             calories: 500, meal: .breakfast),
        Meal(consumedDate: Date(timeIntervalSinceNow: 250_000),
             calories: 440, meal: .shake),
        Meal(consumedDate: Date(timeIntervalSinceNow: 450_000),
             calories: 440, meal: .supper)
    ]
}
// Simplify your code!
// Create a reusable Lego brick that shows just one meal!
struct OneMeal: View {
    var showThisMeal: Meal // Send in ONE meal to show.
    var body: some View {
        HStack {
            Text(showThisMeal.meal.rawValue)
            Text("[\(showThisMeal.calories) calories]").foregroundColor(.red.opacity(0.5))
        }
    }
}

// Show all meals, grouped by date consumed.
struct MealView: View {
    var meals          = Meal.sampleMeals // use mock data, add CoreData later!
    // Grab a unique array of meal dates
    let sectionHeaders = Array(Set( Meal.sampleMeals.map { $0.consumedDateAsString }))
        .sorted{ $0 > $1 } // Older dates fall to bottom of the view

    // return all meals logged for an arbitrary date. Warning, this may be empty!
    func mealsForDate(thisDate: String) -> [Meal] {
        meals.filter{ $0.consumedDateAsString == thisDate }
    }

    var body: some View {
        // From delawareGuy....
        List {
            // Loop over each meal date, make that the section
            ForEach(sectionHeaders, id:\.self) { mealDate in
                Section(header: Text(mealDate)) {
                // Find all meals for this section. Create a Lego brick (view) for each meal you found.
                    ForEach(mealsForDate(thisDate: mealDate)) {
                        OneMeal(showThisMeal: $0) // reuseable row template.
                    }
                }
            }
        }
    }
}

// Preview your design in XCode!
struct MealView_Previews: PreviewProvider {
    static var previews: some View {
        MealView()
    }
}

2      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

I've been searching around for a while, and this is exactly what I'm looking for. I don't mean to hijack the thread (it hasn't been marked as answered yet), but I'm having a hard time porting the example given by @Obelix above to use Core Data, because I don't understand what pieces to change from the example.

Am I correct in assuming if we're using Core Data we can ignore the entirety of the Meal struct? And if so, do we need to move the consumedDateAsString function into the MealView?

2      

Hi @mradamw,

Did you try to use a SectionedFetchRequest instead?

This will allow you to group the meals by date an put the dates as a headers in every section.

2      

@CacereLucas I've tried using them before, but could never get the syntax right. I can make a new thread so as to not derail it, but any help would be greatly appreciated.

2      

Try this:

import SwiftUI
import CoreData

struct ContentView: View { 
    @Environment(\.managedObjectContext) var managedObjContext
    @SectionedFetchRequest<String, Food>(sectionIdentifier: \Food.consumedDateAsString, sortDescriptors: [SortDescriptor(\Food.date, order: .reverse)]) var food: SectionedFetchResults<String, Food>

    var body: some View {
        List {
            ForEach(food) { section in
                Section(header: Text(section.id)) {
                    ForEach(section) { food in
                        HStack {
                            VStack(alignment: .leading, spacing: 6) {
                                Text(food.date!, style: .date)
                                Text(food.name!)
                                    .bold()

                                Text("\(Int(food.calories))") + Text(" calories").foregroundColor(.red)
                            }
                            Spacer()
                            Text(calcTimeSince(date: food.date!))
                                .foregroundColor(.gray)
                                .italic()
                        }
                    }
                }
            }
        }
    }
}

This should generate a list by grouping the items by date.

For it to work properly you have to use the computed property that @Obelix suggest, so the sections ignore the time and only take into account the day and month. You have to put this as an extension of Food entity. (Remember to add the @objc before var to avoid a runtime error.)

extension Food {
    @objc var consumedDateAsString: String {
        consumedDate.formatted(date: .abbreviated, time: .omitted)
    }
}

Let me know if it works for you or if you have a problem. This code may vary depending on whether some Core Data attributes are optional or not, so let me know how it has worked for you.

2      

@CacereLucas thank you so much, that worked perfectly! I have a timeline view now! There were a few changes I made, laid out below:

extension Food {
    @objc var consumedDateAsString: String {
        consumedDate!.formatted(.dateTime.weekday().day().month().year())
    }
}

I had to force unwrap the date, because it's an optional (but will always exist). Is there a better way to do this?

The last request I have comes down to beefing up the formatting for consumedDateAsString - how could I have Today and Yesterday as Strings and then fall back to normal dates for the day before yesterday, and days before that?

If I had to take a guess, maybe something like:

extension Food {
    @objc var consumedDateAsString: String {
        // If consumedDate is today
        if Calendar.current.isDateInToday(consumedDate!) {
            Text("Today")
        // if consumedDate was yesterday
        } else if Calendar.current.isDateInYesterday(consumedDate!) {
           Text("Yesterday")
       // otherwise use normal dates
       } else {
           consumedDate!.formatted(.dateTime.weekday().day().month().year())
       }
    }
}

2      

@mradamw I'm very happy that it worked for you! :)

In relation to force unwrappe the optionals in Core Data, the way I do it (but I am not entirely sure that it's the BEST way to make it), is manually generate the managed object subclasses from entities and if I'm sure that is not an optional, then I delete the "?" from the attribute.

This way, the attribute doesn't bother me anymore with the optionals. But again, I don't know if it's the best way to do it.

I don't know if I fully understand what you want to achieve with consumedDateAsString. Do you want the first two sections to say "Today" and "Yesterday" respectively and then the rest of the sections show the dates?

2      

@CacereLucas sorry for not replying sooner. I remember Paul showing something similar re the subclasses in one of his videos.

As for the today/yesterday thing, you got it. I managed to make it work, here's my amended extension:

extension Food {
    @objc var consumedDateAsString: String {
        var formattedDate = ""

        if Calendar.current.isDateInToday(consumedDate) {
            formattedDate = "Today"
        } else if Calendar.current.isDateInYesterday(consumedDate) {
            formattedDate = "Yesterday"
        } else {
            formattedDate = unwrappedDate.formatted(.dateTime.weekday(.wide).day().month())
        }
        return formattedDate
    }
}

3      

The extension works perfect. Great job! :)

3      

Sorry to reopen the thread but I'm stuck when using SectionedFetchRequest

as suggested by delawaremathguy,

If I use this :

List {

      ForEach(sections) { section in
              Section(header: Text(title(for: section))) {
                    ForEach(section.meals) { meal in
// display the meal detail or drink detail for this row
                    }
              }
        }
}

How can I not display the section header if there is no meals in this section? I think my problem is that each section could have meals AND/OR drinks (for example), and when I want to display only meals, there is also header from drinks for this date...

I can open a new thread if needed :)

2      

hi @Greetcha,

it's been a while since i was in this thread ... but i can quickly answer your question:

How can I not display the section header if there is no meals in this section?

if you use the Dictionary(grouping:) method to form the suggested [[Food]] structure, there will be no empty sections. every key of the dictionary came from at least one Food object.

i have not used @SectionedFetchRequest in anything so far, but for new work with a simple sectioning scheme, i would look there first. but if i recall, the section keys must be present in the Food records (which may or may not be something you have or need).

hope that helps,

DMG

2      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.