Tokamak/Sources/TokamakCore/Fiber/FiberReconciler.swift

291 lines
8.7 KiB
Swift

// 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 2/15/22.
//
import Foundation
import OpenCombineShim
/// A reconciler modeled after React's
/// [Fiber reconciler](https://reactjs.org/docs/faq-internals.html#what-is-react-fiber)
public final class FiberReconciler<Renderer: FiberRenderer> {
/// The root node in the `Fiber` tree that represents the `View`s currently rendered on screen.
@_spi(TokamakCore)
public var current: Fiber!
/// The alternate of `current`, or the work in progress tree root.
///
/// We must keep a strong reference to both the current and alternate tree roots,
/// as they only keep weak references to each other.
private var alternate: Fiber!
/// The `FiberRenderer` used to create and update the `Element`s on screen.
public let renderer: Renderer
/// Enabled passes to run on each `reconcile(from:)` call.
private let passes: [FiberReconcilerPass]
private let caches: Caches
private var sceneSizeCancellable: AnyCancellable?
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 {
let content: Content
let reconciler: FiberReconciler<Renderer>
var environment: EnvironmentValues {
var environment = reconciler.renderer.defaultEnvironment
environment.measureText = reconciler.renderer.measureText
environment.measureImage = reconciler.renderer.measureImage
environment.afterReconcile = reconciler.afterReconcile
return environment
}
var body: some View {
RootLayout(renderer: reconciler.renderer).callAsFunction {
content
.environmentValues(environment)
}
}
}
/// The `Layout` container for the root of a `View` hierarchy.
///
/// Simply places each `View` in the center of its bounds.
struct RootLayout: Layout {
let renderer: Renderer
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
renderer.sceneSize.value
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
for subview in subviews {
subview.place(
at: .init(x: bounds.midX, y: bounds.midY),
anchor: .center,
proposal: .init(width: bounds.width, height: bounds.height)
)
}
}
}
public init<V: View>(_ renderer: Renderer, _ view: V) {
self.renderer = renderer
if renderer.useDynamicLayout {
passes = [.reconcile, .layout]
} else {
passes = [.reconcile]
}
caches = Caches()
var view = RootView(content: view, reconciler: self)
current = .init(
&view,
element: renderer.rootElement,
parent: nil,
elementParent: nil,
preferenceParent: nil,
elementIndex: 0,
traits: nil,
reconciler: self
)
// Start by building the initial tree.
alternate = current.createAndBindAlternate?()
sceneSizeCancellable = renderer.sceneSize.removeDuplicates().sink { [weak self] _ in
guard let self = self else { return }
self.fiberChanged(self.current)
}
}
public init<A: App>(_ renderer: Renderer, _ app: A) {
self.renderer = renderer
if renderer.useDynamicLayout {
passes = [.reconcile, .layout]
} else {
passes = [.reconcile]
}
caches = Caches()
var environment = renderer.defaultEnvironment
environment.measureText = renderer.measureText
environment.measureImage = renderer.measureImage
environment.afterReconcile = afterReconcile
var app = app
current = .init(
&app,
rootElement: renderer.rootElement,
rootEnvironment: environment,
reconciler: self
)
// Start by building the initial tree.
alternate = current.createAndBindAlternate?()
sceneSizeCancellable = renderer.sceneSize.removeDuplicates().sink { [weak self] _ in
guard let self = self else { return }
self.fiberChanged(self.current)
}
}
/// A visitor that performs each pass used by the `FiberReconciler`.
final class ReconcilerVisitor: AppVisitor, SceneVisitor, ViewVisitor {
let root: 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, changedFibers: Set<ObjectIdentifier>, reconciler: FiberReconciler) {
self.root = root
self.changedFibers = changedFibers
self.reconciler = reconciler
}
func visit<A>(_ app: A) where A: App {
visitAny(app) { $0.visit(app.body) }
}
func visit<S>(_ scene: S) where S: Scene {
visitAny(scene, scene._visitChildren)
}
func visit<V>(_ view: V) where V: View {
visitAny(view, reconciler.renderer.viewVisitor(for: view))
}
private func visitAny(
_ content: Any,
_ visitChildren: @escaping (TreeReducer.SceneVisitor) -> ()
) {
let alternateRoot: Fiber?
if let alternate = root.alternate {
alternateRoot = alternate
} else {
alternateRoot = root.createAndBindAlternate?()
}
let rootResult = TreeReducer.Result(
fiber: alternateRoot, // The alternate is the WIP node.
visitChildren: visitChildren,
parent: nil,
child: alternateRoot?.child,
alternateChild: root.child,
elementIndices: [:],
nextTraits: .init()
)
reconciler.caches.clear()
for pass in reconciler.passes {
pass.run(
in: reconciler,
root: rootResult,
changedFibers: changedFibers,
caches: reconciler.caches
)
}
mutations = reconciler.caches.mutations
}
}
func afterReconcile(_ action: @escaping () -> ()) {
guard isReconciling == true
else {
action()
return
}
afterReconcileActions.append(action)
}
/// 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: current, changedFibers: changedFibers, reconciler: self)
switch current.content {
case let .view(_, visit):
visit(visitor)
case let .scene(_, visit):
visit(visitor)
case let .app(_, visit):
visit(visitor)
case .none:
break
}
// Apply mutations to the rendered output.
renderer.commit(visitor.mutations)
// Swap the root out for its alternate.
// Essentially, making the work in progress tree the current,
// and leaving the current available to be the work in progress
// on our next update.
let alternate = alternate
self.alternate = current
current = alternate
isReconciling = false
for action in afterReconcileActions {
action()
}
}
}
public extension EnvironmentValues {
private enum AfterReconcileKey: EnvironmentKey {
static let defaultValue: (@escaping () -> ()) -> () = { _ in }
}
var afterReconcile: (@escaping () -> ()) -> () {
get { self[AfterReconcileKey.self] }
set { self[AfterReconcileKey.self] = newValue }
}
}