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.
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.
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.
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.