portico/Sources/Portico/Views/ForEach.swift

148 lines
6.3 KiB
Swift

import Gtk
/// A view that renders one row per element of a reactive array, keyed by a
/// stable identifier, and applies a keyed diff on change - appending new rows,
/// removing deleted rows, and reordering moved rows. Surviving rows are never
/// rebuilt, so widget identity and subscriptions are preserved.
///
/// When placed in a dynamic ordered container's `children:` builder, rows spread
/// directly into that container. Supported parents are `Gtk.Box`, `HStack`,
/// `VStack`, `Gtk.ListBox`, `Gtk.FlowBox`, `Adw.WrapBox`, and `Adw.Carousel`.
/// In a single-widget slot, modifier closure, unsupported container, or after a
/// non-`.environment` modifier, it falls back to its own vertical `Gtk.Box`.
/// Reactivity requires a state-backed `Binding` (for example, `$items`); a custom
/// `Binding(get:set:)` does not track and will not update. Rows are built from an
/// element value snapshot at insert time.
@MainActor public struct ForEach<Element, ID: Hashable>: View {
private let data: Binding<[Element]>
private let id: KeyPath<Element, ID>
private let row: (Element) -> [AnyView]
public var body: Never { fatalError() }
/// Creates a keyed reactive collection of row views.
///
/// - Parameters:
/// - data: The reactive array driving the rows.
/// - id: Key path to each element's stable identifier. IDs MUST be unique;
/// on a duplicate the first occurrence wins and later ones are ignored
/// for that diff.
/// - row: Builds one row's views from an element value snapshot.
public init(
_ data: Binding<[Element]>,
id: KeyPath<Element, ID>,
@ViewBuilder row: @escaping (Element) -> [AnyView]
) {
self.data = data
self.id = id
self.row = row
}
}
@_spi(Portico) extension ForEach: DynamicViewGroup {
@_spi(Portico) public func mountRegion(_ region: ChildRegion, ctx: MountContext) {
// Per-key on-screen state persists across diffs in the tracker closure.
var rows: [ID: (widgets: [Gtk.Widget], registry: NodeRegistry)] = [:]
var order: [ID] = []
/// Position of each key in `order`, so a moved key is located in O(1)
/// instead of by a linear `order.firstIndex(of:)` scan.
var indexOf: [ID: Int] = [:]
/// Region-local widget offset of each ordered row. The trailing element
/// stores the region's total widget count, so every valid row position
/// and the insertion tail are answered in O(1).
var offsets: [Int] = [0]
/// Restores key positions and widget offsets after an array mutation.
/// The rebuilt suffix is no longer than the array memmove that caused
/// the mutation, so it adds no asymptotic cost.
func rebuildTail(from position: Int) {
var offset = offsets[position]
for i in position..<order.count {
indexOf[order[i]] = i
offsets[i] = offset
offset += rows[order[i]]!.widgets.count
}
offsets[order.count] = offset
}
func applyDiff() {
// Deduplicate by ID, keeping the first occurrence and input order.
// Store source indexes so surviving rows do not copy their elements.
let source = data.wrappedValue
var ordered: [(key: ID, source: Int)] = []
ordered.reserveCapacity(source.count)
var target = Set<ID>()
target.reserveCapacity(source.count)
for (index, element) in source.enumerated() {
let key = element[keyPath: id]
if target.insert(key).inserted {
ordered.append((key, index))
}
}
// Detach removed rows and tear down only their child registries.
var position = 0
while position < order.count {
let key = order[position]
if target.contains(key) {
position += 1
continue
}
let entry = rows[key]!
let offset = offsets[position]
for _ in entry.widgets.indices {
region.remove(at: offset)
}
entry.registry.teardown()
ctx.registry.removeChild(entry.registry)
rows[key] = nil
order.remove(at: position)
indexOf.removeValue(forKey: key)
offsets.removeLast()
rebuildTail(from: position)
}
// Place rows left to right. The processed prefix already matches the
// target prefix, so local offsets are valid for each operation.
for (position, entry) in ordered.enumerated() {
let key = entry.key
if position < order.count && order[position] == key {
continue
}
if let current = indexOf[key] {
let count = rows[key]!.widgets.count
region.move(from: offsets[current], count: count, to: offsets[position])
order.remove(at: current)
order.insert(key, at: position)
rebuildTail(from: position)
} else {
let child = ctx.makeChild()
let widgets = row(source[entry.source]).map { $0.makeWidget(child) }
var offset = offsets[position]
for widget in widgets {
region.insert(widget, at: offset)
offset += 1
}
rows[key] = (widgets, child.registry)
order.insert(key, at: position)
offsets.append(0)
rebuildTail(from: position)
}
}
}
let tracker = DependencyTracker(observation: .disabled) { applyDiff() }
tracker.run()
ctx.registry.add(tracker)
}
}
@_spi(Portico) extension ForEach: Mountable {
/// Mounts rows into a vertical box when no dynamic parent can spread them.
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let box = Gtk.Box(orientation: .vertical, spacing: 0)
let ledger = ChildLedger(host: box)
mountRegion(ledger.addRegion(), ctx: ctx)
return box
}
}