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 {
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 {

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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

View File

@ -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
)
}

View File

@ -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 }

View File

@ -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 {

View File

@ -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
))
}

View File

@ -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> {

View File

@ -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`.

View File

@ -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 {

View File

@ -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)) }
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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 {

View File

@ -253,4 +253,8 @@ public struct StaticHTMLFiberRenderer: FiberRenderer {
</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
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")
)
}
}