Add support for preferences, `@StateObject`, `@EnvironmentObject`, and custom `DynamicProperty` types (#501)

* Pass preferences up the Fiber tree

* Working preferences (except for backgroundPreferenceValue)

* Initial StateObject support

* Fix layout caching bug

* Support StateObject/EnvironmentObject/custom DynamicProperty

* Add doc comments for bindProperties and updateDynamicProperties

* Use immediate scheduling in static HTML and test renderers

* Add preferences test

* Add state tests and improve testing API

* Attempt to fix tests

* Fix preference tests

* Attempt to fix tests when Dispatch is unavailable

* #if out on os(WASI)

* Add check for WASI to test

* Add check for WASI to TestViewProxy

* #if out of import Dispatch when os == WASI

* Remove all Dispatch code

* Remove address from debugDescription

* Move TestViewProxy to TokamakTestRenderer

* Add memory address to Fiber.debugDescription

* Fix copyright year

* Add several missing types to Core.swift

* Add missing LayoutValueKey to Core.swift

* Fix issues with view trait propagation

* Enable App/SceneStorage in DOMFiberRenderer

* Address review comments

* Revise preference implementation
This commit is contained in:
Carson Katri 2022-07-05 18:04:28 -04:00 committed by GitHub
parent 9d0e2fc067
commit 676760d34b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 823 additions and 262 deletions

View File

@ -17,12 +17,13 @@
extension FiberReconciler.Fiber: CustomDebugStringConvertible { extension FiberReconciler.Fiber: CustomDebugStringConvertible {
public var debugDescription: String { public var debugDescription: String {
let memoryAddress = String(format: "%010p", unsafeBitCast(self, to: Int.self))
if case let .view(view, _) = content, if case let .view(view, _) = content,
let text = view as? Text let text = view as? Text
{ {
return "Text(\"\(text.storage.rawText)\")" return "Text(\"\(text.storage.rawText)\") (\(memoryAddress))"
} }
return typeInfo?.name ?? "Unknown" return "\(typeInfo?.name ?? "Unknown") (\(memoryAddress))"
} }
private func flush(level: Int = 0) -> String { private func flush(level: Int = 0) -> String {

View File

@ -16,7 +16,9 @@
// //
import Foundation import Foundation
import OpenCombineShim
// swiftlint:disable type_body_length
@_spi(TokamakCore) @_spi(TokamakCore)
public extension FiberReconciler { public extension FiberReconciler {
/// A manager for a single `View`. /// A manager for a single `View`.
@ -83,22 +85,36 @@ public extension FiberReconciler {
/// Parent references are `unowned` (as opposed to `weak`) /// Parent references are `unowned` (as opposed to `weak`)
/// because the parent will always exist if a child does. /// because the parent will always exist if a child does.
/// If the parent is released, the child is released with it. /// If the parent is released, the child is released with it.
unowned var parent: Fiber? @_spi(TokamakCore)
public unowned var parent: Fiber?
/// The nearest parent that can be mounted on. /// The nearest parent that can be mounted on.
unowned var elementParent: Fiber? unowned var elementParent: Fiber?
/// The nearest parent that receives preferences.
unowned var preferenceParent: Fiber?
/// The cached type information for the underlying `View`. /// The cached type information for the underlying `View`.
var typeInfo: TypeInfo? var typeInfo: TypeInfo?
/// Boxes that store `State` data. /// Boxes that store `State` data.
var state: [PropertyInfo: MutableStorage] = [:] var state: [PropertyInfo: MutableStorage] = [:]
/// Subscribed `Cancellable`s keyed with the property contained the observable.
///
/// Each time properties are bound, a new subscription could be created.
/// When the subscription is overridden, the old cancellable is released.
var subscriptions: [PropertyInfo: AnyCancellable] = [:]
/// Storage for `PreferenceKey` values as they are passed up the tree.
var preferences: _PreferenceStore?
/// The computed dimensions and origin. /// The computed dimensions and origin.
var geometry: ViewGeometry? var geometry: ViewGeometry?
/// The WIP node if this is current, or the current node if this is WIP. /// The WIP node if this is current, or the current node if this is WIP.
weak var alternate: Fiber? @_spi(TokamakCore)
public weak var alternate: Fiber?
var createAndBindAlternate: (() -> Fiber?)? var createAndBindAlternate: (() -> Fiber?)?
@ -129,6 +145,7 @@ public extension FiberReconciler {
element: Renderer.ElementType?, element: Renderer.ElementType?,
parent: Fiber?, parent: Fiber?,
elementParent: Fiber?, elementParent: Fiber?,
preferenceParent: Fiber?,
elementIndex: Int?, elementIndex: Int?,
traits: _ViewTraitStore?, traits: _ViewTraitStore?,
reconciler: FiberReconciler<Renderer>? reconciler: FiberReconciler<Renderer>?
@ -138,17 +155,24 @@ public extension FiberReconciler {
sibling = nil sibling = nil
self.parent = parent self.parent = parent
self.elementParent = elementParent self.elementParent = elementParent
self.preferenceParent = preferenceParent
typeInfo = TokamakCore.typeInfo(of: V.self) typeInfo = TokamakCore.typeInfo(of: V.self)
let environment = parent?.outputs.environment ?? .init(.init()) let environment = parent?.outputs.environment ?? .init(.init())
state = bindProperties(to: &view, typeInfo, environment.environment) bindProperties(to: &view, typeInfo, environment.environment)
var updateView = view
let viewInputs = ViewInputs( let viewInputs = ViewInputs(
content: view, content: view,
updateContent: { $0(&updateView) },
environment: environment, environment: environment,
traits: traits traits: traits,
preferenceStore: preferences
) )
outputs = V._makeView(viewInputs) outputs = V._makeView(viewInputs)
if let preferenceStore = outputs.preferenceStore {
preferences = preferenceStore
}
view = updateView
content = content(for: view) content = content(for: view)
if let element = element { if let element = element {
@ -175,6 +199,8 @@ public extension FiberReconciler {
let alternate = Fiber( let alternate = Fiber(
bound: alternateView, bound: alternateView,
state: self.state, state: self.state,
subscriptions: self.subscriptions,
preferences: self.preferences,
layout: self.layout, layout: self.layout,
alternate: self, alternate: self,
outputs: self.outputs, outputs: self.outputs,
@ -182,6 +208,7 @@ public extension FiberReconciler {
element: self.element, element: self.element,
parent: self.parent?.alternate, parent: self.parent?.alternate,
elementParent: self.elementParent?.alternate, elementParent: self.elementParent?.alternate,
preferenceParent: self.preferenceParent?.alternate,
reconciler: reconciler reconciler: reconciler
) )
self.alternate = alternate self.alternate = alternate
@ -205,6 +232,8 @@ public extension FiberReconciler {
init<V: View>( init<V: View>(
bound view: V, bound view: V,
state: [PropertyInfo: MutableStorage], state: [PropertyInfo: MutableStorage],
subscriptions: [PropertyInfo: AnyCancellable],
preferences: _PreferenceStore?,
layout: AnyLayout!, layout: AnyLayout!,
alternate: Fiber, alternate: Fiber,
outputs: ViewOutputs, outputs: ViewOutputs,
@ -212,6 +241,7 @@ public extension FiberReconciler {
element: Renderer.ElementType?, element: Renderer.ElementType?,
parent: FiberReconciler<Renderer>.Fiber?, parent: FiberReconciler<Renderer>.Fiber?,
elementParent: Fiber?, elementParent: Fiber?,
preferenceParent: Fiber?,
reconciler: FiberReconciler<Renderer>? reconciler: FiberReconciler<Renderer>?
) { ) {
self.alternate = alternate self.alternate = alternate
@ -221,9 +251,12 @@ public extension FiberReconciler {
sibling = nil sibling = nil
self.parent = parent self.parent = parent
self.elementParent = elementParent self.elementParent = elementParent
self.preferenceParent = preferenceParent
self.typeInfo = typeInfo self.typeInfo = typeInfo
self.outputs = outputs self.outputs = outputs
self.state = state self.state = state
self.subscriptions = subscriptions
self.preferences = preferences
if element != nil { if element != nil {
self.layout = layout self.layout = layout
} }
@ -234,36 +267,98 @@ public extension FiberReconciler {
to content: inout T, to content: inout T,
_ typeInfo: TypeInfo?, _ typeInfo: TypeInfo?,
_ environment: EnvironmentValues _ environment: EnvironmentValues
) -> [PropertyInfo: MutableStorage] { ) {
guard let typeInfo = typeInfo else { return [:] } var erased: Any = content
bindProperties(to: &erased, typeInfo, environment)
// swiftlint:disable:next force_cast
content = erased as! T
}
/// Collect `DynamicProperty`s and link their state changes to the reconciler.
private func bindProperties(
to content: inout Any,
_ typeInfo: TypeInfo?,
_ environment: EnvironmentValues
) {
guard let typeInfo = typeInfo else { return }
var state: [PropertyInfo: MutableStorage] = [:]
for property in typeInfo.properties where property.type is DynamicProperty.Type { for property in typeInfo.properties where property.type is DynamicProperty.Type {
var value = property.get(from: content) var value = property.get(from: content)
// Bind nested properties.
bindProperties(to: &value, TokamakCore.typeInfo(of: property.type), environment)
// Create boxes for `@State` and other mutable properties.
if var storage = value as? WritableValueStorage { if var storage = value as? WritableValueStorage {
let box = self.state[property] ?? MutableStorage( let box = self.state[property] ?? MutableStorage(
initialValue: storage.anyInitialValue, initialValue: storage.anyInitialValue,
onSet: { [weak self] in onSet: { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.reconciler?.reconcile(from: self) self.reconciler?.fiberChanged(self)
} }
) )
state[property] = box state[property] = box
storage.getter = { box.value } storage.getter = { box.value }
storage.setter = { box.setValue($0, with: $1) } storage.setter = { box.setValue($0, with: $1) }
value = storage value = storage
// Create boxes for `@StateObject` and other immutable properties.
} else if var storage = value as? ValueStorage {
let box = self.state[property] ?? MutableStorage(
initialValue: storage.anyInitialValue,
onSet: {}
)
state[property] = box
storage.getter = { box.value }
value = storage
// Read from the environment.
} else if var environmentReader = value as? EnvironmentReader { } else if var environmentReader = value as? EnvironmentReader {
environmentReader.setContent(from: environment) environmentReader.setContent(from: environment)
value = environmentReader value = environmentReader
} }
// Subscribe to observable properties.
if let observed = value as? ObservedProperty {
subscriptions[property] = observed.objectWillChange.sink { [weak self] _ in
guard let self = self else { return }
self.reconciler?.fiberChanged(self)
}
}
property.set(value: value, on: &content) property.set(value: value, on: &content)
} }
if var environmentReader = content as? EnvironmentReader { if var environmentReader = content as? EnvironmentReader {
environmentReader.setContent(from: environment) environmentReader.setContent(from: environment)
// swiftlint:disable:next force_cast content = environmentReader
content = environmentReader as! T }
}
/// Call `update()` on each `DynamicProperty` in the type.
private func updateDynamicProperties(
of content: inout Any,
_ typeInfo: TypeInfo?
) {
guard let typeInfo = typeInfo else { return }
for property in typeInfo.properties where property.type is DynamicProperty.Type {
var value = property.get(from: content)
// Update nested properties.
updateDynamicProperties(of: &value, TokamakCore.typeInfo(of: property.type))
// swiftlint:disable:next force_cast
var dynamicProperty = value as! DynamicProperty
dynamicProperty.update()
property.set(value: dynamicProperty, on: &content)
}
}
/// Update each `DynamicProperty` in our content.
func updateDynamicProperties() {
guard let content = content else { return }
switch content {
case .app(var app, let visit):
updateDynamicProperties(of: &app, typeInfo)
self.content = .app(app, visit: visit)
case .scene(var scene, let visit):
updateDynamicProperties(of: &scene, typeInfo)
self.content = .scene(scene, visit: visit)
case .view(var view, let visit):
updateDynamicProperties(of: &view, typeInfo)
self.content = .view(view, visit: visit)
} }
return state
} }
func update<V: View>( func update<V: View>(
@ -276,14 +371,20 @@ public extension FiberReconciler {
self.elementIndex = elementIndex self.elementIndex = elementIndex
let environment = parent?.outputs.environment ?? .init(.init()) let environment = parent?.outputs.environment ?? .init(.init())
state = bindProperties(to: &view, typeInfo, environment.environment) bindProperties(to: &view, typeInfo, environment.environment)
content = content(for: view) var updateView = view
let inputs = ViewInputs( let inputs = ViewInputs(
content: view, content: view,
updateContent: {
$0(&updateView)
},
environment: environment, environment: environment,
traits: traits traits: traits,
preferenceStore: preferences
) )
outputs = V._makeView(inputs) outputs = V._makeView(inputs)
view = updateView
content = content(for: view)
if element != nil { if element != nil {
layout = (view as? _AnyLayout)?._erased() ?? DefaultLayout.shared layout = (view as? _AnyLayout)?._erased() ?? DefaultLayout.shared
@ -308,13 +409,26 @@ public extension FiberReconciler {
// `App`s are always the root, so they can have no parent. // `App`s are always the root, so they can have no parent.
parent = nil parent = nil
elementParent = nil elementParent = nil
preferenceParent = nil
element = rootElement element = rootElement
typeInfo = TokamakCore.typeInfo(of: A.self) typeInfo = TokamakCore.typeInfo(of: A.self)
state = bindProperties(to: &app, typeInfo, rootEnvironment) bindProperties(to: &app, typeInfo, rootEnvironment)
var updateApp = app
outputs = .init( outputs = .init(
inputs: .init(content: app, environment: .init(rootEnvironment), traits: .init()) inputs: .init(
content: app,
updateContent: {
$0(&updateApp)
},
environment: .init(rootEnvironment),
traits: .init(),
preferenceStore: preferences
)
) )
if let preferenceStore = outputs.preferenceStore {
preferences = preferenceStore
}
app = updateApp
content = content(for: app) content = content(for: app)
layout = .init(RootLayout(renderer: reconciler.renderer)) layout = .init(RootLayout(renderer: reconciler.renderer))
@ -326,6 +440,8 @@ public extension FiberReconciler {
let alternate = Fiber( let alternate = Fiber(
bound: alternateApp, bound: alternateApp,
state: self.state, state: self.state,
subscriptions: self.subscriptions,
preferences: self.preferences,
layout: self.layout, layout: self.layout,
alternate: self, alternate: self,
outputs: self.outputs, outputs: self.outputs,
@ -341,6 +457,8 @@ public extension FiberReconciler {
init<A: App>( init<A: App>(
bound app: A, bound app: A,
state: [PropertyInfo: MutableStorage], state: [PropertyInfo: MutableStorage],
subscriptions: [PropertyInfo: AnyCancellable],
preferences: _PreferenceStore?,
layout: AnyLayout?, layout: AnyLayout?,
alternate: Fiber, alternate: Fiber,
outputs: SceneOutputs, outputs: SceneOutputs,
@ -355,9 +473,12 @@ public extension FiberReconciler {
sibling = nil sibling = nil
parent = nil parent = nil
elementParent = nil elementParent = nil
preferenceParent = nil
self.typeInfo = typeInfo self.typeInfo = typeInfo
self.outputs = outputs self.outputs = outputs
self.state = state self.state = state
self.subscriptions = subscriptions
self.preferences = preferences
self.layout = layout self.layout = layout
content = content(for: app) content = content(for: app)
} }
@ -367,6 +488,7 @@ public extension FiberReconciler {
element: Renderer.ElementType?, element: Renderer.ElementType?,
parent: Fiber?, parent: Fiber?,
elementParent: Fiber?, elementParent: Fiber?,
preferenceParent: Fiber?,
environment: EnvironmentBox?, environment: EnvironmentBox?,
reconciler: FiberReconciler<Renderer>? reconciler: FiberReconciler<Renderer>?
) { ) {
@ -376,18 +498,27 @@ public extension FiberReconciler {
self.parent = parent self.parent = parent
self.elementParent = elementParent self.elementParent = elementParent
self.element = element self.element = element
self.preferenceParent = preferenceParent
typeInfo = TokamakCore.typeInfo(of: S.self) typeInfo = TokamakCore.typeInfo(of: S.self)
let environment = environment ?? parent?.outputs.environment ?? .init(.init()) let environment = environment ?? parent?.outputs.environment ?? .init(.init())
state = bindProperties(to: &scene, typeInfo, environment.environment) bindProperties(to: &scene, typeInfo, environment.environment)
var updateScene = scene
outputs = S._makeScene( outputs = S._makeScene(
.init( .init(
content: scene, content: scene,
updateContent: {
$0(&updateScene)
},
environment: environment, environment: environment,
traits: .init() traits: .init(),
preferenceStore: preferences
) )
) )
if let preferenceStore = outputs.preferenceStore {
preferences = preferenceStore
}
scene = updateScene
content = content(for: scene) content = content(for: scene)
if element != nil { if element != nil {
@ -401,6 +532,8 @@ public extension FiberReconciler {
let alternate = Fiber( let alternate = Fiber(
bound: alternateScene, bound: alternateScene,
state: self.state, state: self.state,
subscriptions: self.subscriptions,
preferences: self.preferences,
layout: self.layout, layout: self.layout,
alternate: self, alternate: self,
outputs: self.outputs, outputs: self.outputs,
@ -408,6 +541,7 @@ public extension FiberReconciler {
element: self.element, element: self.element,
parent: self.parent?.alternate, parent: self.parent?.alternate,
elementParent: self.elementParent?.alternate, elementParent: self.elementParent?.alternate,
preferenceParent: self.preferenceParent?.alternate,
reconciler: reconciler reconciler: reconciler
) )
self.alternate = alternate self.alternate = alternate
@ -431,6 +565,8 @@ public extension FiberReconciler {
init<S: Scene>( init<S: Scene>(
bound scene: S, bound scene: S,
state: [PropertyInfo: MutableStorage], state: [PropertyInfo: MutableStorage],
subscriptions: [PropertyInfo: AnyCancellable],
preferences: _PreferenceStore?,
layout: AnyLayout!, layout: AnyLayout!,
alternate: Fiber, alternate: Fiber,
outputs: SceneOutputs, outputs: SceneOutputs,
@ -438,6 +574,7 @@ public extension FiberReconciler {
element: Renderer.ElementType?, element: Renderer.ElementType?,
parent: FiberReconciler<Renderer>.Fiber?, parent: FiberReconciler<Renderer>.Fiber?,
elementParent: Fiber?, elementParent: Fiber?,
preferenceParent: Fiber?,
reconciler: FiberReconciler<Renderer>? reconciler: FiberReconciler<Renderer>?
) { ) {
self.alternate = alternate self.alternate = alternate
@ -447,9 +584,12 @@ public extension FiberReconciler {
sibling = nil sibling = nil
self.parent = parent self.parent = parent
self.elementParent = elementParent self.elementParent = elementParent
self.preferenceParent = preferenceParent
self.typeInfo = typeInfo self.typeInfo = typeInfo
self.outputs = outputs self.outputs = outputs
self.state = state self.state = state
self.subscriptions = subscriptions
self.preferences = preferences
if element != nil { if element != nil {
self.layout = layout self.layout = layout
} }
@ -462,13 +602,19 @@ public extension FiberReconciler {
typeInfo = TokamakCore.typeInfo(of: S.self) typeInfo = TokamakCore.typeInfo(of: S.self)
let environment = parent?.outputs.environment ?? .init(.init()) let environment = parent?.outputs.environment ?? .init(.init())
state = bindProperties(to: &scene, typeInfo, environment.environment) bindProperties(to: &scene, typeInfo, environment.environment)
content = content(for: scene) var updateScene = scene
outputs = S._makeScene(.init( outputs = S._makeScene(.init(
content: scene, content: scene,
updateContent: {
$0(&updateScene)
},
environment: environment, environment: environment,
traits: .init() traits: .init(),
preferenceStore: preferences
)) ))
scene = updateScene
content = content(for: scene)
if element != nil { if element != nil {
layout = (scene as? _AnyLayout)?._erased() ?? DefaultLayout.shared layout = (scene as? _AnyLayout)?._erased() ?? DefaultLayout.shared

View File

@ -29,12 +29,12 @@ extension FiberReconciler {
var sibling: Result? var sibling: Result?
var newContent: Renderer.ElementType.Content? var newContent: Renderer.ElementType.Content?
var elementIndices: [ObjectIdentifier: Int] var elementIndices: [ObjectIdentifier: Int]
var nextTraits: _ViewTraitStore
// For reducing // For reducing
var lastSibling: Result? var lastSibling: Result?
var nextExisting: Fiber? var nextExisting: Fiber?
var nextExistingAlternate: Fiber? var nextExistingAlternate: Fiber?
var pendingTraits: _ViewTraitStore
init( init(
fiber: Fiber?, fiber: Fiber?,
@ -44,7 +44,7 @@ extension FiberReconciler {
alternateChild: Fiber?, alternateChild: Fiber?,
newContent: Renderer.ElementType.Content? = nil, newContent: Renderer.ElementType.Content? = nil,
elementIndices: [ObjectIdentifier: Int], elementIndices: [ObjectIdentifier: Int],
pendingTraits: _ViewTraitStore nextTraits: _ViewTraitStore
) { ) {
self.fiber = fiber self.fiber = fiber
self.visitChildren = visitChildren self.visitChildren = visitChildren
@ -53,7 +53,7 @@ extension FiberReconciler {
nextExistingAlternate = alternateChild nextExistingAlternate = alternateChild
self.newContent = newContent self.newContent = newContent
self.elementIndices = elementIndices self.elementIndices = elementIndices
self.pendingTraits = pendingTraits self.nextTraits = nextTraits
} }
} }
@ -61,12 +61,13 @@ extension FiberReconciler {
Self.reduce( Self.reduce(
into: &partialResult, into: &partialResult,
nextValue: nextScene, nextValue: nextScene,
createFiber: { scene, element, parent, elementParent, _, _, reconciler in createFiber: { scene, element, parent, elementParent, preferenceParent, _, _, reconciler in
Fiber( Fiber(
&scene, &scene,
element: element, element: element,
parent: parent, parent: parent,
elementParent: elementParent, elementParent: elementParent,
preferenceParent: preferenceParent,
environment: nil, environment: nil,
reconciler: reconciler reconciler: reconciler
) )
@ -82,12 +83,16 @@ extension FiberReconciler {
Self.reduce( Self.reduce(
into: &partialResult, into: &partialResult,
nextValue: nextView, nextValue: nextView,
createFiber: { view, element, parent, elementParent, elementIndex, traits, reconciler in createFiber: {
view, element,
parent, elementParent, preferenceParent, elementIndex,
traits, reconciler in
Fiber( Fiber(
&view, &view,
element: element, element: element,
parent: parent, parent: parent,
elementParent: elementParent, elementParent: elementParent,
preferenceParent: preferenceParent,
elementIndex: elementIndex, elementIndex: elementIndex,
traits: traits, traits: traits,
reconciler: reconciler reconciler: reconciler
@ -114,6 +119,7 @@ extension FiberReconciler {
Renderer.ElementType?, Renderer.ElementType?,
Fiber?, Fiber?,
Fiber?, Fiber?,
Fiber?,
Int?, Int?,
_ViewTraitStore, _ViewTraitStore,
FiberReconciler? FiberReconciler?
@ -123,6 +129,7 @@ extension FiberReconciler {
) { ) {
// Create the node and its element. // Create the node and its element.
var nextValue = nextValue var nextValue = nextValue
let resultChild: Result let resultChild: Result
if let existing = partialResult.nextExisting { if let existing = partialResult.nextExisting {
// If a fiber already exists, simply update it with the new view. // If a fiber already exists, simply update it with the new view.
@ -136,7 +143,7 @@ extension FiberReconciler {
existing, existing,
&nextValue, &nextValue,
key.map { partialResult.elementIndices[$0, default: 0] }, key.map { partialResult.elementIndices[$0, default: 0] },
partialResult.pendingTraits partialResult.nextTraits
) )
resultChild = Result( resultChild = Result(
fiber: existing, fiber: existing,
@ -146,7 +153,7 @@ extension FiberReconciler {
alternateChild: existing.alternate?.child, alternateChild: existing.alternate?.child,
newContent: newContent, newContent: newContent,
elementIndices: partialResult.elementIndices, elementIndices: partialResult.elementIndices,
pendingTraits: existing.element != nil ? .init() : partialResult.pendingTraits nextTraits: existing.element != nil ? .init() : partialResult.nextTraits
) )
partialResult.nextExisting = existing.sibling partialResult.nextExisting = existing.sibling
partialResult.nextExistingAlternate = partialResult.nextExistingAlternate?.sibling partialResult.nextExistingAlternate = partialResult.nextExistingAlternate?.sibling
@ -154,6 +161,9 @@ extension FiberReconciler {
let elementParent = partialResult.fiber?.element != nil let elementParent = partialResult.fiber?.element != nil
? partialResult.fiber ? partialResult.fiber
: partialResult.fiber?.elementParent : partialResult.fiber?.elementParent
let preferenceParent = partialResult.fiber?.preferences != nil
? partialResult.fiber
: partialResult.fiber?.preferenceParent
let key: ObjectIdentifier? let key: ObjectIdentifier?
if let elementParent = elementParent { if let elementParent = elementParent {
key = ObjectIdentifier(elementParent) key = ObjectIdentifier(elementParent)
@ -166,10 +176,12 @@ extension FiberReconciler {
partialResult.nextExistingAlternate?.element, partialResult.nextExistingAlternate?.element,
partialResult.fiber, partialResult.fiber,
elementParent, elementParent,
preferenceParent,
key.map { partialResult.elementIndices[$0, default: 0] }, key.map { partialResult.elementIndices[$0, default: 0] },
partialResult.pendingTraits, partialResult.nextTraits,
partialResult.fiber?.reconciler partialResult.fiber?.reconciler
) )
// If a fiber already exists for an alternate, link them. // If a fiber already exists for an alternate, link them.
if let alternate = partialResult.nextExistingAlternate { if let alternate = partialResult.nextExistingAlternate {
fiber.alternate = alternate fiber.alternate = alternate
@ -182,7 +194,7 @@ extension FiberReconciler {
child: nil, child: nil,
alternateChild: fiber.alternate?.child, alternateChild: fiber.alternate?.child,
elementIndices: partialResult.elementIndices, elementIndices: partialResult.elementIndices,
pendingTraits: fiber.element != nil ? .init() : partialResult.pendingTraits nextTraits: fiber.element != nil ? .init() : partialResult.nextTraits
) )
} }
// Get the last child element we've processed, and add the new child as its sibling. // Get the last child element we've processed, and add the new child as its sibling.

View File

@ -39,6 +39,13 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
private let caches: Caches private let caches: Caches
private var isReconciling = false private var isReconciling = false
/// The identifiers for each `Fiber` that changed state during the last run loop.
///
/// The reconciler loop starts at the root of the `View` hierarchy
/// to ensure all preference values are passed down correctly.
/// To help mitigate performance issues related to this, we only perform reconcile
/// checks when we reach a changed `Fiber`.
private var changedFibers = Set<ObjectIdentifier>()
public var afterReconcileActions = [() -> ()]() public var afterReconcileActions = [() -> ()]()
struct RootView<Content: View>: View { struct RootView<Content: View>: View {
@ -105,13 +112,14 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
element: renderer.rootElement, element: renderer.rootElement,
parent: nil, parent: nil,
elementParent: nil, elementParent: nil,
preferenceParent: nil,
elementIndex: 0, elementIndex: 0,
traits: nil, traits: nil,
reconciler: self reconciler: self
) )
// Start by building the initial tree. // Start by building the initial tree.
alternate = current.createAndBindAlternate?() alternate = current.createAndBindAlternate?()
reconcile(from: current) fiberChanged(current)
} }
public init<A: App>(_ renderer: Renderer, _ app: A) { public init<A: App>(_ renderer: Renderer, _ app: A) {
@ -135,19 +143,20 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
) )
// Start by building the initial tree. // Start by building the initial tree.
alternate = current.createAndBindAlternate?() alternate = current.createAndBindAlternate?()
reconcile(from: current) fiberChanged(current)
} }
/// A visitor that performs each pass used by the `FiberReconciler`. /// A visitor that performs each pass used by the `FiberReconciler`.
final class ReconcilerVisitor: AppVisitor, SceneVisitor, ViewVisitor { final class ReconcilerVisitor: AppVisitor, SceneVisitor, ViewVisitor {
let root: Fiber let root: Fiber
let reconcileRoot: Fiber /// Any `Fiber`s that changed state during the last run loop.
let changedFibers: Set<ObjectIdentifier>
unowned let reconciler: FiberReconciler unowned let reconciler: FiberReconciler
var mutations = [Mutation<Renderer>]() var mutations = [Mutation<Renderer>]()
init(root: Fiber, reconcileRoot: Fiber, reconciler: FiberReconciler) { init(root: Fiber, changedFibers: Set<ObjectIdentifier>, reconciler: FiberReconciler) {
self.root = root self.root = root
self.reconcileRoot = reconcileRoot self.changedFibers = changedFibers
self.reconciler = reconciler self.reconciler = reconciler
} }
@ -173,13 +182,6 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
} else { } else {
alternateRoot = root.createAndBindAlternate?() alternateRoot = root.createAndBindAlternate?()
} }
let alternateReconcileRoot: Fiber?
if let alternate = reconcileRoot.alternate {
alternateReconcileRoot = alternate
} else {
alternateReconcileRoot = reconcileRoot.createAndBindAlternate?()
}
guard let alternateReconcileRoot = alternateReconcileRoot else { return }
let rootResult = TreeReducer.Result( let rootResult = TreeReducer.Result(
fiber: alternateRoot, // The alternate is the WIP node. fiber: alternateRoot, // The alternate is the WIP node.
visitChildren: visitChildren, visitChildren: visitChildren,
@ -187,14 +189,14 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
child: alternateRoot?.child, child: alternateRoot?.child,
alternateChild: root.child, alternateChild: root.child,
elementIndices: [:], elementIndices: [:],
pendingTraits: .init() nextTraits: .init()
) )
reconciler.caches.clear() reconciler.caches.clear()
for pass in reconciler.passes { for pass in reconciler.passes {
pass.run( pass.run(
in: reconciler, in: reconciler,
root: rootResult, root: rootResult,
reconcileRoot: alternateReconcileRoot, changedFibers: changedFibers,
caches: reconciler.caches caches: reconciler.caches
) )
} }
@ -211,18 +213,31 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
afterReconcileActions.append(action) afterReconcileActions.append(action)
} }
func reconcile(from updateRoot: Fiber) { /// Called by any `Fiber` that experiences a state change.
isReconciling = true ///
let root: Fiber /// Reconciliation only runs after every change during the current run loop has been performed.
if renderer.useDynamicLayout { func fiberChanged(_ fiber: Fiber) {
// We need to re-layout from the top down when using dynamic layout. guard let alternate = fiber.alternate ?? fiber.createAndBindAlternate?()
root = current else { return }
} else { let shouldSchedule = changedFibers.isEmpty
root = updateRoot changedFibers.insert(ObjectIdentifier(alternate))
if shouldSchedule {
renderer.schedule { [weak self] in
self?.reconcile()
}
} }
}
/// Perform each `FiberReconcilerPass` given the `changedFibers`.
///
/// A `reconcile()` call is queued from `fiberChanged` once per run loop.
func reconcile() {
isReconciling = true
let changedFibers = changedFibers
self.changedFibers.removeAll()
// Create a list of mutations. // Create a list of mutations.
let visitor = ReconcilerVisitor(root: root, reconcileRoot: updateRoot, reconciler: self) let visitor = ReconcilerVisitor(root: current, changedFibers: changedFibers, reconciler: self)
switch root.content { switch current.content {
case let .view(_, visit): case let .view(_, visit):
visit(visitor) visit(visitor)
case let .scene(_, visit): case let .scene(_, visit):
@ -240,15 +255,9 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
// Essentially, making the work in progress tree the current, // Essentially, making the work in progress tree the current,
// and leaving the current available to be the work in progress // and leaving the current available to be the work in progress
// on our next update. // on our next update.
if root === current { let alternate = alternate
let alternate = alternate self.alternate = current
self.alternate = current current = alternate
current = alternate
} else {
let child = root.child
root.child = root.alternate?.child
root.alternate?.child = child
}
isReconciling = false isReconciling = false

View File

@ -57,6 +57,33 @@ public protocol FiberRenderer {
proposal: ProposedViewSize, proposal: ProposedViewSize,
in environment: EnvironmentValues in environment: EnvironmentValues
) -> CGSize ) -> CGSize
/// Run `action` on the next run loop.
///
/// Called by the `FiberReconciler` to perform reconciliation after all changed Fibers are collected.
///
/// For example, take the following sample `View`:
///
/// struct DuelOfTheStates: View {
/// @State private var hits1 = 0
/// @State private var hits2 = 0
///
/// var body: some View {
/// Button("Hit") {
/// hits1 += 1
/// hits2 += 2
/// }
/// }
/// }
///
/// When the button is pressed, both `hits1` and `hits2` are updated.
/// If reconciliation was done on every state change, we would needlessly run it twice,
/// once for `hits1` and again for `hits2`.
///
/// Instead, we create a list of changed fibers
/// (in this case just `DuelOfTheStates` as both properties were on it),
/// and reconcile after all changes have been collected.
func schedule(_ action: @escaping () -> ())
} }
public extension FiberRenderer { public extension FiberRenderer {

View File

@ -141,8 +141,9 @@ public struct LayoutSubview: Equatable {
) )
cache.sizeThatFits[request] = size cache.sizeThatFits[request] = size
if let alternate = fiber.alternate { if let alternate = fiber.alternate {
caches.updateLayoutCache(for: alternate) { cache in caches.updateLayoutCache(for: alternate) { alternateCache in
cache.sizeThatFits[request] = size alternateCache.cache = cache.cache
alternateCache.sizeThatFits[request] = size
} }
} }
return size return size
@ -246,6 +247,6 @@ public struct LayoutSubview: Equatable {
} }
public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool {
lhs.id == rhs.id lhs.storage === rhs.storage
} }
} }

View File

@ -117,7 +117,6 @@ public extension StackLayout {
/// The aggregate minimum size of each `View` with a given priority. /// The aggregate minimum size of each `View` with a given priority.
var prioritySize = [Double: CGFloat]() var prioritySize = [Double: CGFloat]()
let measuredSubviews = subviews.enumerated().map { index, view -> MeasuredSubview in let measuredSubviews = subviews.enumerated().map { index, view -> MeasuredSubview in
priorityCount[view.priority, default: 0] += 1 priorityCount[view.priority, default: 0] += 1

View File

@ -61,9 +61,9 @@ extension FiberReconciler {
} }
func clear() { func clear() {
elementIndices = [:] elementIndices.removeAll()
layoutSubviews = [:] layoutSubviews.removeAll()
mutations = [] mutations.removeAll()
} }
func layoutCache(for fiber: Fiber) -> LayoutCache? { func layoutCache(for fiber: Fiber) -> LayoutCache? {
@ -123,14 +123,14 @@ protocol FiberReconcilerPass {
/// - Parameter root: The node to start the pass from. /// - Parameter root: The node to start the pass from.
/// The top of the `View` hierarchy when `useDynamicLayout` is enabled. /// The top of the `View` hierarchy when `useDynamicLayout` is enabled.
/// Otherwise, the same as `reconcileRoot`. /// Otherwise, the same as `reconcileRoot`.
/// - Parameter reconcileRoot: The topmost node that needs reconciliation. /// - Parameter reconcileRoot: A list of topmost nodes that need reconciliation.
/// When `useDynamicLayout` is enabled, this can be used to limit /// When `useDynamicLayout` is enabled, this can be used to limit
/// the number of operations performed during reconciliation. /// the number of operations performed during reconciliation.
/// - Parameter caches: The shared cache data for this and other passes. /// - Parameter caches: The shared cache data for this and other passes.
func run<R: FiberRenderer>( func run<R: FiberRenderer>(
in reconciler: FiberReconciler<R>, in reconciler: FiberReconciler<R>,
root: FiberReconciler<R>.TreeReducer.Result, root: FiberReconciler<R>.TreeReducer.Result,
reconcileRoot: FiberReconciler<R>.Fiber, changedFibers: Set<ObjectIdentifier>,
caches: FiberReconciler<R>.Caches caches: FiberReconciler<R>.Caches
) )
} }

View File

@ -22,7 +22,7 @@ struct LayoutPass: FiberReconcilerPass {
func run<R>( func run<R>(
in reconciler: FiberReconciler<R>, in reconciler: FiberReconciler<R>,
root: FiberReconciler<R>.TreeReducer.Result, root: FiberReconciler<R>.TreeReducer.Result,
reconcileRoot: FiberReconciler<R>.Fiber, changedFibers: Set<ObjectIdentifier>,
caches: FiberReconciler<R>.Caches caches: FiberReconciler<R>.Caches
) where R: FiberRenderer { ) where R: FiberRenderer {
guard let root = root.fiber else { return } guard let root = root.fiber else { return }

View File

@ -63,7 +63,7 @@ struct ReconcilePass: FiberReconcilerPass {
func run<R>( func run<R>(
in reconciler: FiberReconciler<R>, in reconciler: FiberReconciler<R>,
root: FiberReconciler<R>.TreeReducer.Result, root: FiberReconciler<R>.TreeReducer.Result,
reconcileRoot: FiberReconciler<R>.Fiber, changedFibers: Set<ObjectIdentifier>,
caches: FiberReconciler<R>.Caches caches: FiberReconciler<R>.Caches
) where R: FiberRenderer { ) where R: FiberRenderer {
var node = root var node = root
@ -71,12 +71,17 @@ struct ReconcilePass: FiberReconcilerPass {
// Enabled when we reach the `reconcileRoot`. // Enabled when we reach the `reconcileRoot`.
var shouldReconcile = false var shouldReconcile = false
// Traits that should be attached to the nearest rendered child.
var pendingTraits = _ViewTraitStore()
while true { while true {
if node.fiber === reconcileRoot || node.fiber?.alternate === reconcileRoot { if !shouldReconcile {
shouldReconcile = true if let fiber = node.fiber,
changedFibers.contains(ObjectIdentifier(fiber))
{
shouldReconcile = true
} else if let alternate = node.fiber?.alternate,
changedFibers.contains(ObjectIdentifier(alternate))
{
shouldReconcile = true
}
} }
// If this fiber has an element, set its `elementIndex` // If this fiber has an element, set its `elementIndex`
@ -94,27 +99,23 @@ struct ReconcilePass: FiberReconcilerPass {
caches.mutations.append(mutation) caches.mutations.append(mutation)
} }
// Ensure the `TreeReducer` can access any necessary state.
node.elementIndices = caches.elementIndices
// Pass view traits down to the nearest element fiber. // Pass view traits down to the nearest element fiber.
if let traits = node.fiber?.outputs.traits, if let traits = node.fiber?.outputs.traits,
!traits.values.isEmpty !traits.values.isEmpty
{ {
if node.fiber?.element == nil { node.nextTraits.values.merge(traits.values, uniquingKeysWith: { $1 })
pendingTraits = traits
}
}
// Clear the pending traits once they have been applied to the target.
if node.fiber?.element != nil && !pendingTraits.values.isEmpty {
pendingTraits = .init()
} }
// Ensure the `TreeReducer` can access any necessary state. // Update `DynamicProperty`s before accessing the `View`'s body.
node.elementIndices = caches.elementIndices node.fiber?.updateDynamicProperties()
node.pendingTraits = pendingTraits
// Compute the children of the node. // Compute the children of the node.
let reducer = FiberReconciler<R>.TreeReducer.SceneVisitor(initialResult: node) let reducer = FiberReconciler<R>.TreeReducer.SceneVisitor(initialResult: node)
node.visitChildren(reducer) node.visitChildren(reducer)
node.fiber?.preferences?.reset()
if reconciler.renderer.useDynamicLayout, if reconciler.renderer.useDynamicLayout,
let fiber = node.fiber let fiber = node.fiber
{ {
@ -124,7 +125,7 @@ struct ReconcilePass: FiberReconcilerPass {
let parentKey = ObjectIdentifier(elementParent) let parentKey = ObjectIdentifier(elementParent)
let subview = LayoutSubview( let subview = LayoutSubview(
id: ObjectIdentifier(fiber), id: ObjectIdentifier(fiber),
traits: node.fiber?.outputs.traits, traits: fiber.outputs.traits,
fiber: fiber, fiber: fiber,
element: element, element: element,
caches: caches caches: caches
@ -177,6 +178,15 @@ struct ReconcilePass: FiberReconcilerPass {
propagateCacheInvalidation(for: fiber, in: reconciler, caches: caches) propagateCacheInvalidation(for: fiber, in: reconciler, caches: caches)
} }
if let preferences = node.fiber?.preferences {
if let action = node.fiber?.outputs.preferenceAction {
action(preferences)
}
if let parentPreferences = node.fiber?.preferenceParent?.preferences {
parentPreferences.merge(preferences)
}
}
var alternateSibling = node.fiber?.alternate?.sibling var alternateSibling = node.fiber?.alternate?.sibling
// The alternate had siblings that no longer exist. // The alternate had siblings that no longer exist.
while alternateSibling != nil { while alternateSibling != nil {

View File

@ -21,10 +21,17 @@ import Foundation
public struct ViewInputs<V> { public struct ViewInputs<V> {
public let content: V public let content: V
/// Mutate the underlying content with the given inputs.
///
/// Used to inject values such as environment values, traits, and preferences into the `View` type.
public let updateContent: ((inout V) -> ()) -> ()
@_spi(TokamakCore) @_spi(TokamakCore)
public let environment: EnvironmentBox public let environment: EnvironmentBox
public let traits: _ViewTraitStore? public let traits: _ViewTraitStore?
public let preferenceStore: _PreferenceStore?
} }
/// Data used to reconcile and render a `View` and its children. /// Data used to reconcile and render a `View` and its children.
@ -33,7 +40,12 @@ public struct ViewOutputs {
/// This is stored as a reference to avoid copying the environment when unnecessary. /// This is stored as a reference to avoid copying the environment when unnecessary.
let environment: EnvironmentBox let environment: EnvironmentBox
let preferences: _PreferenceStore let preferenceStore: _PreferenceStore?
/// An action to perform after all preferences values have been reduced.
///
/// Called when walking back up the tree in the `ReconcilePass`.
let preferenceAction: ((_PreferenceStore) -> ())?
let traits: _ViewTraitStore? let traits: _ViewTraitStore?
} }
@ -51,13 +63,15 @@ public extension ViewOutputs {
init<V>( init<V>(
inputs: ViewInputs<V>, inputs: ViewInputs<V>,
environment: EnvironmentValues? = nil, environment: EnvironmentValues? = nil,
preferences: _PreferenceStore? = nil, preferenceStore: _PreferenceStore? = nil,
preferenceAction: ((_PreferenceStore) -> ())? = nil,
traits: _ViewTraitStore? = nil traits: _ViewTraitStore? = nil
) { ) {
// Only replace the `EnvironmentBox` when we change the environment. // Only replace the `EnvironmentBox` when we change the environment.
// Otherwise the same box can be reused. // Otherwise the same box can be reused.
self.environment = environment.map(EnvironmentBox.init) ?? inputs.environment self.environment = environment.map(EnvironmentBox.init) ?? inputs.environment
self.preferences = preferences ?? .init() self.preferenceStore = preferenceStore
self.preferenceAction = preferenceAction
self.traits = traits ?? inputs.traits self.traits = traits ?? inputs.traits
} }
} }
@ -74,8 +88,10 @@ public extension ModifiedContent where Content: View, Modifier: ViewModifier {
static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs { static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs {
Modifier._makeView(.init( Modifier._makeView(.init(
content: inputs.content.modifier, content: inputs.content.modifier,
updateContent: { _ in },
environment: inputs.environment, environment: inputs.environment,
traits: inputs.traits traits: inputs.traits,
preferenceStore: inputs.preferenceStore
)) ))
} }

View File

@ -15,20 +15,24 @@
// Created by Carson Katri on 2/11/22. // Created by Carson Katri on 2/11/22.
// //
enum WalkWorkResult<Success> { @_spi(TokamakCore)
public enum WalkWorkResult<Success> {
case `continue` case `continue`
case `break`(with: Success) case `break`(with: Success)
case pause case pause
} }
enum WalkResult<Renderer: FiberRenderer, Success> { @_spi(TokamakCore)
public enum WalkResult<Renderer: FiberRenderer, Success> {
case success(Success) case success(Success)
case finished case finished
case paused(at: FiberReconciler<Renderer>.Fiber) case paused(at: FiberReconciler<Renderer>.Fiber)
} }
/// Walk a fiber tree from `root` until the `work` predicate returns `false`.
@_spi(TokamakCore)
@discardableResult @discardableResult
func walk<Renderer: FiberRenderer>( public func walk<Renderer: FiberRenderer>(
_ root: FiberReconciler<Renderer>.Fiber, _ root: FiberReconciler<Renderer>.Fiber,
_ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> Bool _ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> Bool
) rethrows -> WalkResult<Renderer, ()> { ) rethrows -> WalkResult<Renderer, ()> {
@ -38,7 +42,17 @@ func walk<Renderer: FiberRenderer>(
} }
/// Parent-first depth-first traversal of a `Fiber` tree. /// Parent-first depth-first traversal of a `Fiber` tree.
func walk<Renderer: FiberRenderer, Success>( /// `work` is called with each `Fiber` in the tree as they are entered.
///
/// Traversal uses the following process:
/// 1. Perform work on the current `Fiber`.
/// 2. If the `Fiber` has a child, repeat from (1) with the child.
/// 3. If the `Fiber` does not have a sibling, walk up until we find a `Fiber` that does have one.
/// 4. Walk across to the sibling.
///
/// When the `root` is reached, the loop exits.
@_spi(TokamakCore)
public func walk<Renderer: FiberRenderer, Success>(
_ root: FiberReconciler<Renderer>.Fiber, _ root: FiberReconciler<Renderer>.Fiber,
_ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> WalkWorkResult<Success> _ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> WalkWorkResult<Success>
) rethrows -> WalkResult<Renderer, Success> { ) rethrows -> WalkResult<Renderer, Success> {

View File

@ -25,12 +25,41 @@ public extension PreferenceKey where Self.Value: ExpressibleByNilLiteral {
static var defaultValue: Value { nil } static var defaultValue: Value { nil }
} }
public struct _PreferenceValue<Key> where Key: PreferenceKey { final class _PreferenceValueStorage: CustomDebugStringConvertible {
/// Every value the `Key` has had. /// Every value the `Key` has had.
var valueList: [Key.Value] var valueList: [Any]
var debugDescription: String {
valueList.debugDescription
}
init<Key: PreferenceKey>(_ key: Key.Type = Key.self) {
valueList = []
}
init(valueList: [Any]) {
self.valueList = valueList
}
func merge(_ other: _PreferenceValueStorage) {
valueList.append(contentsOf: other.valueList)
}
func reset() {
valueList = []
}
}
public struct _PreferenceValue<Key> where Key: PreferenceKey {
var storage: _PreferenceValueStorage
init(storage: _PreferenceValueStorage) {
self.storage = storage
}
/// The latest value. /// The latest value.
public var value: Key.Value { public var value: Key.Value {
reduce(valueList) reduce(storage.valueList.compactMap { $0 as? Key.Value })
} }
func reduce(_ values: [Key.Value]) -> Key.Value { func reduce(_ values: [Key.Value]) -> Key.Value {
@ -48,30 +77,80 @@ public extension _PreferenceValue {
} }
} }
public final class _PreferenceStore { public final class _PreferenceStore: CustomDebugStringConvertible {
/// The values of the `_PreferenceStore` on the last update.
private var previousValues: [ObjectIdentifier: _PreferenceValueStorage]
/// The backing values of the `_PreferenceStore`. /// The backing values of the `_PreferenceStore`.
private var values: [String: Any] private var values: [ObjectIdentifier: _PreferenceValueStorage]
weak var parent: _PreferenceStore? weak var parent: _PreferenceStore?
public init(values: [String: Any] = [:]) { public var debugDescription: String {
"Preferences (\(ObjectIdentifier(self))): \(values)"
}
init(values: [ObjectIdentifier: _PreferenceValueStorage] = [:]) {
previousValues = [:]
self.values = values self.values = values
} }
/// Retrieve a late-binding token for `key`, or save the default value if it does not yet exist.
public func value<Key>(forKey key: Key.Type = Key.self) -> _PreferenceValue<Key> public func value<Key>(forKey key: Key.Type = Key.self) -> _PreferenceValue<Key>
where Key: PreferenceKey where Key: PreferenceKey
{ {
values[String(reflecting: key)] as? _PreferenceValue<Key> let keyID = ObjectIdentifier(key)
?? _PreferenceValue(valueList: [Key.defaultValue]) let storage: _PreferenceValueStorage
if let existing = values[keyID] {
storage = existing
} else {
storage = .init(key)
values[keyID] = storage
}
return _PreferenceValue(storage: storage)
}
/// Retrieve the value `Key` had on the last update.
///
/// Used to check if the value changed during the last update.
func previousValue<Key>(forKey key: Key.Type = Key.self) -> _PreferenceValue<Key>
where Key: PreferenceKey
{
_PreferenceValue(storage: previousValues[ObjectIdentifier(key)] ?? .init(key))
} }
public func insert<Key>(_ value: Key.Value, forKey key: Key.Type = Key.self) public func insert<Key>(_ value: Key.Value, forKey key: Key.Type = Key.self)
where Key: PreferenceKey where Key: PreferenceKey
{ {
let previousValues = self.value(forKey: key).valueList let keyID = ObjectIdentifier(key)
values[String(reflecting: key)] = _PreferenceValue<Key>(valueList: previousValues + [value]) if !values.keys.contains(keyID) {
values[keyID] = .init(key)
}
values[keyID]?.valueList.append(value)
parent?.insert(value, forKey: key) parent?.insert(value, forKey: key)
} }
func merge(_ other: _PreferenceStore) {
for (key, otherStorage) in other.values {
if let storage = values[key] {
storage.merge(otherStorage)
} else {
values[key] = .init(valueList: otherStorage.valueList)
}
}
}
/// Copies `values` to `previousValues`, and clears `values`.
///
/// Each reconcile pass the preferences are collected from scratch, so we need to
/// clear out the old values.
func reset() {
previousValues = values.mapValues {
_PreferenceValueStorage(valueList: $0.valueList)
}
for storage in values.values {
storage.reset()
}
}
} }
/// A protocol that allows a `View` to read values from the current `_PreferenceStore`. /// A protocol that allows a `View` to read values from the current `_PreferenceStore`.

View File

@ -25,12 +25,26 @@ public struct _PreferenceActionModifier<Key>: _PreferenceWritingModifierProtocol
public func body(_ content: Content, with preferenceStore: inout _PreferenceStore) -> AnyView { public func body(_ content: Content, with preferenceStore: inout _PreferenceStore) -> AnyView {
let value = preferenceStore.value(forKey: Key.self) let value = preferenceStore.value(forKey: Key.self)
let previousValue = value.reduce(value.valueList.dropLast()) let previousValue = value.reduce((value.storage.valueList as? [Key.Value] ?? []).dropLast())
if previousValue != value.value { if previousValue != value.value {
action(value.value) action(value.value)
} }
return content.view return content.view
} }
public static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs {
.init(
inputs: inputs,
preferenceStore: inputs.preferenceStore ?? .init(),
preferenceAction: {
let value = $0.value(forKey: Key.self).value
let previousValue = $0.previousValue(forKey: Key.self).value
if value != previousValue {
inputs.content.action(value)
}
}
)
}
} }
public extension View { public extension View {

View File

@ -21,11 +21,11 @@ public struct _DelayedPreferenceView<Key, Content>: View, _PreferenceReadingView
where Key: PreferenceKey, Content: View where Key: PreferenceKey, Content: View
{ {
@State @State
private var resolvedValue: _PreferenceValue<Key> = _PreferenceValue( private var resolvedValue: _PreferenceValue<Key> = _PreferenceValue(storage: .init(Key.self))
valueList: [Key.defaultValue]
)
public let transform: (_PreferenceValue<Key>) -> Content public let transform: (_PreferenceValue<Key>) -> Content
private var valueReference: _PreferenceValue<Key>?
public init(transform: @escaping (_PreferenceValue<Key>) -> Content) { public init(transform: @escaping (_PreferenceValue<Key>) -> Content) {
self.transform = transform self.transform = transform
} }
@ -35,7 +35,18 @@ public struct _DelayedPreferenceView<Key, Content>: View, _PreferenceReadingView
} }
public var body: some View { public var body: some View {
transform(resolvedValue) transform(valueReference ?? resolvedValue)
}
public static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs {
let preferenceStore = inputs.preferenceStore ?? .init()
inputs.updateContent {
$0.valueReference = preferenceStore.value(forKey: Key.self)
}
return .init(
inputs: inputs,
preferenceStore: preferenceStore
)
} }
} }
@ -70,7 +81,7 @@ public extension View {
) -> some View ) -> some View
where Key: PreferenceKey, T: View where Key: PreferenceKey, T: View
{ {
Key._delay { self.overlay(transform($0.value)) } Key._delay { self.overlay($0._force(transform)) }
} }
func backgroundPreferenceValue<Key, T>( func backgroundPreferenceValue<Key, T>(
@ -79,6 +90,6 @@ public extension View {
) -> some View ) -> some View
where Key: PreferenceKey, T: View where Key: PreferenceKey, T: View
{ {
Key._delay { self.background(transform($0.value)) } Key._delay { self.background($0._force(transform)) }
} }
} }

View File

@ -34,6 +34,18 @@ public struct _PreferenceTransformModifier<Key>: _PreferenceWritingModifierProto
preferenceStore.insert(newValue, forKey: Key.self) preferenceStore.insert(newValue, forKey: Key.self)
return content.view return content.view
} }
public static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs {
.init(
inputs: inputs,
preferenceStore: inputs.preferenceStore ?? .init(),
preferenceAction: {
var value = $0.value(forKey: Key.self).value
inputs.content.transform(&value)
$0.insert(value, forKey: Key.self)
}
)
}
} }
public extension View { public extension View {

View File

@ -27,6 +27,14 @@ public struct _PreferenceWritingModifier<Key>: _PreferenceWritingModifierProtoco
preferenceStore.insert(value, forKey: Key.self) preferenceStore.insert(value, forKey: Key.self)
return content.view return content.view
} }
public static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs {
.init(
inputs: inputs,
preferenceStore: inputs.preferenceStore ?? .init(),
preferenceAction: { $0.insert(inputs.content.value, forKey: Key.self) }
)
}
} }
extension _PreferenceWritingModifier: Equatable where Key.Value: Equatable { extension _PreferenceWritingModifier: Equatable where Key.Value: Equatable {

View File

@ -19,6 +19,8 @@ import TokamakCore
// MARK: Environment & State // MARK: Environment & State
public typealias DynamicProperty = TokamakCore.DynamicProperty
public typealias Environment = TokamakCore.Environment public typealias Environment = TokamakCore.Environment
public typealias EnvironmentKey = TokamakCore.EnvironmentKey public typealias EnvironmentKey = TokamakCore.EnvironmentKey
public typealias EnvironmentObject = TokamakCore.EnvironmentObject public typealias EnvironmentObject = TokamakCore.EnvironmentObject
@ -180,8 +182,16 @@ public typealias View = TokamakCore.View
public typealias AnyView = TokamakCore.AnyView public typealias AnyView = TokamakCore.AnyView
public typealias EmptyView = TokamakCore.EmptyView public typealias EmptyView = TokamakCore.EmptyView
// MARK: Layout
public typealias Layout = TokamakCore.Layout public typealias Layout = TokamakCore.Layout
public typealias AnyLayout = TokamakCore.AnyLayout public typealias AnyLayout = TokamakCore.AnyLayout
public typealias LayoutProperties = TokamakCore.LayoutProperties
public typealias LayoutSubviews = TokamakCore.LayoutSubviews
public typealias LayoutSubview = TokamakCore.LayoutSubview
public typealias LayoutValueKey = TokamakCore.LayoutValueKey
public typealias ProposedViewSize = TokamakCore.ProposedViewSize
public typealias ViewSpacing = TokamakCore.ViewSpacing
// MARK: Toolbars // MARK: Toolbars

View File

@ -17,6 +17,7 @@
import Foundation import Foundation
import JavaScriptKit import JavaScriptKit
import OpenCombineJS
@_spi(TokamakCore) @_spi(TokamakCore)
import TokamakCore import TokamakCore
@_spi(TokamakStaticHTML) @_spi(TokamakStaticHTML)
@ -88,6 +89,8 @@ public struct DOMFiberRenderer: FiberRenderer {
public var defaultEnvironment: EnvironmentValues { public var defaultEnvironment: EnvironmentValues {
var environment = EnvironmentValues() var environment = EnvironmentValues()
environment[_ColorSchemeKey.self] = .light environment[_ColorSchemeKey.self] = .light
environment._defaultAppStorage = LocalStorage.standard
_DefaultSceneStorageProvider.default = SessionStorage.standard
return environment return environment
} }
@ -117,6 +120,10 @@ public struct DOMFiberRenderer: FiberRenderer {
_ = reference.style.setProperty("width", "100vw") _ = reference.style.setProperty("width", "100vw")
_ = reference.style.setProperty("height", "100vh") _ = reference.style.setProperty("height", "100vh")
_ = reference.style.setProperty("position", "relative") _ = reference.style.setProperty("position", "relative")
} else {
let style = document.createElement!("style").object!
style.innerHTML = .string(TokamakStaticHTML.tokamakStyles)
_ = document.head.appendChild(style)
} }
} }
@ -299,6 +306,11 @@ public struct DOMFiberRenderer: FiberRenderer {
} }
} }
} }
private let scheduler = JSScheduler()
public func schedule(_ action: @escaping () -> ()) {
scheduler.schedule(options: nil, action)
}
} }
extension _PrimitiveButtonStyleBody: DOMNodeConvertible { extension _PrimitiveButtonStyleBody: DOMNodeConvertible {

View File

@ -253,4 +253,8 @@ public struct StaticHTMLFiberRenderer: FiberRenderer {
</html> </html>
""" """
} }
public func schedule(_ action: @escaping () -> ()) {
action()
}
} }

View File

@ -176,4 +176,8 @@ public struct TestFiberRenderer: FiberRenderer {
} }
} }
} }
public func schedule(_ action: @escaping () -> ()) {
action()
}
} }

View File

@ -0,0 +1,103 @@
// Copyright 2022 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 6/30/22.
//
import Foundation
@_spi(TokamakCore)
import TokamakCore
/// A proxy for an identified view in the `TestFiberRenderer`.
///
/// The properties are evaluated on access,
/// so you will never unintentionally access an `alternate` value.
@dynamicMemberLookup
public struct TestViewProxy<V: View> {
/// The id to lookup.
let id: AnyHashable
/// The active reconciler instance to search in.
let reconciler: FiberReconciler<TestFiberRenderer>
/// Searches for a `Fiber` representing `id`.
///
/// - Note: This returns the child of the `identified(by:)` modifier,
/// not the `IdentifiedView` itself.
@_spi(TokamakCore)
public var fiber: FiberReconciler<TestFiberRenderer>.Fiber? {
let id = AnyHashable(id)
let result = TokamakCore.walk(
reconciler.current
) { fiber -> WalkWorkResult<FiberReconciler<TestFiberRenderer>.Fiber?> in
guard case let .view(view, _) = fiber.content,
!(view is AnyOptional),
(view as? IdentifiedViewProtocol)?.id == AnyHashable(id),
let child = fiber.child
else { return WalkWorkResult.continue }
return WalkWorkResult.break(with: child)
}
guard case let .success(fiber) = result else { return nil }
return fiber
}
/// The `fiber`'s content casted to `V`.
public var view: V? {
guard case let .view(view, _) = fiber?.content else { return nil }
return view as? V
}
/// Access properties from the `view` without specifying `.view` manually.
public subscript<T>(dynamicMember member: KeyPath<V, T>) -> T? {
self.view?[keyPath: member]
}
}
/// An erased `IdentifiedView`.
protocol IdentifiedViewProtocol {
var id: AnyHashable { get }
}
/// A wrapper that identifies a `View` in a test.
struct IdentifiedView<Content: View>: View, IdentifiedViewProtocol {
let id: AnyHashable
let content: Content
var body: some View {
content
}
}
public extension View {
/// Identifies a `View` in a test.
///
/// You can access this view from the `FiberReconciler` with `findView(id:as:)`.
func identified<ID: Hashable>(by id: ID) -> some View {
IdentifiedView(id: id, content: self)
}
}
public extension FiberReconciler where Renderer == TestFiberRenderer {
/// Find the `View` identified by `ID`.
///
/// - Note: This returns a proxy to the child of the `identified(by:)` modifier,
/// not the `IdentifiedView` itself.
func findView<ID: Hashable, V: View>(
id: ID,
as type: V.Type = V.self
) -> TestViewProxy<V> {
TestViewProxy<V>(id: id, reconciler: self)
}
}

View File

@ -0,0 +1,65 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 6/29/22.
//
import XCTest
@_spi(TokamakCore) @testable import TokamakCore
import TokamakTestRenderer
private enum TestKey: PreferenceKey {
static let defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
value += nextValue()
}
}
final class PreferenceTests: XCTestCase {
func testPreferenceAction() {
struct TestView: View {
public var body: some View {
Text("")
.preference(key: TestKey.self, value: 2)
.preference(key: TestKey.self, value: 3)
.onPreferenceChange(TestKey.self) {
XCTAssertEqual($0, 5)
}
}
}
let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500))
.render(TestView())
reconciler.fiberChanged(reconciler.current)
}
func testOverlay() {
struct TestView: View {
var body: some View {
Text("")
.preference(key: TestKey.self, value: 2)
.preference(key: TestKey.self, value: 3)
.overlayPreferenceValue(TestKey.self) {
Text("\($0)")
.identified(by: "overlay")
}
}
}
let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500))
.render(TestView())
XCTAssertEqual(reconciler.findView(id: "overlay").view, Text("5"))
}
}

View File

@ -20,40 +20,6 @@ import XCTest
@_spi(TokamakCore) @testable import TokamakCore @_spi(TokamakCore) @testable import TokamakCore
import TokamakTestRenderer import TokamakTestRenderer
extension FiberReconciler {
/// Expect a `Fiber` to represent a particular `View` type.
func expect<V>(
_ fiber: Fiber?,
represents viewType: V.Type,
_ message: String? = nil
) where V: View {
guard case let .view(view, _) = fiber?.content else {
return XCTAssert(false, "Fiber does not exit")
}
if let message = message {
XCTAssert(type(of: view) == viewType, message)
} else {
XCTAssert(type(of: view) == viewType)
}
}
/// Expect a `Fiber` to represent a `View` matching`testView`.
func expect<V>(
_ fiber: Fiber?,
equals testView: V,
_ message: String? = nil
) where V: View & Equatable {
guard case let .view(fiberView, _) = fiber?.content else {
return XCTAssert(false, "Fiber does not exit")
}
if let message = message {
XCTAssertEqual(fiberView as? V, testView, message)
} else {
XCTAssertEqual(fiberView as? V, testView)
}
}
}
final class VisitorTests: XCTestCase { final class VisitorTests: XCTestCase {
func testCounter() { func testCounter() {
struct TestView: View { struct TestView: View {
@ -64,16 +30,19 @@ final class VisitorTests: XCTestCase {
var body: some View { var body: some View {
VStack { VStack {
Text("\(count)") Text("\(count)")
.identified(by: "count")
HStack { HStack {
if count > 0 { if count > 0 {
Button("Decrement") { Button("Decrement") {
count -= 1 count -= 1
} }
.identified(by: "decrement")
} }
if count < 5 { if count < 5 {
Button("Increment") { Button("Increment") {
count += 1 count += 1
} }
.identified(by: "increment")
} }
} }
} }
@ -84,75 +53,28 @@ final class VisitorTests: XCTestCase {
Counter() Counter()
} }
} }
let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500)) let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
.render(TestView())
var hStack: FiberReconciler<TestFiberRenderer>.Fiber? { let incrementButton = reconciler.findView(id: "increment", as: Button<Text>.self)
reconciler.current // RootView let countText = reconciler.findView(id: "count", as: Text.self)
.child? // LayoutView let decrementButton = reconciler.findView(id: "decrement", as: Button<Text>.self)
.child? // ModifiedContent
.child? // _ViewModifier_Content
.child? // TestView
.child? // Counter
.child? // VStack
.child? // TupleView
.child?.sibling? // HStack
.child // TupleView
}
var text: FiberReconciler<TestFiberRenderer>.Fiber? {
reconciler.current // RootView
.child? // LayoutView
.child? // ModifiedContent
.child? // _ViewModifier_Content
.child? // TestView
.child? // Counter
.child? // VStack
.child? // TupleView
.child // Text
}
var decrementButton: FiberReconciler<TestFiberRenderer>.Fiber? {
hStack?
.child? // Optional
.child // Button
}
var incrementButton: FiberReconciler<TestFiberRenderer>.Fiber? {
hStack?
.child?.sibling? // Optional
.child // Button
}
func decrement() {
guard case let .view(view, _) = decrementButton?.content
else { return }
(view as? Button<Text>)?.action()
}
func increment() {
guard case let .view(view, _) = incrementButton?.content
else { return }
(view as? Button<Text>)?.action()
}
// The decrement button is removed when count is < 0 // The decrement button is removed when count is < 0
XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") XCTAssertNil(decrementButton.view, "'Decrement' should be hidden when count <= 0")
XCTAssertNotNil(incrementButton.view, "'Increment' should be visible when count < 5")
// Count up to 5 // Count up to 5
for i in 0..<5 { for i in 0..<5 {
reconciler.expect(text, equals: Text("\(i)")) XCTAssertEqual(countText.view, Text("\(i)"))
increment() incrementButton.action?()
} }
XCTAssertNil(incrementButton, "'Increment' should be hidden when count >= 5") XCTAssertNil(incrementButton.view, "'Increment' should be hidden when count >= 5")
reconciler.expect( XCTAssertNotNil(decrementButton.view, "'Decrement' should be visible when count > 0")
decrementButton,
represents: Button<Text>.self,
"'Decrement' should be visible when count > 0"
)
// Count down to 0. // Count down to 0.
for i in 0..<5 { for i in 0..<5 {
reconciler.expect(text, equals: Text("\(5 - i)")) XCTAssertEqual(countText.view, Text("\(5 - i)"))
decrement() decrementButton.action?()
} }
XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") XCTAssertNil(decrementButton.view, "'Decrement' should be hidden when count <= 0")
reconciler.expect( XCTAssertNotNil(incrementButton.view, "'Increment' should be visible when count < 5")
incrementButton,
represents: Button<Text>.self,
"'Increment' should be visible when count < 5"
)
} }
func testForEach() { func testForEach() {
@ -163,61 +85,143 @@ final class VisitorTests: XCTestCase {
var body: some View { var body: some View {
VStack { VStack {
Button("Add Item") { count += 1 } Button("Add Item") { count += 1 }
.identified(by: "addItem")
ForEach(Array(0..<count), id: \.self) { i in ForEach(Array(0..<count), id: \.self) { i in
Text("Item \(i)") Text("Item \(i)")
.identified(by: i)
} }
} }
} }
} }
let reconciler = TestFiberRenderer( let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
.root,
size: .init(width: 500, height: 500), let addItemButton = reconciler.findView(id: "addItem", as: Button<Text>.self)
useDynamicLayout: true XCTAssertNotNil(addItemButton)
) for i in 0..<10 {
.render(TestView()) addItemButton.action?()
var addItemFiber: FiberReconciler<TestFiberRenderer>.Fiber? { XCTAssertEqual(reconciler.findView(id: i).view, Text("Item \(i)"))
reconciler.current // RootView
.child? // LayoutView
.child? // ModifiedContent
.child? // _ViewModifier_Content
.child? // TestView
.child? // VStack
.child? // TupleView
.child // Button
} }
var forEachFiber: FiberReconciler<TestFiberRenderer>.Fiber? { }
reconciler.current // RootView
.child? // LayoutView func testDynamicProperties() {
.child? // ModifiedContent enum DynamicPropertyTest: Hashable {
.child? // _ViewModifier_Content case state
.child? // TestView case environment
.child? // VStack case stateObject
.child? // TupleView case observedObject
.child?.sibling // ForEach case environmentObject
} }
func item(at index: Int) -> FiberReconciler<TestFiberRenderer>.Fiber? { struct TestView: View {
var node = forEachFiber?.child var body: some View {
for _ in 0..<index { TestState()
node = node?.sibling TestEnvironment()
TestStateObject()
}
struct TestState: View {
@State
private var count = 0
var body: some View {
Button("\(count)") { count += 1 }
.identified(by: DynamicPropertyTest.state)
}
}
private enum TestKey: EnvironmentKey {
static let defaultValue = 5
}
struct TestEnvironment: View {
@Environment(\.self)
var values
var body: some View {
Text("\(values[TestKey.self])")
.identified(by: DynamicPropertyTest.environment)
}
}
struct TestStateObject: View {
final class Count: ObservableObject {
@Published
var count = 0
func increment() {
count += 5
}
}
@StateObject
private var count = Count()
var body: some View {
VStack {
Button("\(count.count)") {
count.increment()
}
.identified(by: DynamicPropertyTest.stateObject)
TestObservedObject(count: count)
TestEnvironmentObject()
}
.environmentObject(count)
}
struct TestObservedObject: View {
@ObservedObject
var count: Count
var body: some View {
Text("\(count.count)")
.identified(by: DynamicPropertyTest.observedObject)
}
}
struct TestEnvironmentObject: View {
@EnvironmentObject
var count: Count
var body: some View {
Text("\(count.count)")
.identified(by: DynamicPropertyTest.environmentObject)
}
}
} }
return node
} }
func addItem() {
guard case let .view(view, _) = addItemFiber?.content let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
else { return }
(view as? Button<Text>)?.action() // State
} let button = reconciler.findView(id: DynamicPropertyTest.state, as: Button<Text>.self)
reconciler.expect(addItemFiber, represents: Button<Text>.self) XCTAssertEqual(button.label, Text("0"))
reconciler.expect(forEachFiber, represents: ForEach<[Int], Int, Text>.self) button.action?()
addItem() XCTAssertEqual(button.label, Text("1"))
reconciler.expect(item(at: 0), equals: Text("Item 0"))
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 2) // Environment
addItem() XCTAssertEqual(
reconciler.expect(item(at: 1), equals: Text("Item 1")) reconciler.findView(id: DynamicPropertyTest.environment).view,
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 3) Text("5")
addItem() )
reconciler.expect(item(at: 2), equals: Text("Item 2"))
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 4) // StateObject
let stateObjectButton = reconciler.findView(
id: DynamicPropertyTest.stateObject,
as: Button<Text>.self
)
XCTAssertEqual(stateObjectButton.label, Text("0"))
stateObjectButton.action?()
stateObjectButton.action?()
XCTAssertEqual(stateObjectButton.label, Text("5"))
XCTAssertEqual(
reconciler.findView(id: DynamicPropertyTest.observedObject).view,
Text("5")
)
XCTAssertEqual(
reconciler.findView(id: DynamicPropertyTest.environmentObject).view,
Text("5")
)
} }
} }