Shinigami
Shinigami

Reputation: 2213

Group list data in SwiftUI for display in sections

I'm trying to take a an array of items (structs) and display them in a grouped table view with SwiftUI.

My (simplified) models look like this:

struct CheckIn: Identifiable {
  ...
  let id = UUID()
  let date = Date().atMidnight // removes the time component
  var completed: Bool
  ...
}

class Store: ObservableObject {
  @Published var checkIns = [...] {
    didSet { persist() }
  }
}

Before showing the check-ins in a list, I want to group them by date. So I have another model:

struct DailyCheckIns {
  let date: Date
  let checkIns: [CheckIn]
}

// and a function to group the check-ins array:
func groupByDate(_ checkIns: [CheckIn]) -> [DailyCheckIns] {...}

The view is where I have the problem. The version below works but the data is not grouped obviously. By "works", I mean that the data is passed into CheckInView and it can update its check-in, which is then correctly reflected in the store and in the UI.

struct ContentView: View {
  @EnvironmentObject var store: Store

  var body: some View {
    NavigationView {
      List {
        ForEach(store.checkIns.indices) { idx in
          CheckInView(checkIn: self.$store.checkIns[idx]) // checkIn is a @Binding
        }
      }
      .navigationBarTitle("Check Ins")
    }
  }
}

This next version is my attempt at grouping the data. With this approach, I have to change CheckInView's checkIn property from @Binding to @State. The grouping works and the data is displayed but when the check-in's completion is toggled, the models update but the UI does not.

struct ContentView: View {
  @EnvironmentObject var store: Store

  var body: some View {
    NavigationView {
      List {
        ForEach(groupByDate(store.checkIns), id: \.date) { daily in
          Section(header: Text(dateFormatter.string(from: daily.date))) {
            ForEach(daily.checkIns, id: \.id) { checkIn in
              CheckInView(checkIn: checkIn) // I can't use a binding here, so in this version I need to make checkIn a @State.
            }
          }
        }
      }
      .navigationBarTitle("Check Ins")
    }
  }
}

At the moment, I don't have CheckInView modifying the check in directly. Instead it posts an update to the store and the store updates the model:

struct CheckInView: View {
  @Binding var checkIn: CheckIn
  @EnvironmentObject var store: Store

  var body: some View {
    HStack {
      Button(action: {
        self.store.update(checkIn: self.checkIn, with: true)

      }) {
        Image(systemName: "...")
          .font(.largeTitle)
          .foregroundColor(checkIn.completed ? .gray : .red)
      }
      .buttonStyle(BorderlessButtonStyle())
...

So the question is: how can I keep the list grouped and keep the bindings working all the way down the view hierarchy?

Upvotes: 6

Views: 5836

Answers (1)

Shinigami
Shinigami

Reputation: 2213

I have figured out how to do this. Whether or not it's the most elegant solution, I don't know. It sure doesn't look elegant to me, but I can't think of another way I can do it.

In the code I posted, DailyCheckIns is an intermediate model for the purpose of grouping check-ins. The function groupByDate takes an array of CheckIns and turns them into DailyCheckIns for display. The problem was that DailyCheckIn essentially holds copies of the data in the store, so I can't really use it to create bindings from that to the store.

The way I've found around this is to use DailyCheckIns for creating sections and the row count in each section, but when it comes to creating views that require a binding to the store, I use the store's data directly. To accomplish this, I had to change DailyCheckIns (and groupByDate) to track the index of each CheckIn in the store's property:

typealias CheckInWithIndex = (Int, CheckIn)

struct DailyCheckIns {
  let date: Date
  var checkIns: [CheckInWithIndex]

  func appending(_ ci: CheckInWithIndex) -> DailyCheckIns {
    DailyCheckIns(date: date, checkIns: checkIns + [ci])
  }
}

private func groupByDate(_ checkIns: [CheckIn]) -> [DailyCheckIn] { ... }

struct ContentView: View {
  @EnvironmentObject var store: Store

  var body: some View {
    NavigationView {
      List {
        ForEach(groupByDate(store.checkIns), id: \.date) { daily in
          Section(header: Text(dateFormatter.string(from: daily.date))) {
            ForEach(daily.checkIns, id: \.id) { checkInWithIndex in
              CheckInView(checkIn: self.$store.checkIns[checkInWithIndex.0])
            }
          }
        }
      }
      .navigationBarTitle("Check Ins")
    }
  }
}

Hope this helps someone. But also, I hope someone has a better solution to this kind of situation.

Upvotes: 3

Related Questions