What would be a good way to partition instances by a particular matching property, such that each partition is a collection view section (Int)?

I'm not entirely sure if this is on topic here since it involves using the collection view framework, but I think the essence of where I'm stuck is with Swift data structures independent of the UI framework. I'm including the collection view context anyway if it's helpful.

Say that in this example here, the struct

struct Reminder: Identifiable {
    var id: String = UUID().uuidString
    var title: String
    var dueDate: Date
    var notes: String? = nil
    var isComplete: Bool = false
    var city: String
}

is modified slightly to include a city string. In the collection view that displays the reminders, I'd like each section to be each unique city, so if two reminder cells have the same city string then they would be in the same section of the collection view.

The progress I've made to this end is in sorting the reminders array so that reminders cells are grouped together by city

func updateSnapshot(reloading ids: [Reminder.ID] = []) {
    var snapshot = Snapshot()
    snapshot.appendSections([0])
    let reminders = reminders.sorted { $0.city }
    snapshot.appendItems(reminders.map { $0.id })
    if !ids.isEmpty {
        snapshot.reloadItems(ids)
    }
    dataSource.apply(snapshot)
}

Where I'm stuck is in coming up with a way to make the snapshot represent sections by unique cities, and not just one flat section of all reminders.

1 Like

Try this:

func grouppedReminders(reminders: [Reminder]) -> [(key: String, value: [Reminder])]  {
    var cities: [String: [Reminder]] = [:]
    reminders.forEach { reminder in
        cities[reminder.city, default: []].append(reminder)
    }
    for var (key, reminders) in cities {
        reminders.sort { $0.title < $1.title } // dueDate ?
        cities[key] = reminders // TODO: could that invalidate enumeration?
    }
    return cities.sorted { $0.key < $1.key }
}

model.state.grouppedReminders = grouppedReminders(reminders: reminders)

The resulting structure can work with SwiftUI as is, with UIKit's diff able datasource it's a bit more convoluted but very similar.

2 Likes

You can simplify this a bit by sorting the array of reminders first, and then using Dictionary's grouping initializer:

let sortedReminders = reminders.sorted(by: { $0.title < $1.title })
let cities = Dictionary(grouping: sortedReminders, by: \.city)
2 Likes

Thank you Tera and Nate!

Any thoughts on how might each city string key correspond to [SectionIdentifierType] (section for each city)?

    var snapshot = NSDiffableDataSourceSnapshot<Int, Reminder.ID>()
    snapshot.appendSections([0])
    snapshot.appendItems(filteredReminders.map { $0.id })
    if !ids.isEmpty {
        snapshot.reloadItems(ids)
    }
    dataSource.apply(snapshot)

The examples I've seen use an array of a known number of sections, like one section in the example above, or say the sections modeled as enumeration cases.

I'm stumped as to how to use appendSections(_ identifiers: [SectionIdentifierType]) with an arbitrary number of city string keys that we currently have.

Have a look:

This common data model
import SwiftUI
import UIKit

struct Reminder: Identifiable/*SwiftUI*/, Hashable {
    let id: String = UUID().uuidString
    let title: String
    let dueDate: Date
    var notes: String? = nil
    var isComplete: Bool = false
    let city: String
}

struct City: Identifiable/*SwiftUI*/, Hashable {
    var id: String { name }
    let name: String
    let reminders: [Reminder]
}

class Model: ObservableObject/*SwiftUI*/ {
    static let singleton = Model()
    let notificationName = Notification.Name("changed")/*UIKit*/

    private init() {
        debugLoad()
    }
    
    var reminders: [Reminder] = [] {
        didSet {
            cities = calculateCities(reminders: reminders)
        }
    }
    
    @Published/*SwiftUI*/ var cities: [City] = [] {
        didSet {/*UIKit*/
            NotificationCenter.default.post(name: notificationName, object: nil, userInfo: nil)
        }
    }
    
    private func calculateCities(reminders: [Reminder]) -> [City]  {
        let sortedReminders = reminders.sorted { $0.title < $1.title }
        let cities = Dictionary(grouping: sortedReminders, by: \.city)
        return cities.sorted { $0.key < $1.key }.map { v in
            City(name: v.key, reminders: v.value)
        }
    }
}

extension Model {
    func debugLoad() {
        let titles = ["Tom", "Dick", "Harry", "Sally", "Rose", "Alice", "Beth", "Bob", "Pete", "Sarah"]
        let cities = ["Loose Bottom", "Plwmp", "Crapstone", "Nether Wallop"]
        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [self] timer in
            let reminder = Reminder(title: titles.randomElement()!, dueDate: Date(), city: cities.randomElement()!)
            withAnimation {/*SwiftUI*/
                reminders.append(reminder)
            }
            if reminders.count > 15 {
                timer.invalidate()
            }
        }
    }
}

can drive either (and even both at the same time)

UIKit view implementation
import UIKit

class DataSource: UITableViewDiffableDataSource<City, Reminder> {
    override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        sectionIdentifier(for: section)?.name
    }
}

class ViewController: UIViewController {

    private var dataSource: DataSource!
    private var model = Model.singleton
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let tv = UITableView(frame: view.bounds, style: .grouped)
        tv.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tv.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: "header")
        tv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
        dataSource = .init(tableView: tv) { tableView, indexPath, reminder in
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            cell.textLabel?.text = reminder.title
            return cell
        }
        dataSource.defaultRowAnimation = .fade
        tv.dataSource = dataSource
        view.addSubview(tv)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        NotificationCenter.default.addObserver(forName: model.notificationName, object: nil, queue: .main) { [weak self] _ in
            guard let self = self else { return }
            var snapshot = NSDiffableDataSourceSnapshot<City, Reminder>()
            snapshot.appendSections(self.model.cities)
            for city in self.model.cities {
                snapshot.appendItems(city.reminders, toSection: city)
            }
            self.dataSource.apply(snapshot, animatingDifferences: true)
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        NotificationCenter.default.removeObserver(self, name: model.notificationName, object: nil)
    }
}

and

SwiftUI view implementation
import SwiftUI

struct ContentView: View {
    @ObservedObject private var model = Model.singleton
    
    var body: some View {
        List {
            ForEach(model.cities) { city in
                Section(city.name) {
                    ForEach(city.reminders) { reminder in
                        Text(reminder.title)
                    }
                }
            }
        }
    }
}

A few notes:

  • for simplicity of this example I do everything on the main thread. In real code model calculations could be done on background queue, model publishing has to be done on the main thread for SwiftUI and on either main thread or background queue (but don't mix) for UIKit's diffable data source implementation. Adjust accordingly.
  • I labeled the corresponding features with /*SwiftUI*/ or /*UIKit*/ to show which one is needed for which implementation.
  • SwiftUI code is more clean, concise and easier to follow. It's about 3x shorter, I found the similar reduction in big real apps.
  • If it feels like "double work" in case of UIKit (first we create a model data structure, then we replicate it in the form of "snapshot") - this is indeed a duplication... UIKit's diffable implementation gears you towards their view of the world where their "snapshot" is the source of truth and they want you to use special methods to modify snapshot. You can still recreate the whole snapshot and it would not cause excessive view update (e.g. if only a single item changed - that only item would be redrawn).
  • Note that Identifiable is not required nor used in UIKit's diffable implementation... Besides other things it means it can not possibly do a proper animation when changing from:
    • "1.Sun" ... other items... "8.Moon" to
    • "8.Moon" ... other items ... "1.Sun"
      in this case the first and last items would be just redrawn whilst the wanted behaviour (supported by SwiftUI which uses Identifiable) would do a proper move / exchange animation for these two items. If it is a must to do that animation properly you'll have to use the convoluted and error prone manual implementation that isn't based on diffable datasource.