| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- import Foundation
- /// A view that displays a variable amount of children.
- public struct ForEach<Items: Collection, ID: Hashable, Child> {
- /// A variable-length collection of elements to display.
- var elements: Items
- /// A method to display the elements as views.
- var child: (Items.Element) -> Child
- /// The path to the property used as Identifier
- var idKeyPath: KeyPath<Items.Element, ID>?
- }
- extension ForEach: TypeSafeView, View where Child: View {
- typealias Children = ForEachViewChildren<Items, ID, Child>
- /// Creates a view that creates child views on demand based on a collection
- /// of data.
- ///
- /// One instance of `child` will be rendered for every element in
- /// `elements`.
- ///
- /// - Parameters:
- /// - elements: The collection to build an array of views from.
- /// - keyPath: A key path to the element type's ID.
- /// - child: A view builder that returns an appropriate view for
- /// each element of `elements`.
- public init(
- _ elements: Items,
- id keyPath: KeyPath<Items.Element, ID>,
- @ViewBuilder _ child: @escaping (Items.Element) -> Child
- ) {
- self.elements = elements
- self.child = child
- self.idKeyPath = keyPath
- }
- public var body: EmptyView {
- return EmptyView()
- }
- public var _asMenuItems: [MenuItem] {
- elements.map(child).flatMap(\._asMenuItems)
- }
- func children<Backend: BaseAppBackend>(
- backend: Backend,
- snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
- environment: EnvironmentValues
- ) -> Children {
- return Children(
- from: self,
- backend: backend,
- idKeyPath: idKeyPath,
- snapshots: snapshots,
- environment: environment
- )
- }
- func asWidget<Backend: BaseAppBackend>(
- _ children: Children,
- backend: Backend
- ) -> Backend.Widget {
- return backend.createContainer()
- }
- func computeLayout<Backend: BaseAppBackend>(
- _ widget: Backend.Widget,
- children: Children,
- proposedSize: ProposedViewSize,
- environment: EnvironmentValues,
- backend: Backend
- ) -> ViewLayoutResult {
- func insertChild(_ child: Backend.Widget, atIndex index: Int) {
- children.queuedChanges.append(.insertChild(AnyWidget(child), index))
- }
- func removeChild(atIndex index: Int) {
- children.queuedChanges.append(.removeChild(index))
- }
- func swap(childAt firstIndex: Int, withChildAt secondIndex: Int) {
- children.queuedChanges.append(.swapChildren(firstIndex, secondIndex))
- }
- // Use the previous update Method when no keyPath is set on a
- // [Hashable] Collection to optionally keep the old behaviour.
- guard let idKeyPath else {
- return deprecatedUpdate(
- widget,
- children: children,
- proposedSize: proposedSize,
- environment: environment,
- backend: backend
- )
- }
- var oldIdentifiers = children.identifiers
- let newIdentifiers = elements.map { $0[keyPath: idKeyPath] }
- // If the identifiers of our elements have changed, then we must rearrange
- // our nodes and widgets so that child view states remain with their
- // corresponding identifiers.
- if oldIdentifiers != newIdentifiers {
- var oldIdentifierMap = children.identifierMap
- var oldNodes = children.nodes
- var seenIdentifiers = Set<ID>()
- var oldNodesReused = 0
- children.nodes = []
- children.identifierMap = [:]
- children.identifiers = []
- children.layoutableChildren = []
- var offset = 0
- var duplicateCount = 0
- for (index, element) in elements.enumerated() {
- let identifier = newIdentifiers[index]
- let childContent = child(element)
- let node: AnyViewGraphNode<Child>
- if !seenIdentifiers.insert(identifier).inserted {
- // We cannot keep view state attached to the correct ForEach element
- // when there are duplicate identifiers. Any elements with unique
- // identifiers are guaranteed to keep functioning correctly. Elements
- // with non-unique identifiers will get their corresponding view graph
- // nodes recreated each time the identifiers of our elements change,
- // unless they are the first element with the shared identifier, in which
- // case they will inherit the view graph node of the previous first element
- // with that same identifier.
- logger.warning(
- "duplicate identifier in ForEach; view state may not act as you would expect",
- metadata: ["identifier": "\(identifier)"]
- )
- duplicateCount += 1
- }
- if let oldIndex = oldIdentifierMap.removeValue(forKey: identifier) {
- // If the identifier already has a corresponding node, reuse it.
- node = oldNodes[oldIndex]
- oldNodesReused += 1
- // If the node's corresponding widget isn't already at the correct
- // position (accounting for insertions), then swap it with the widget
- // at the target position and update our accounting accordinly.
- if index != offset + oldIndex {
- // When talking about current widget indices, we add `offset` to oldIndex.
- // When talking about old element indices, we subtract `offset` from index.
- swap(childAt: offset + oldIndex, withChildAt: index)
- oldNodes.swapAt(oldIndex, index - offset)
- oldIdentifierMap[oldIdentifiers[index - offset]] = oldIndex
- oldIdentifiers.swapAt(oldIndex, index - offset)
- }
- } else {
- // If the identifier is new, create a node for it and insert its
- // widget at the correct position.
- node = AnyViewGraphNode(
- for: childContent,
- backend: backend,
- environment: environment
- )
- insertChild(node.widget.into(), atIndex: index)
- // `offset` tracks how many elements have been inserted, which we
- // use to adjust old indices. All nodes before the one we just
- // inserted are already at their final position, so we never have
- // to adjust old indices that point to before our latest insertion, otherwise
- // such a simple adjustment wouldn't be possible.
- offset += 1
- }
- children.nodes.append(node)
- children.identifierMap[identifier] = index
- children.identifiers.append(identifier)
- children.layoutableChildren.append(
- LayoutSystem.LayoutableChild(node) { child(element) }
- )
- }
- // TODO: We should be able to reuse unused widgets in newly created nodes.
- // Remove unused widgets, starting from the end of the container for
- // cheaper removals.
- let removalCount = oldNodes.count - oldNodesReused
- if removalCount > 0 {
- for i in (0..<removalCount).reversed() {
- removeChild(atIndex: children.nodes.count + i)
- }
- }
- }
- // Recompute layoutable children if the last commit cleared them
- if children.layoutableChildren.isEmpty && !children.nodes.isEmpty {
- children.layoutableChildren = zip(children.nodes, elements).map { (node, element) in
- LayoutSystem.LayoutableChild(node) { child(element) }
- }
- }
- return LayoutSystem.computeStackLayout(
- container: widget,
- children: children.layoutableChildren,
- cache: &children.stackLayoutCache,
- proposedSize: proposedSize,
- environment: environment,
- backend: backend
- )
- }
- @MainActor
- func deprecatedUpdate<Backend: BaseAppBackend>(
- _ widget: Backend.Widget,
- children: Children,
- proposedSize: ProposedViewSize,
- environment: EnvironmentValues,
- backend: Backend
- ) -> ViewLayoutResult {
- @inline(__always)
- func insertChild(_ child: Backend.Widget, atIndex index: Int) {
- children.queuedChanges.append(.insertChild(AnyWidget(child), index))
- }
- @inline(__always)
- func removeChild(atIndex index: Int) {
- children.queuedChanges.append(.removeChild(index))
- }
- let elementsStartIndex = elements.startIndex
- var layoutableChildren: [LayoutSystem.LayoutableChild] = []
- for (i, node) in children.nodes.enumerated() {
- guard i < elements.count else {
- break
- }
- let index = elements.index(elementsStartIndex, offsetBy: i)
- if children.isFirstUpdate {
- insertChild(node.widget.into(), atIndex: i)
- }
- let layoutableChild = LayoutSystem.LayoutableChild(node) { child(elements[index]) }
- layoutableChildren.append(layoutableChild)
- }
- children.isFirstUpdate = false
- let nodeCount = children.nodes.count
- let remainingElementCount = elements.count - nodeCount
- if remainingElementCount > 0 {
- let startIndex = elements.index(elementsStartIndex, offsetBy: nodeCount)
- for i in 0..<remainingElementCount {
- let element = elements[elements.index(startIndex, offsetBy: i)]
- let node = AnyViewGraphNode(
- for: child(element),
- backend: backend,
- environment: environment
- )
- insertChild(node.widget.into(), atIndex: children.nodes.count)
- children.nodes.append(node)
- let layoutableChild = LayoutSystem.LayoutableChild(node) { child(element) }
- layoutableChildren.append(layoutableChild)
- }
- } else if remainingElementCount < 0 {
- let unusedCount = -remainingElementCount
- for i in 0..<unusedCount {
- removeChild(atIndex: nodeCount - i - 1)
- }
- children.nodes.removeLast(unusedCount)
- }
- children.layoutableChildren = layoutableChildren
- return LayoutSystem.computeStackLayout(
- container: widget,
- children: layoutableChildren,
- cache: &children.stackLayoutCache,
- proposedSize: proposedSize,
- environment: environment,
- backend: backend
- )
- }
- func commit<Backend: BaseAppBackend>(
- _ widget: Backend.Widget,
- children: Children,
- layout: ViewLayoutResult,
- environment: EnvironmentValues,
- backend: Backend
- ) {
- for change in children.queuedChanges {
- switch change {
- case .insertChild(let child, let index):
- backend.insert(child.into(), into: widget, at: index)
- case .removeChild(let index):
- backend.remove(childAt: index, from: widget)
- case .swapChildren(let firstIndex, let secondIndex):
- backend.swap(childAt: firstIndex, withChildAt: secondIndex, in: widget)
- }
- }
- children.queuedChanges = []
- LayoutSystem.commitStackLayout(
- container: widget,
- children: children.layoutableChildren,
- cache: &children.stackLayoutCache,
- layout: layout,
- environment: environment,
- backend: backend
- )
- // Reset layoutable children cache so that we recompute them during the
- // next update cycle. This is important at the moment because the `child`
- // closure and `elements` array may have changed. In future we'll separate
- // view body recomputation from the computeLayout step, which should simplify
- // things.
- children.layoutableChildren = []
- }
- }
- /// Stores the child nodes of a ``ForEach`` view.
- ///
- /// Also handles the ``ForEach`` view's widget unlike most ``ViewGraphNodeChildren``
- /// implementations. This logic could mostly be moved into ``ForEach`` but it would still
- /// be accessing ``ForEachViewChildren/storage`` so it'd just introduce an extra layer of
- /// property accesses. It also means that the complexity is in a single type instead of
- /// split across two.
- ///
- /// Most of the complexity comes from resizing the list widget and moving around elements
- /// when elements are added/removed.
- class ForEachViewChildren<
- Items: Collection,
- ID: Hashable,
- Child: View
- >: ViewGraphNodeChildren {
- /// The nodes for all current children of the ``ForEach`` view.
- var nodes: [AnyViewGraphNode<Child>] = []
- /// A map from element identifier to node index.
- var identifierMap: [ID: Int]
- /// The identifiers corresponding to ``nodes``.
- var identifiers: [ID]
- /// Changes queued during computeLayout.
- var queuedChanges: [Change] = []
- /// A queued widget operation to perform during `ForEach.commit`.
- enum Change: CustomStringConvertible {
- case insertChild(AnyWidget, Int)
- case removeChild(Int)
- case swapChildren(Int, Int)
- var description: String {
- switch self {
- case .insertChild(let widget, let index):
- "Insert widget \(ObjectIdentifier(widget.widget as AnyObject)) at \(index)"
- case .removeChild(let index):
- "Remove widget at \(index)"
- case .swapChildren(let firstIndex, let secondIndex):
- "Swap widgets at \(firstIndex) and \(secondIndex)"
- }
- }
- }
- /// Only used by ``ForEach/deprecatedUpdate(_:children:proposedSize:environment:backend:)``.
- var isFirstUpdate = true
- /// A cache of the view's children, used when the ForEach's element
- /// identifiers haven't changed since the previous layout computation.
- var layoutableChildren: [LayoutSystem.LayoutableChild] = []
- var widgets: [AnyWidget] {
- nodes.map(\.widget)
- }
- // TODO: This pattern of erasing by wrapping in a temporary class seems
- // inefficient. Could ErasedViewGraphNode maybe be a struct instead?
- var erasedNodes: [ErasedViewGraphNode] {
- nodes.map(ErasedViewGraphNode.init(wrapping:))
- }
- var stackLayoutCache = StackLayoutCache.initial
- init<Backend: BaseAppBackend>(
- from view: ForEach<Items, ID, Child>,
- backend: Backend,
- idKeyPath: KeyPath<Items.Element, ID>?,
- snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
- environment: EnvironmentValues
- ) {
- identifierMap = [:]
- identifiers = []
- if idKeyPath == nil {
- // Deprecated code path. I'm not touching this anymore cause it's
- // gonna get deleted before any proper release.
- nodes = view.elements
- .map(view.child)
- .enumerated()
- .map { (index, child) in
- let snapshot = index < snapshots?.count ?? 0 ? snapshots?[index] : nil
- return ViewGraphNode(
- for: child,
- backend: backend,
- snapshot: snapshot,
- environment: environment
- )
- }
- .map(AnyViewGraphNode.init(_:))
- } else {
- nodes = []
- }
- }
- }
- extension ForEach where ID == Int {
- /// Creates a view that creates child views on demand based on a collection of data.
- @available(
- *,
- deprecated,
- renamed: "init(_:id:_:)",
- message: """
- ForEach requires an explicit 'id' parameter for non-Identifiable \
- elements to correctly persist state across view updates
- """
- )
- @_disfavoredOverload
- public init(
- _ elements: Items,
- @ViewBuilder _ child: @escaping (Items.Element) -> Child
- ) {
- self.elements = elements
- self.child = child
- self.idKeyPath = nil
- }
- }
- extension ForEach where Items.Element: Identifiable, ID == Items.Element.ID {
- /// Creates a view that creates child views on demand based on a collection of identifiable data.
- public init(
- _ elements: Items,
- @ViewBuilder _ child: @escaping (Items.Element) -> Child
- ) {
- self.elements = elements
- self.child = child
- self.idKeyPath = \.id
- }
- }
- // MARK: Deprecated MenuItem-based inits
- extension ForEach where ID == Int {
- /// Creates a view that creates child views on demand based on a collection of data.
- @available(
- *,
- deprecated,
- message: """
- ForEach requires an explicit 'id' parameter for non-Identifiable \
- elements to correctly persist state across view updates
- """
- )
- @_disfavoredOverload
- public init(
- menuItems elements: Items,
- @ViewBuilder _ child: @escaping (Items.Element) -> Child
- ) {
- self.elements = elements
- self.child = child
- self.idKeyPath = nil
- }
- }
- extension ForEach {
- /// Creates a view that creates child views on demand based on a collection of data.
- @available(
- *,
- deprecated,
- renamed: "init(_:id:_:)",
- message: """
- Special treatment of menu item ForEach blocks is no longer necessary. \
- Remove the menuItems parameter label.
- """
- )
- public init(
- menuItems elements: Items,
- id keyPath: KeyPath<Items.Element, ID>,
- @ViewBuilder _ child: @escaping (Items.Element) -> Child
- ) {
- self.elements = elements
- self.child = child
- self.idKeyPath = keyPath
- }
- }
- extension ForEach where Items.Element: Identifiable, ID == Items.Element.ID {
- /// Creates a view that creates child views on demand based on a collection of data.
- @available(
- *,
- deprecated,
- renamed: "init(_:_:)",
- message: """
- Special treatment of menu item ForEach blocks is no longer necessary. \
- Remove the menuItems parameter label.
- """
- )
- public init(
- menuItems elements: Items,
- @ViewBuilder _ child: @escaping (Items.Element) -> Child
- ) {
- self.elements = elements
- self.child = child
- self.idKeyPath = \.id
- }
- }
|