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

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.