148 lines
6.3 KiB
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
|
|
}
|
|
}
|