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:
parent
9d0e2fc067
commit
676760d34b
|
@ -17,12 +17,13 @@
|
|||
|
||||
extension FiberReconciler.Fiber: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
let memoryAddress = String(format: "%010p", unsafeBitCast(self, to: Int.self))
|
||||
if case let .view(view, _) = content,
|
||||
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 {
|
||||
|
|
|
@ -16,7 +16,9 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import OpenCombineShim
|
||||
|
||||
// swiftlint:disable type_body_length
|
||||
@_spi(TokamakCore)
|
||||
public extension FiberReconciler {
|
||||
/// A manager for a single `View`.
|
||||
|
@ -83,22 +85,36 @@ public extension FiberReconciler {
|
|||
/// Parent references are `unowned` (as opposed to `weak`)
|
||||
/// because the parent will always exist if a child does.
|
||||
/// 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.
|
||||
unowned var elementParent: Fiber?
|
||||
|
||||
/// The nearest parent that receives preferences.
|
||||
unowned var preferenceParent: Fiber?
|
||||
|
||||
/// The cached type information for the underlying `View`.
|
||||
var typeInfo: TypeInfo?
|
||||
|
||||
/// Boxes that store `State` data.
|
||||
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.
|
||||
var geometry: ViewGeometry?
|
||||
|
||||
/// 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?)?
|
||||
|
||||
|
@ -129,6 +145,7 @@ public extension FiberReconciler {
|
|||
element: Renderer.ElementType?,
|
||||
parent: Fiber?,
|
||||
elementParent: Fiber?,
|
||||
preferenceParent: Fiber?,
|
||||
elementIndex: Int?,
|
||||
traits: _ViewTraitStore?,
|
||||
reconciler: FiberReconciler<Renderer>?
|
||||
|
@ -138,17 +155,24 @@ public extension FiberReconciler {
|
|||
sibling = nil
|
||||
self.parent = parent
|
||||
self.elementParent = elementParent
|
||||
self.preferenceParent = preferenceParent
|
||||
typeInfo = TokamakCore.typeInfo(of: V.self)
|
||||
|
||||
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(
|
||||
content: view,
|
||||
updateContent: { $0(&updateView) },
|
||||
environment: environment,
|
||||
traits: traits
|
||||
traits: traits,
|
||||
preferenceStore: preferences
|
||||
)
|
||||
outputs = V._makeView(viewInputs)
|
||||
|
||||
if let preferenceStore = outputs.preferenceStore {
|
||||
preferences = preferenceStore
|
||||
}
|
||||
view = updateView
|
||||
content = content(for: view)
|
||||
|
||||
if let element = element {
|
||||
|
@ -175,6 +199,8 @@ public extension FiberReconciler {
|
|||
let alternate = Fiber(
|
||||
bound: alternateView,
|
||||
state: self.state,
|
||||
subscriptions: self.subscriptions,
|
||||
preferences: self.preferences,
|
||||
layout: self.layout,
|
||||
alternate: self,
|
||||
outputs: self.outputs,
|
||||
|
@ -182,6 +208,7 @@ public extension FiberReconciler {
|
|||
element: self.element,
|
||||
parent: self.parent?.alternate,
|
||||
elementParent: self.elementParent?.alternate,
|
||||
preferenceParent: self.preferenceParent?.alternate,
|
||||
reconciler: reconciler
|
||||
)
|
||||
self.alternate = alternate
|
||||
|
@ -205,6 +232,8 @@ public extension FiberReconciler {
|
|||
init<V: View>(
|
||||
bound view: V,
|
||||
state: [PropertyInfo: MutableStorage],
|
||||
subscriptions: [PropertyInfo: AnyCancellable],
|
||||
preferences: _PreferenceStore?,
|
||||
layout: AnyLayout!,
|
||||
alternate: Fiber,
|
||||
outputs: ViewOutputs,
|
||||
|
@ -212,6 +241,7 @@ public extension FiberReconciler {
|
|||
element: Renderer.ElementType?,
|
||||
parent: FiberReconciler<Renderer>.Fiber?,
|
||||
elementParent: Fiber?,
|
||||
preferenceParent: Fiber?,
|
||||
reconciler: FiberReconciler<Renderer>?
|
||||
) {
|
||||
self.alternate = alternate
|
||||
|
@ -221,9 +251,12 @@ public extension FiberReconciler {
|
|||
sibling = nil
|
||||
self.parent = parent
|
||||
self.elementParent = elementParent
|
||||
self.preferenceParent = preferenceParent
|
||||
self.typeInfo = typeInfo
|
||||
self.outputs = outputs
|
||||
self.state = state
|
||||
self.subscriptions = subscriptions
|
||||
self.preferences = preferences
|
||||
if element != nil {
|
||||
self.layout = layout
|
||||
}
|
||||
|
@ -234,36 +267,98 @@ public extension FiberReconciler {
|
|||
to content: inout T,
|
||||
_ typeInfo: TypeInfo?,
|
||||
_ 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 {
|
||||
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 {
|
||||
let box = self.state[property] ?? MutableStorage(
|
||||
initialValue: storage.anyInitialValue,
|
||||
onSet: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.reconciler?.reconcile(from: self)
|
||||
self.reconciler?.fiberChanged(self)
|
||||
}
|
||||
)
|
||||
state[property] = box
|
||||
storage.getter = { box.value }
|
||||
storage.setter = { box.setValue($0, with: $1) }
|
||||
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 {
|
||||
environmentReader.setContent(from: environment)
|
||||
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)
|
||||
}
|
||||
if var environmentReader = content as? EnvironmentReader {
|
||||
environmentReader.setContent(from: environment)
|
||||
// swiftlint:disable:next force_cast
|
||||
content = environmentReader as! T
|
||||
content = environmentReader
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>(
|
||||
|
@ -276,14 +371,20 @@ public extension FiberReconciler {
|
|||
self.elementIndex = elementIndex
|
||||
|
||||
let environment = parent?.outputs.environment ?? .init(.init())
|
||||
state = bindProperties(to: &view, typeInfo, environment.environment)
|
||||
content = content(for: view)
|
||||
bindProperties(to: &view, typeInfo, environment.environment)
|
||||
var updateView = view
|
||||
let inputs = ViewInputs(
|
||||
content: view,
|
||||
updateContent: {
|
||||
$0(&updateView)
|
||||
},
|
||||
environment: environment,
|
||||
traits: traits
|
||||
traits: traits,
|
||||
preferenceStore: preferences
|
||||
)
|
||||
outputs = V._makeView(inputs)
|
||||
view = updateView
|
||||
content = content(for: view)
|
||||
|
||||
if element != nil {
|
||||
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.
|
||||
parent = nil
|
||||
elementParent = nil
|
||||
preferenceParent = nil
|
||||
element = rootElement
|
||||
typeInfo = TokamakCore.typeInfo(of: A.self)
|
||||
state = bindProperties(to: &app, typeInfo, rootEnvironment)
|
||||
bindProperties(to: &app, typeInfo, rootEnvironment)
|
||||
var updateApp = app
|
||||
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)
|
||||
|
||||
layout = .init(RootLayout(renderer: reconciler.renderer))
|
||||
|
@ -326,6 +440,8 @@ public extension FiberReconciler {
|
|||
let alternate = Fiber(
|
||||
bound: alternateApp,
|
||||
state: self.state,
|
||||
subscriptions: self.subscriptions,
|
||||
preferences: self.preferences,
|
||||
layout: self.layout,
|
||||
alternate: self,
|
||||
outputs: self.outputs,
|
||||
|
@ -341,6 +457,8 @@ public extension FiberReconciler {
|
|||
init<A: App>(
|
||||
bound app: A,
|
||||
state: [PropertyInfo: MutableStorage],
|
||||
subscriptions: [PropertyInfo: AnyCancellable],
|
||||
preferences: _PreferenceStore?,
|
||||
layout: AnyLayout?,
|
||||
alternate: Fiber,
|
||||
outputs: SceneOutputs,
|
||||
|
@ -355,9 +473,12 @@ public extension FiberReconciler {
|
|||
sibling = nil
|
||||
parent = nil
|
||||
elementParent = nil
|
||||
preferenceParent = nil
|
||||
self.typeInfo = typeInfo
|
||||
self.outputs = outputs
|
||||
self.state = state
|
||||
self.subscriptions = subscriptions
|
||||
self.preferences = preferences
|
||||
self.layout = layout
|
||||
content = content(for: app)
|
||||
}
|
||||
|
@ -367,6 +488,7 @@ public extension FiberReconciler {
|
|||
element: Renderer.ElementType?,
|
||||
parent: Fiber?,
|
||||
elementParent: Fiber?,
|
||||
preferenceParent: Fiber?,
|
||||
environment: EnvironmentBox?,
|
||||
reconciler: FiberReconciler<Renderer>?
|
||||
) {
|
||||
|
@ -376,18 +498,27 @@ public extension FiberReconciler {
|
|||
self.parent = parent
|
||||
self.elementParent = elementParent
|
||||
self.element = element
|
||||
self.preferenceParent = preferenceParent
|
||||
typeInfo = TokamakCore.typeInfo(of: S.self)
|
||||
|
||||
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(
|
||||
.init(
|
||||
content: scene,
|
||||
updateContent: {
|
||||
$0(&updateScene)
|
||||
},
|
||||
environment: environment,
|
||||
traits: .init()
|
||||
traits: .init(),
|
||||
preferenceStore: preferences
|
||||
)
|
||||
)
|
||||
|
||||
if let preferenceStore = outputs.preferenceStore {
|
||||
preferences = preferenceStore
|
||||
}
|
||||
scene = updateScene
|
||||
content = content(for: scene)
|
||||
|
||||
if element != nil {
|
||||
|
@ -401,6 +532,8 @@ public extension FiberReconciler {
|
|||
let alternate = Fiber(
|
||||
bound: alternateScene,
|
||||
state: self.state,
|
||||
subscriptions: self.subscriptions,
|
||||
preferences: self.preferences,
|
||||
layout: self.layout,
|
||||
alternate: self,
|
||||
outputs: self.outputs,
|
||||
|
@ -408,6 +541,7 @@ public extension FiberReconciler {
|
|||
element: self.element,
|
||||
parent: self.parent?.alternate,
|
||||
elementParent: self.elementParent?.alternate,
|
||||
preferenceParent: self.preferenceParent?.alternate,
|
||||
reconciler: reconciler
|
||||
)
|
||||
self.alternate = alternate
|
||||
|
@ -431,6 +565,8 @@ public extension FiberReconciler {
|
|||
init<S: Scene>(
|
||||
bound scene: S,
|
||||
state: [PropertyInfo: MutableStorage],
|
||||
subscriptions: [PropertyInfo: AnyCancellable],
|
||||
preferences: _PreferenceStore?,
|
||||
layout: AnyLayout!,
|
||||
alternate: Fiber,
|
||||
outputs: SceneOutputs,
|
||||
|
@ -438,6 +574,7 @@ public extension FiberReconciler {
|
|||
element: Renderer.ElementType?,
|
||||
parent: FiberReconciler<Renderer>.Fiber?,
|
||||
elementParent: Fiber?,
|
||||
preferenceParent: Fiber?,
|
||||
reconciler: FiberReconciler<Renderer>?
|
||||
) {
|
||||
self.alternate = alternate
|
||||
|
@ -447,9 +584,12 @@ public extension FiberReconciler {
|
|||
sibling = nil
|
||||
self.parent = parent
|
||||
self.elementParent = elementParent
|
||||
self.preferenceParent = preferenceParent
|
||||
self.typeInfo = typeInfo
|
||||
self.outputs = outputs
|
||||
self.state = state
|
||||
self.subscriptions = subscriptions
|
||||
self.preferences = preferences
|
||||
if element != nil {
|
||||
self.layout = layout
|
||||
}
|
||||
|
@ -462,13 +602,19 @@ public extension FiberReconciler {
|
|||
typeInfo = TokamakCore.typeInfo(of: S.self)
|
||||
|
||||
let environment = parent?.outputs.environment ?? .init(.init())
|
||||
state = bindProperties(to: &scene, typeInfo, environment.environment)
|
||||
content = content(for: scene)
|
||||
bindProperties(to: &scene, typeInfo, environment.environment)
|
||||
var updateScene = scene
|
||||
outputs = S._makeScene(.init(
|
||||
content: scene,
|
||||
updateContent: {
|
||||
$0(&updateScene)
|
||||
},
|
||||
environment: environment,
|
||||
traits: .init()
|
||||
traits: .init(),
|
||||
preferenceStore: preferences
|
||||
))
|
||||
scene = updateScene
|
||||
content = content(for: scene)
|
||||
|
||||
if element != nil {
|
||||
layout = (scene as? _AnyLayout)?._erased() ?? DefaultLayout.shared
|
||||
|
|
|
@ -29,12 +29,12 @@ extension FiberReconciler {
|
|||
var sibling: Result?
|
||||
var newContent: Renderer.ElementType.Content?
|
||||
var elementIndices: [ObjectIdentifier: Int]
|
||||
var nextTraits: _ViewTraitStore
|
||||
|
||||
// For reducing
|
||||
var lastSibling: Result?
|
||||
var nextExisting: Fiber?
|
||||
var nextExistingAlternate: Fiber?
|
||||
var pendingTraits: _ViewTraitStore
|
||||
|
||||
init(
|
||||
fiber: Fiber?,
|
||||
|
@ -44,7 +44,7 @@ extension FiberReconciler {
|
|||
alternateChild: Fiber?,
|
||||
newContent: Renderer.ElementType.Content? = nil,
|
||||
elementIndices: [ObjectIdentifier: Int],
|
||||
pendingTraits: _ViewTraitStore
|
||||
nextTraits: _ViewTraitStore
|
||||
) {
|
||||
self.fiber = fiber
|
||||
self.visitChildren = visitChildren
|
||||
|
@ -53,7 +53,7 @@ extension FiberReconciler {
|
|||
nextExistingAlternate = alternateChild
|
||||
self.newContent = newContent
|
||||
self.elementIndices = elementIndices
|
||||
self.pendingTraits = pendingTraits
|
||||
self.nextTraits = nextTraits
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,12 +61,13 @@ extension FiberReconciler {
|
|||
Self.reduce(
|
||||
into: &partialResult,
|
||||
nextValue: nextScene,
|
||||
createFiber: { scene, element, parent, elementParent, _, _, reconciler in
|
||||
createFiber: { scene, element, parent, elementParent, preferenceParent, _, _, reconciler in
|
||||
Fiber(
|
||||
&scene,
|
||||
element: element,
|
||||
parent: parent,
|
||||
elementParent: elementParent,
|
||||
preferenceParent: preferenceParent,
|
||||
environment: nil,
|
||||
reconciler: reconciler
|
||||
)
|
||||
|
@ -82,12 +83,16 @@ extension FiberReconciler {
|
|||
Self.reduce(
|
||||
into: &partialResult,
|
||||
nextValue: nextView,
|
||||
createFiber: { view, element, parent, elementParent, elementIndex, traits, reconciler in
|
||||
createFiber: {
|
||||
view, element,
|
||||
parent, elementParent, preferenceParent, elementIndex,
|
||||
traits, reconciler in
|
||||
Fiber(
|
||||
&view,
|
||||
element: element,
|
||||
parent: parent,
|
||||
elementParent: elementParent,
|
||||
preferenceParent: preferenceParent,
|
||||
elementIndex: elementIndex,
|
||||
traits: traits,
|
||||
reconciler: reconciler
|
||||
|
@ -114,6 +119,7 @@ extension FiberReconciler {
|
|||
Renderer.ElementType?,
|
||||
Fiber?,
|
||||
Fiber?,
|
||||
Fiber?,
|
||||
Int?,
|
||||
_ViewTraitStore,
|
||||
FiberReconciler?
|
||||
|
@ -123,6 +129,7 @@ extension FiberReconciler {
|
|||
) {
|
||||
// Create the node and its element.
|
||||
var nextValue = nextValue
|
||||
|
||||
let resultChild: Result
|
||||
if let existing = partialResult.nextExisting {
|
||||
// If a fiber already exists, simply update it with the new view.
|
||||
|
@ -136,7 +143,7 @@ extension FiberReconciler {
|
|||
existing,
|
||||
&nextValue,
|
||||
key.map { partialResult.elementIndices[$0, default: 0] },
|
||||
partialResult.pendingTraits
|
||||
partialResult.nextTraits
|
||||
)
|
||||
resultChild = Result(
|
||||
fiber: existing,
|
||||
|
@ -146,7 +153,7 @@ extension FiberReconciler {
|
|||
alternateChild: existing.alternate?.child,
|
||||
newContent: newContent,
|
||||
elementIndices: partialResult.elementIndices,
|
||||
pendingTraits: existing.element != nil ? .init() : partialResult.pendingTraits
|
||||
nextTraits: existing.element != nil ? .init() : partialResult.nextTraits
|
||||
)
|
||||
partialResult.nextExisting = existing.sibling
|
||||
partialResult.nextExistingAlternate = partialResult.nextExistingAlternate?.sibling
|
||||
|
@ -154,6 +161,9 @@ extension FiberReconciler {
|
|||
let elementParent = partialResult.fiber?.element != nil
|
||||
? partialResult.fiber
|
||||
: partialResult.fiber?.elementParent
|
||||
let preferenceParent = partialResult.fiber?.preferences != nil
|
||||
? partialResult.fiber
|
||||
: partialResult.fiber?.preferenceParent
|
||||
let key: ObjectIdentifier?
|
||||
if let elementParent = elementParent {
|
||||
key = ObjectIdentifier(elementParent)
|
||||
|
@ -166,10 +176,12 @@ extension FiberReconciler {
|
|||
partialResult.nextExistingAlternate?.element,
|
||||
partialResult.fiber,
|
||||
elementParent,
|
||||
preferenceParent,
|
||||
key.map { partialResult.elementIndices[$0, default: 0] },
|
||||
partialResult.pendingTraits,
|
||||
partialResult.nextTraits,
|
||||
partialResult.fiber?.reconciler
|
||||
)
|
||||
|
||||
// If a fiber already exists for an alternate, link them.
|
||||
if let alternate = partialResult.nextExistingAlternate {
|
||||
fiber.alternate = alternate
|
||||
|
@ -182,7 +194,7 @@ extension FiberReconciler {
|
|||
child: nil,
|
||||
alternateChild: fiber.alternate?.child,
|
||||
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.
|
||||
|
|
|
@ -39,6 +39,13 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
|
|||
private let caches: Caches
|
||||
|
||||
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 = [() -> ()]()
|
||||
|
||||
struct RootView<Content: View>: View {
|
||||
|
@ -105,13 +112,14 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
|
|||
element: renderer.rootElement,
|
||||
parent: nil,
|
||||
elementParent: nil,
|
||||
preferenceParent: nil,
|
||||
elementIndex: 0,
|
||||
traits: nil,
|
||||
reconciler: self
|
||||
)
|
||||
// Start by building the initial tree.
|
||||
alternate = current.createAndBindAlternate?()
|
||||
reconcile(from: current)
|
||||
fiberChanged(current)
|
||||
}
|
||||
|
||||
public init<A: App>(_ renderer: Renderer, _ app: A) {
|
||||
|
@ -135,19 +143,20 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
|
|||
)
|
||||
// Start by building the initial tree.
|
||||
alternate = current.createAndBindAlternate?()
|
||||
reconcile(from: current)
|
||||
fiberChanged(current)
|
||||
}
|
||||
|
||||
/// A visitor that performs each pass used by the `FiberReconciler`.
|
||||
final class ReconcilerVisitor: AppVisitor, SceneVisitor, ViewVisitor {
|
||||
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
|
||||
var mutations = [Mutation<Renderer>]()
|
||||
|
||||
init(root: Fiber, reconcileRoot: Fiber, reconciler: FiberReconciler) {
|
||||
init(root: Fiber, changedFibers: Set<ObjectIdentifier>, reconciler: FiberReconciler) {
|
||||
self.root = root
|
||||
self.reconcileRoot = reconcileRoot
|
||||
self.changedFibers = changedFibers
|
||||
self.reconciler = reconciler
|
||||
}
|
||||
|
||||
|
@ -173,13 +182,6 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
|
|||
} else {
|
||||
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(
|
||||
fiber: alternateRoot, // The alternate is the WIP node.
|
||||
visitChildren: visitChildren,
|
||||
|
@ -187,14 +189,14 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
|
|||
child: alternateRoot?.child,
|
||||
alternateChild: root.child,
|
||||
elementIndices: [:],
|
||||
pendingTraits: .init()
|
||||
nextTraits: .init()
|
||||
)
|
||||
reconciler.caches.clear()
|
||||
for pass in reconciler.passes {
|
||||
pass.run(
|
||||
in: reconciler,
|
||||
root: rootResult,
|
||||
reconcileRoot: alternateReconcileRoot,
|
||||
changedFibers: changedFibers,
|
||||
caches: reconciler.caches
|
||||
)
|
||||
}
|
||||
|
@ -211,18 +213,31 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
|
|||
afterReconcileActions.append(action)
|
||||
}
|
||||
|
||||
func reconcile(from updateRoot: Fiber) {
|
||||
isReconciling = true
|
||||
let root: Fiber
|
||||
if renderer.useDynamicLayout {
|
||||
// We need to re-layout from the top down when using dynamic layout.
|
||||
root = current
|
||||
} else {
|
||||
root = updateRoot
|
||||
/// Called by any `Fiber` that experiences a state change.
|
||||
///
|
||||
/// Reconciliation only runs after every change during the current run loop has been performed.
|
||||
func fiberChanged(_ fiber: Fiber) {
|
||||
guard let alternate = fiber.alternate ?? fiber.createAndBindAlternate?()
|
||||
else { return }
|
||||
let shouldSchedule = changedFibers.isEmpty
|
||||
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.
|
||||
let visitor = ReconcilerVisitor(root: root, reconcileRoot: updateRoot, reconciler: self)
|
||||
switch root.content {
|
||||
let visitor = ReconcilerVisitor(root: current, changedFibers: changedFibers, reconciler: self)
|
||||
switch current.content {
|
||||
case let .view(_, visit):
|
||||
visit(visitor)
|
||||
case let .scene(_, visit):
|
||||
|
@ -240,15 +255,9 @@ public final class FiberReconciler<Renderer: FiberRenderer> {
|
|||
// Essentially, making the work in progress tree the current,
|
||||
// and leaving the current available to be the work in progress
|
||||
// on our next update.
|
||||
if root === current {
|
||||
let alternate = alternate
|
||||
self.alternate = current
|
||||
current = alternate
|
||||
} else {
|
||||
let child = root.child
|
||||
root.child = root.alternate?.child
|
||||
root.alternate?.child = child
|
||||
}
|
||||
let alternate = alternate
|
||||
self.alternate = current
|
||||
current = alternate
|
||||
|
||||
isReconciling = false
|
||||
|
||||
|
|
|
@ -57,6 +57,33 @@ public protocol FiberRenderer {
|
|||
proposal: ProposedViewSize,
|
||||
in environment: EnvironmentValues
|
||||
) -> 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 {
|
||||
|
|
|
@ -141,8 +141,9 @@ public struct LayoutSubview: Equatable {
|
|||
)
|
||||
cache.sizeThatFits[request] = size
|
||||
if let alternate = fiber.alternate {
|
||||
caches.updateLayoutCache(for: alternate) { cache in
|
||||
cache.sizeThatFits[request] = size
|
||||
caches.updateLayoutCache(for: alternate) { alternateCache in
|
||||
alternateCache.cache = cache.cache
|
||||
alternateCache.sizeThatFits[request] = size
|
||||
}
|
||||
}
|
||||
return size
|
||||
|
@ -246,6 +247,6 @@ public struct LayoutSubview: Equatable {
|
|||
}
|
||||
|
||||
public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
lhs.storage === rhs.storage
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,7 +117,6 @@ public extension StackLayout {
|
|||
|
||||
/// The aggregate minimum size of each `View` with a given priority.
|
||||
var prioritySize = [Double: CGFloat]()
|
||||
|
||||
let measuredSubviews = subviews.enumerated().map { index, view -> MeasuredSubview in
|
||||
priorityCount[view.priority, default: 0] += 1
|
||||
|
||||
|
|
|
@ -61,9 +61,9 @@ extension FiberReconciler {
|
|||
}
|
||||
|
||||
func clear() {
|
||||
elementIndices = [:]
|
||||
layoutSubviews = [:]
|
||||
mutations = []
|
||||
elementIndices.removeAll()
|
||||
layoutSubviews.removeAll()
|
||||
mutations.removeAll()
|
||||
}
|
||||
|
||||
func layoutCache(for fiber: Fiber) -> LayoutCache? {
|
||||
|
@ -123,14 +123,14 @@ protocol FiberReconcilerPass {
|
|||
/// - Parameter root: The node to start the pass from.
|
||||
/// The top of the `View` hierarchy when `useDynamicLayout` is enabled.
|
||||
/// 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
|
||||
/// the number of operations performed during reconciliation.
|
||||
/// - Parameter caches: The shared cache data for this and other passes.
|
||||
func run<R: FiberRenderer>(
|
||||
in reconciler: FiberReconciler<R>,
|
||||
root: FiberReconciler<R>.TreeReducer.Result,
|
||||
reconcileRoot: FiberReconciler<R>.Fiber,
|
||||
changedFibers: Set<ObjectIdentifier>,
|
||||
caches: FiberReconciler<R>.Caches
|
||||
)
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ struct LayoutPass: FiberReconcilerPass {
|
|||
func run<R>(
|
||||
in reconciler: FiberReconciler<R>,
|
||||
root: FiberReconciler<R>.TreeReducer.Result,
|
||||
reconcileRoot: FiberReconciler<R>.Fiber,
|
||||
changedFibers: Set<ObjectIdentifier>,
|
||||
caches: FiberReconciler<R>.Caches
|
||||
) where R: FiberRenderer {
|
||||
guard let root = root.fiber else { return }
|
||||
|
|
|
@ -63,7 +63,7 @@ struct ReconcilePass: FiberReconcilerPass {
|
|||
func run<R>(
|
||||
in reconciler: FiberReconciler<R>,
|
||||
root: FiberReconciler<R>.TreeReducer.Result,
|
||||
reconcileRoot: FiberReconciler<R>.Fiber,
|
||||
changedFibers: Set<ObjectIdentifier>,
|
||||
caches: FiberReconciler<R>.Caches
|
||||
) where R: FiberRenderer {
|
||||
var node = root
|
||||
|
@ -71,12 +71,17 @@ struct ReconcilePass: FiberReconcilerPass {
|
|||
// Enabled when we reach the `reconcileRoot`.
|
||||
var shouldReconcile = false
|
||||
|
||||
// Traits that should be attached to the nearest rendered child.
|
||||
var pendingTraits = _ViewTraitStore()
|
||||
|
||||
while true {
|
||||
if node.fiber === reconcileRoot || node.fiber?.alternate === reconcileRoot {
|
||||
shouldReconcile = true
|
||||
if !shouldReconcile {
|
||||
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`
|
||||
|
@ -94,27 +99,23 @@ struct ReconcilePass: FiberReconcilerPass {
|
|||
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.
|
||||
if let traits = node.fiber?.outputs.traits,
|
||||
!traits.values.isEmpty
|
||||
{
|
||||
if node.fiber?.element == nil {
|
||||
pendingTraits = traits
|
||||
}
|
||||
}
|
||||
// Clear the pending traits once they have been applied to the target.
|
||||
if node.fiber?.element != nil && !pendingTraits.values.isEmpty {
|
||||
pendingTraits = .init()
|
||||
node.nextTraits.values.merge(traits.values, uniquingKeysWith: { $1 })
|
||||
}
|
||||
|
||||
// Ensure the `TreeReducer` can access any necessary state.
|
||||
node.elementIndices = caches.elementIndices
|
||||
node.pendingTraits = pendingTraits
|
||||
|
||||
// Update `DynamicProperty`s before accessing the `View`'s body.
|
||||
node.fiber?.updateDynamicProperties()
|
||||
// Compute the children of the node.
|
||||
let reducer = FiberReconciler<R>.TreeReducer.SceneVisitor(initialResult: node)
|
||||
node.visitChildren(reducer)
|
||||
|
||||
node.fiber?.preferences?.reset()
|
||||
|
||||
if reconciler.renderer.useDynamicLayout,
|
||||
let fiber = node.fiber
|
||||
{
|
||||
|
@ -124,7 +125,7 @@ struct ReconcilePass: FiberReconcilerPass {
|
|||
let parentKey = ObjectIdentifier(elementParent)
|
||||
let subview = LayoutSubview(
|
||||
id: ObjectIdentifier(fiber),
|
||||
traits: node.fiber?.outputs.traits,
|
||||
traits: fiber.outputs.traits,
|
||||
fiber: fiber,
|
||||
element: element,
|
||||
caches: caches
|
||||
|
@ -177,6 +178,15 @@ struct ReconcilePass: FiberReconcilerPass {
|
|||
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
|
||||
// The alternate had siblings that no longer exist.
|
||||
while alternateSibling != nil {
|
||||
|
|
|
@ -21,10 +21,17 @@ import Foundation
|
|||
public struct ViewInputs<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)
|
||||
public let environment: EnvironmentBox
|
||||
|
||||
public let traits: _ViewTraitStore?
|
||||
|
||||
public let preferenceStore: _PreferenceStore?
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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?
|
||||
}
|
||||
|
@ -51,13 +63,15 @@ public extension ViewOutputs {
|
|||
init<V>(
|
||||
inputs: ViewInputs<V>,
|
||||
environment: EnvironmentValues? = nil,
|
||||
preferences: _PreferenceStore? = nil,
|
||||
preferenceStore: _PreferenceStore? = nil,
|
||||
preferenceAction: ((_PreferenceStore) -> ())? = nil,
|
||||
traits: _ViewTraitStore? = nil
|
||||
) {
|
||||
// Only replace the `EnvironmentBox` when we change the environment.
|
||||
// Otherwise the same box can be reused.
|
||||
self.environment = environment.map(EnvironmentBox.init) ?? inputs.environment
|
||||
self.preferences = preferences ?? .init()
|
||||
self.preferenceStore = preferenceStore
|
||||
self.preferenceAction = preferenceAction
|
||||
self.traits = traits ?? inputs.traits
|
||||
}
|
||||
}
|
||||
|
@ -74,8 +88,10 @@ public extension ModifiedContent where Content: View, Modifier: ViewModifier {
|
|||
static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs {
|
||||
Modifier._makeView(.init(
|
||||
content: inputs.content.modifier,
|
||||
updateContent: { _ in },
|
||||
environment: inputs.environment,
|
||||
traits: inputs.traits
|
||||
traits: inputs.traits,
|
||||
preferenceStore: inputs.preferenceStore
|
||||
))
|
||||
}
|
||||
|
||||
|
|
|
@ -15,20 +15,24 @@
|
|||
// Created by Carson Katri on 2/11/22.
|
||||
//
|
||||
|
||||
enum WalkWorkResult<Success> {
|
||||
@_spi(TokamakCore)
|
||||
public enum WalkWorkResult<Success> {
|
||||
case `continue`
|
||||
case `break`(with: Success)
|
||||
case pause
|
||||
}
|
||||
|
||||
enum WalkResult<Renderer: FiberRenderer, Success> {
|
||||
@_spi(TokamakCore)
|
||||
public enum WalkResult<Renderer: FiberRenderer, Success> {
|
||||
case success(Success)
|
||||
case finished
|
||||
case paused(at: FiberReconciler<Renderer>.Fiber)
|
||||
}
|
||||
|
||||
/// Walk a fiber tree from `root` until the `work` predicate returns `false`.
|
||||
@_spi(TokamakCore)
|
||||
@discardableResult
|
||||
func walk<Renderer: FiberRenderer>(
|
||||
public func walk<Renderer: FiberRenderer>(
|
||||
_ root: FiberReconciler<Renderer>.Fiber,
|
||||
_ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> Bool
|
||||
) rethrows -> WalkResult<Renderer, ()> {
|
||||
|
@ -38,7 +42,17 @@ func walk<Renderer: FiberRenderer>(
|
|||
}
|
||||
|
||||
/// 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,
|
||||
_ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> WalkWorkResult<Success>
|
||||
) rethrows -> WalkResult<Renderer, Success> {
|
||||
|
|
|
@ -25,12 +25,41 @@ public extension PreferenceKey where Self.Value: ExpressibleByNilLiteral {
|
|||
static var defaultValue: Value { nil }
|
||||
}
|
||||
|
||||
public struct _PreferenceValue<Key> where Key: PreferenceKey {
|
||||
final class _PreferenceValueStorage: CustomDebugStringConvertible {
|
||||
/// 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.
|
||||
public var value: Key.Value {
|
||||
reduce(valueList)
|
||||
reduce(storage.valueList.compactMap { $0 as? 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`.
|
||||
private var values: [String: Any]
|
||||
private var values: [ObjectIdentifier: _PreferenceValueStorage]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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>
|
||||
where Key: PreferenceKey
|
||||
{
|
||||
values[String(reflecting: key)] as? _PreferenceValue<Key>
|
||||
?? _PreferenceValue(valueList: [Key.defaultValue])
|
||||
let keyID = ObjectIdentifier(key)
|
||||
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)
|
||||
where Key: PreferenceKey
|
||||
{
|
||||
let previousValues = self.value(forKey: key).valueList
|
||||
values[String(reflecting: key)] = _PreferenceValue<Key>(valueList: previousValues + [value])
|
||||
let keyID = ObjectIdentifier(key)
|
||||
if !values.keys.contains(keyID) {
|
||||
values[keyID] = .init(key)
|
||||
}
|
||||
values[keyID]?.valueList.append(value)
|
||||
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`.
|
||||
|
|
|
@ -25,12 +25,26 @@ public struct _PreferenceActionModifier<Key>: _PreferenceWritingModifierProtocol
|
|||
|
||||
public func body(_ content: Content, with preferenceStore: inout _PreferenceStore) -> AnyView {
|
||||
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 {
|
||||
action(value.value)
|
||||
}
|
||||
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 {
|
||||
|
|
|
@ -21,11 +21,11 @@ public struct _DelayedPreferenceView<Key, Content>: View, _PreferenceReadingView
|
|||
where Key: PreferenceKey, Content: View
|
||||
{
|
||||
@State
|
||||
private var resolvedValue: _PreferenceValue<Key> = _PreferenceValue(
|
||||
valueList: [Key.defaultValue]
|
||||
)
|
||||
private var resolvedValue: _PreferenceValue<Key> = _PreferenceValue(storage: .init(Key.self))
|
||||
public let transform: (_PreferenceValue<Key>) -> Content
|
||||
|
||||
private var valueReference: _PreferenceValue<Key>?
|
||||
|
||||
public init(transform: @escaping (_PreferenceValue<Key>) -> Content) {
|
||||
self.transform = transform
|
||||
}
|
||||
|
@ -35,7 +35,18 @@ public struct _DelayedPreferenceView<Key, Content>: View, _PreferenceReadingView
|
|||
}
|
||||
|
||||
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
|
||||
where Key: PreferenceKey, T: View
|
||||
{
|
||||
Key._delay { self.overlay(transform($0.value)) }
|
||||
Key._delay { self.overlay($0._force(transform)) }
|
||||
}
|
||||
|
||||
func backgroundPreferenceValue<Key, T>(
|
||||
|
@ -79,6 +90,6 @@ public extension View {
|
|||
) -> some View
|
||||
where Key: PreferenceKey, T: View
|
||||
{
|
||||
Key._delay { self.background(transform($0.value)) }
|
||||
Key._delay { self.background($0._force(transform)) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,18 @@ public struct _PreferenceTransformModifier<Key>: _PreferenceWritingModifierProto
|
|||
preferenceStore.insert(newValue, forKey: Key.self)
|
||||
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 {
|
||||
|
|
|
@ -27,6 +27,14 @@ public struct _PreferenceWritingModifier<Key>: _PreferenceWritingModifierProtoco
|
|||
preferenceStore.insert(value, forKey: Key.self)
|
||||
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 {
|
||||
|
|
|
@ -19,6 +19,8 @@ import TokamakCore
|
|||
|
||||
// MARK: Environment & State
|
||||
|
||||
public typealias DynamicProperty = TokamakCore.DynamicProperty
|
||||
|
||||
public typealias Environment = TokamakCore.Environment
|
||||
public typealias EnvironmentKey = TokamakCore.EnvironmentKey
|
||||
public typealias EnvironmentObject = TokamakCore.EnvironmentObject
|
||||
|
@ -180,8 +182,16 @@ public typealias View = TokamakCore.View
|
|||
public typealias AnyView = TokamakCore.AnyView
|
||||
public typealias EmptyView = TokamakCore.EmptyView
|
||||
|
||||
// MARK: Layout
|
||||
|
||||
public typealias Layout = TokamakCore.Layout
|
||||
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
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
import Foundation
|
||||
import JavaScriptKit
|
||||
import OpenCombineJS
|
||||
@_spi(TokamakCore)
|
||||
import TokamakCore
|
||||
@_spi(TokamakStaticHTML)
|
||||
|
@ -88,6 +89,8 @@ public struct DOMFiberRenderer: FiberRenderer {
|
|||
public var defaultEnvironment: EnvironmentValues {
|
||||
var environment = EnvironmentValues()
|
||||
environment[_ColorSchemeKey.self] = .light
|
||||
environment._defaultAppStorage = LocalStorage.standard
|
||||
_DefaultSceneStorageProvider.default = SessionStorage.standard
|
||||
return environment
|
||||
}
|
||||
|
||||
|
@ -117,6 +120,10 @@ public struct DOMFiberRenderer: FiberRenderer {
|
|||
_ = reference.style.setProperty("width", "100vw")
|
||||
_ = reference.style.setProperty("height", "100vh")
|
||||
_ = 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 {
|
||||
|
|
|
@ -253,4 +253,8 @@ public struct StaticHTMLFiberRenderer: FiberRenderer {
|
|||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
public func schedule(_ action: @escaping () -> ()) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -176,4 +176,8 @@ public struct TestFiberRenderer: FiberRenderer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func schedule(_ action: @escaping () -> ()) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"))
|
||||
}
|
||||
}
|
|
@ -20,40 +20,6 @@ import XCTest
|
|||
@_spi(TokamakCore) @testable import TokamakCore
|
||||
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 {
|
||||
func testCounter() {
|
||||
struct TestView: View {
|
||||
|
@ -64,16 +30,19 @@ final class VisitorTests: XCTestCase {
|
|||
var body: some View {
|
||||
VStack {
|
||||
Text("\(count)")
|
||||
.identified(by: "count")
|
||||
HStack {
|
||||
if count > 0 {
|
||||
Button("Decrement") {
|
||||
count -= 1
|
||||
}
|
||||
.identified(by: "decrement")
|
||||
}
|
||||
if count < 5 {
|
||||
Button("Increment") {
|
||||
count += 1
|
||||
}
|
||||
.identified(by: "increment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -84,75 +53,28 @@ final class VisitorTests: XCTestCase {
|
|||
Counter()
|
||||
}
|
||||
}
|
||||
let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500))
|
||||
.render(TestView())
|
||||
var hStack: FiberReconciler<TestFiberRenderer>.Fiber? {
|
||||
reconciler.current // RootView
|
||||
.child? // LayoutView
|
||||
.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()
|
||||
}
|
||||
let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
|
||||
|
||||
let incrementButton = reconciler.findView(id: "increment", as: Button<Text>.self)
|
||||
let countText = reconciler.findView(id: "count", as: Text.self)
|
||||
let decrementButton = reconciler.findView(id: "decrement", as: Button<Text>.self)
|
||||
// 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
|
||||
for i in 0..<5 {
|
||||
reconciler.expect(text, equals: Text("\(i)"))
|
||||
increment()
|
||||
XCTAssertEqual(countText.view, Text("\(i)"))
|
||||
incrementButton.action?()
|
||||
}
|
||||
XCTAssertNil(incrementButton, "'Increment' should be hidden when count >= 5")
|
||||
reconciler.expect(
|
||||
decrementButton,
|
||||
represents: Button<Text>.self,
|
||||
"'Decrement' should be visible when count > 0"
|
||||
)
|
||||
XCTAssertNil(incrementButton.view, "'Increment' should be hidden when count >= 5")
|
||||
XCTAssertNotNil(decrementButton.view, "'Decrement' should be visible when count > 0")
|
||||
// Count down to 0.
|
||||
for i in 0..<5 {
|
||||
reconciler.expect(text, equals: Text("\(5 - i)"))
|
||||
decrement()
|
||||
XCTAssertEqual(countText.view, Text("\(5 - i)"))
|
||||
decrementButton.action?()
|
||||
}
|
||||
XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0")
|
||||
reconciler.expect(
|
||||
incrementButton,
|
||||
represents: Button<Text>.self,
|
||||
"'Increment' should be visible when count < 5"
|
||||
)
|
||||
XCTAssertNil(decrementButton.view, "'Decrement' should be hidden when count <= 0")
|
||||
XCTAssertNotNil(incrementButton.view, "'Increment' should be visible when count < 5")
|
||||
}
|
||||
|
||||
func testForEach() {
|
||||
|
@ -163,61 +85,143 @@ final class VisitorTests: XCTestCase {
|
|||
var body: some View {
|
||||
VStack {
|
||||
Button("Add Item") { count += 1 }
|
||||
.identified(by: "addItem")
|
||||
ForEach(Array(0..<count), id: \.self) { i in
|
||||
Text("Item \(i)")
|
||||
.identified(by: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reconciler = TestFiberRenderer(
|
||||
.root,
|
||||
size: .init(width: 500, height: 500),
|
||||
useDynamicLayout: true
|
||||
)
|
||||
.render(TestView())
|
||||
var addItemFiber: FiberReconciler<TestFiberRenderer>.Fiber? {
|
||||
reconciler.current // RootView
|
||||
.child? // LayoutView
|
||||
.child? // ModifiedContent
|
||||
.child? // _ViewModifier_Content
|
||||
.child? // TestView
|
||||
.child? // VStack
|
||||
.child? // TupleView
|
||||
.child // Button
|
||||
let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
|
||||
|
||||
let addItemButton = reconciler.findView(id: "addItem", as: Button<Text>.self)
|
||||
XCTAssertNotNil(addItemButton)
|
||||
for i in 0..<10 {
|
||||
addItemButton.action?()
|
||||
XCTAssertEqual(reconciler.findView(id: i).view, Text("Item \(i)"))
|
||||
}
|
||||
var forEachFiber: FiberReconciler<TestFiberRenderer>.Fiber? {
|
||||
reconciler.current // RootView
|
||||
.child? // LayoutView
|
||||
.child? // ModifiedContent
|
||||
.child? // _ViewModifier_Content
|
||||
.child? // TestView
|
||||
.child? // VStack
|
||||
.child? // TupleView
|
||||
.child?.sibling // ForEach
|
||||
}
|
||||
|
||||
func testDynamicProperties() {
|
||||
enum DynamicPropertyTest: Hashable {
|
||||
case state
|
||||
case environment
|
||||
case stateObject
|
||||
case observedObject
|
||||
case environmentObject
|
||||
}
|
||||
func item(at index: Int) -> FiberReconciler<TestFiberRenderer>.Fiber? {
|
||||
var node = forEachFiber?.child
|
||||
for _ in 0..<index {
|
||||
node = node?.sibling
|
||||
struct TestView: View {
|
||||
var body: some View {
|
||||
TestState()
|
||||
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
|
||||
else { return }
|
||||
(view as? Button<Text>)?.action()
|
||||
}
|
||||
reconciler.expect(addItemFiber, represents: Button<Text>.self)
|
||||
reconciler.expect(forEachFiber, represents: ForEach<[Int], Int, Text>.self)
|
||||
addItem()
|
||||
reconciler.expect(item(at: 0), equals: Text("Item 0"))
|
||||
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 2)
|
||||
addItem()
|
||||
reconciler.expect(item(at: 1), equals: Text("Item 1"))
|
||||
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 3)
|
||||
addItem()
|
||||
reconciler.expect(item(at: 2), equals: Text("Item 2"))
|
||||
XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 4)
|
||||
|
||||
let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView())
|
||||
|
||||
// State
|
||||
let button = reconciler.findView(id: DynamicPropertyTest.state, as: Button<Text>.self)
|
||||
XCTAssertEqual(button.label, Text("0"))
|
||||
button.action?()
|
||||
XCTAssertEqual(button.label, Text("1"))
|
||||
|
||||
// Environment
|
||||
XCTAssertEqual(
|
||||
reconciler.findView(id: DynamicPropertyTest.environment).view,
|
||||
Text("5")
|
||||
)
|
||||
|
||||
// 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue