Add View Traits and transitions (#426)

This commit is contained in:
Carson Katri 2021-07-28 09:40:12 -04:00 committed by GitHub
parent ae0db4d1f1
commit 9a568ab9cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1117 additions and 208 deletions

View File

@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
207C05702610E16E00BBBE54 /* DatePickerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */; };
207C05712610E16E00BBBE54 /* DatePickerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */; };
26136823269E8EB5006F372E /* TransitionDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26136822269E8EB5006F372E /* TransitionDemo.swift */; };
26136824269E8EB5006F372E /* TransitionDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26136822269E8EB5006F372E /* TransitionDemo.swift */; };
262DA7B32695D99500CABEAE /* ShapeStyleDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */; };
262DA7B42695D99500CABEAE /* ShapeStyleDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */; };
26AC04B62698D33A0057784E /* ProgressViewDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26AC04B52698D33A0057784E /* ProgressViewDemo.swift */; };
@ -103,6 +105,7 @@
/* Begin PBXFileReference section */
207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatePickerDemo.swift; sourceTree = "<group>"; };
26136822269E8EB5006F372E /* TransitionDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionDemo.swift; sourceTree = "<group>"; };
262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShapeStyleDemo.swift; sourceTree = "<group>"; };
26AC04B52698D33A0057784E /* ProgressViewDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressViewDemo.swift; sourceTree = "<group>"; };
26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimationDemo.swift; sourceTree = "<group>"; };
@ -200,6 +203,7 @@
85ED189924AD425E0085DFA0 /* TokamakDemo */ = {
isa = PBXGroup;
children = (
26136822269E8EB5006F372E /* TransitionDemo.swift */,
26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */,
262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */,
D120FDDA257E7145008FFBAD /* TextEditorDemo.swift */,
@ -379,6 +383,7 @@
B5DBA22B24D509B4003D3347 /* RedactDemo.swift in Sources */,
B56F22E024BC89FD001738DF /* ColorDemo.swift in Sources */,
26A3BFB0269BD18A0004DA16 /* AnimationDemo.swift in Sources */,
26136823269E8EB5006F372E /* TransitionDemo.swift in Sources */,
B51F215024B920B400CF2583 /* PathDemo.swift in Sources */,
85ED18AF24AD425E0085DFA0 /* EnvironmentDemo.swift in Sources */,
85ED18A324AD425E0085DFA0 /* SpacerDemo.swift in Sources */,
@ -415,6 +420,7 @@
D1D6B62424D817350041E1D9 /* GeometryReaderDemo.swift in Sources */,
B5DBA22C24D509B4003D3347 /* RedactDemo.swift in Sources */,
26A3BFB1269BD18A0004DA16 /* AnimationDemo.swift in Sources */,
26136824269E8EB5006F372E /* TransitionDemo.swift in Sources */,
B56F22E124BC89FD001738DF /* ColorDemo.swift in Sources */,
B51F215124B920B400CF2583 /* PathDemo.swift in Sources */,
85ED18A424AD425E0085DFA0 /* SpacerDemo.swift in Sources */,

View File

@ -177,7 +177,13 @@ let package = Package(
condition: .when(platforms: [.wasi])
),
],
resources: [.copy("logo-header.png")]
resources: [.copy("logo-header.png")],
linkerSettings: [
.unsafeFlags(
["-Xlinker", "--stack-first", "-Xlinker", "-z", "-Xlinker", "stack-size=16777216"],
.when(platforms: [.wasi])
),
]
),
.target(
name: "TokamakStaticHTMLDemo",

View File

@ -26,7 +26,7 @@ public struct Transaction {
public init(animation: Animation?) {
self.animation = animation
disablesAnimations = true
disablesAnimations = false
}
}

View File

@ -39,7 +39,7 @@ extension ModifiedContent: EnvironmentReader where Modifier: EnvironmentReader {
}
}
extension ModifiedContent: View, ParentView where Content: View, Modifier: ViewModifier {
extension ModifiedContent: View, GroupView, ParentView where Content: View, Modifier: ViewModifier {
public var body: Body {
neverBody("ModifiedContent<View, ViewModifier>")
}

View File

@ -18,7 +18,9 @@ public protocol ViewModifier {
func body(content: Content) -> Self.Body
}
public struct _ViewModifier_Content<Modifier>: View where Modifier: ViewModifier {
public struct _ViewModifier_Content<Modifier>: View
where Modifier: ViewModifier
{
public let modifier: Modifier
public let view: AnyView
@ -27,7 +29,7 @@ public struct _ViewModifier_Content<Modifier>: View where Modifier: ViewModifier
self.view = view
}
public var body: AnyView {
public var body: some View {
view
}
}

View File

@ -22,21 +22,33 @@ import OpenCombineShim
// `View`s
final class MountedApp<R: Renderer>: MountedCompositeElement<R> {
override func mount(
before _: R.TargetType? = nil,
on _: MountedElement<R>? = nil,
with reconciler: StackReconciler<R>
before sibling: R.TargetType? = nil,
on parent: MountedElement<R>? = nil,
in reconciler: StackReconciler<R>,
with transaction: Transaction
) {
super.prepareForMount(with: transaction)
// `App` elements have no siblings, hence the `before` argument is discarded.
// They also have no parents, so the `parent` argument is discarded as well.
let childBody = reconciler.render(mountedApp: self)
let child: MountedElement<R> = mountChild(reconciler.renderer, childBody)
mountedChildren = [child]
child.mount(before: nil, on: self, with: reconciler)
child.transaction = transaction
child.mount(before: nil, on: self, in: reconciler, with: transaction)
super.mount(before: sibling, on: parent, in: reconciler, with: transaction)
}
override func unmount(with reconciler: StackReconciler<R>) {
mountedChildren.forEach { $0.unmount(with: reconciler) }
override func unmount(
in reconciler: StackReconciler<R>,
with transaction: Transaction,
parentTask: UnmountTask<R>?
) {
super.unmount(in: reconciler, with: transaction, parentTask: parentTask)
mountedChildren
.forEach { $0.unmount(in: reconciler, with: transaction, parentTask: parentTask) }
}
/// Mounts a child scene within the app.

View File

@ -61,10 +61,11 @@ class MountedCompositeElement<R: Renderer>: MountedElement<R> {
_ view: AnyView,
_ parentTarget: R.TargetType,
_ environmentValues: EnvironmentValues,
_ viewTraits: _ViewTraitStore,
_ parent: MountedElement<R>?
) {
self.parentTarget = parentTarget
super.init(view, environmentValues, parent)
super.init(view, environmentValues, viewTraits, parent)
}
}

View File

@ -21,20 +21,32 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
override func mount(
before sibling: R.TargetType? = nil,
on parent: MountedElement<R>? = nil,
with reconciler: StackReconciler<R>
in reconciler: StackReconciler<R>,
with transaction: Transaction
) {
super.prepareForMount(with: transaction)
var transaction = transaction
(view.view as? _TransactionModifierProtocol)?.modifyTransaction(&transaction)
// Disable animations on mount so `animation(_:)` doesn't try to animate
// until the transition finishes.
transaction.disablesAnimations = true
self.transaction = transaction
let childBody = reconciler.render(compositeView: self)
if let traitModifier = view.view as? _TraitWritingModifierProtocol {
traitModifier.modifyViewTraitStore(&viewTraits)
}
let child: MountedElement<R> = childBody.makeMountedView(
reconciler.renderer,
parentTarget,
environmentValues,
viewTraits,
self
)
mountedChildren = [child]
child.mount(before: sibling, on: self, with: reconciler)
child.mount(before: sibling, on: self, in: reconciler, with: transaction)
// `_TargetRef` (and `TargetRefType` generic eraser protocol it conforms to) is a composite
// view, so it's enough check for it only here.
@ -73,10 +85,25 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
preferenceReader.preferenceStore(self.preferenceStore)
}
})
super.mount(before: sibling, on: parent, in: reconciler, with: transaction)
}
override func unmount(with reconciler: StackReconciler<R>) {
mountedChildren.forEach { $0.unmount(with: reconciler) }
override func unmount(
in reconciler: StackReconciler<R>,
with transaction: Transaction,
parentTask: UnmountTask<R>?
) {
super.unmount(in: reconciler, with: transaction, parentTask: parentTask)
var transaction = transaction
transaction.disablesAnimations = false
(view.view as? _TransactionModifierProtocol)?.modifyTransaction(&transaction)
mountedChildren.forEach {
$0.viewTraits = self.viewTraits
$0.unmount(in: reconciler, with: transaction, parentTask: parentTask)
}
if let appearanceAction = view.view as? AppearanceActionType {
appearanceAction.disappear?()
@ -85,6 +112,7 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
override func update(in reconciler: StackReconciler<R>, with transaction: Transaction) {
var transaction = transaction
transaction.disablesAnimations = false
(view.view as? _TransactionModifierProtocol)?.modifyTransaction(&transaction)
let element = reconciler.render(compositeView: self)
reconciler.reconcile(
@ -98,7 +126,13 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
$0.transaction = transaction
},
mountChild: {
$0.makeMountedView(reconciler.renderer, parentTarget, environmentValues, self)
$0.makeMountedView(
reconciler.renderer,
parentTarget,
environmentValues,
viewTraits,
self
)
}
)
}

View File

@ -86,11 +86,15 @@ public class MountedElement<R: Renderer> {
var mountedChildren = [MountedElement<R>]()
public internal(set) var transaction: Transaction = .init(animation: nil)
public var transaction: Transaction = .init(animation: nil)
/// Where this element is the process of mounting/unmounting.
var transitionPhase = TransitionPhase.willMount
/// The current `UnmountTask` of this element.
var unmountTask: UnmountTask<R>?
var environmentValues: EnvironmentValues
public internal(set) var environmentValues: EnvironmentValues
unowned var parent: MountedElement<R>?
weak var parent: MountedElement<R>?
/// `didSet` on this field propagates the preference changes up the view tree.
var preferenceStore: _PreferenceStore = .init() {
didSet {
@ -98,10 +102,13 @@ public class MountedElement<R: Renderer> {
}
}
public internal(set) var viewTraits: _ViewTraitStore
init(_ app: _AnyApp, _ environmentValues: EnvironmentValues, _ parent: MountedElement<R>?) {
element = .app(app)
self.parent = parent
self.environmentValues = environmentValues
viewTraits = .init()
updateEnvironment()
}
@ -109,13 +116,20 @@ public class MountedElement<R: Renderer> {
element = .scene(scene)
self.parent = parent
self.environmentValues = environmentValues
viewTraits = .init()
updateEnvironment()
}
init(_ view: AnyView, _ environmentValues: EnvironmentValues, _ parent: MountedElement<R>?) {
init(
_ view: AnyView,
_ environmentValues: EnvironmentValues,
_ viewTraits: _ViewTraitStore,
_ parent: MountedElement<R>?
) {
element = .view(view)
self.parent = parent
self.environmentValues = environmentValues
self.viewTraits = viewTraits
updateEnvironment()
}
@ -131,16 +145,68 @@ public class MountedElement<R: Renderer> {
}
}
/// You must call `super.prepareForMount` before all other mounting work.
func prepareForMount(with transaction: Transaction) {
// `GroupView`'s don't really mount, so let their children transition if the group can.
if case let .view(view) = element,
view.type is GroupView.Type
{
transitionPhase = parent?.transitionPhase ?? .normal
}
// Allow the root of a mount to transition
// (if their parent isn't mounting, then they are the root of the mount).
if parent?.transitionPhase == .normal {
viewTraits.insert(
transaction.animation != nil
|| _AnyTransitionProxy(viewTraits.transition)
.resolve(in: environmentValues)
.insertionAnimation != nil,
forKey: CanTransitionTraitKey.self
)
}
}
/// You must call `super.mount` after all other mounting work.
func mount(
before sibling: R.TargetType? = nil,
on parent: MountedElement<R>? = nil,
with reconciler: StackReconciler<R>
in reconciler: StackReconciler<R>,
with transaction: Transaction
) {
fatalError("implement \(#function) in subclass")
// Set the phase to `normal` after finished mounting.
transitionPhase = .normal
}
func unmount(with reconciler: StackReconciler<R>) {
fatalError("implement \(#function) in subclass")
/// You must call `super.unmount` before all other unmounting work.
func unmount(
in reconciler: StackReconciler<R>,
with transaction: Transaction,
parentTask: UnmountTask<R>?
) {
if !(self is MountedHostView<R>) {
unmountTask = parentTask?.appendChild()
}
// `GroupView`'s don't really unmount, so let their children transition if the group can.
if case let .view(view) = element,
view.type is GroupView.Type
{
transitionPhase = parent?.transitionPhase ?? .normal
} else {
// Set the phase to `willUnmount` before unmounting.
transitionPhase = .willUnmount
}
// Allow the root of an unmount to transition
// (if their parent isn't unmounting, then they are the root of the unmount).
if parent?.transitionPhase == .normal {
viewTraits.insert(
transaction.animation != nil
|| _AnyTransitionProxy(viewTraits.transition)
.resolve(in: environmentValues)
.removalAnimation != nil,
forKey: CanTransitionTraitKey.self
)
}
}
func update(in reconciler: StackReconciler<R>, with transaction: Transaction) {
@ -228,14 +294,15 @@ extension AnyView {
_ renderer: R,
_ parentTarget: R.TargetType,
_ environmentValues: EnvironmentValues,
_ viewTraits: _ViewTraitStore,
_ parent: MountedElement<R>?
) -> MountedElement<R> {
if type == EmptyView.self {
return MountedEmptyView(self, environmentValues, parent)
return MountedEmptyView(self, environmentValues, viewTraits, parent)
} else if bodyType == Never.self && !renderer.isPrimitiveView(type) {
return MountedHostView(self, parentTarget, environmentValues, parent)
return MountedHostView(self, parentTarget, environmentValues, viewTraits, parent)
} else {
return MountedCompositeView(self, parentTarget, environmentValues, parent)
return MountedCompositeView(self, parentTarget, environmentValues, viewTraits, parent)
}
}
}

View File

@ -19,10 +19,20 @@ final class MountedEmptyView<R: Renderer>: MountedElement<R> {
override func mount(
before sibling: R.TargetType? = nil,
on parent: MountedElement<R>? = nil,
with reconciler: StackReconciler<R>
) {}
in reconciler: StackReconciler<R>,
with transaction: Transaction
) {
super.prepareForMount(with: transaction)
super.mount(before: sibling, on: parent, in: reconciler, with: transaction)
}
override func unmount(with reconciler: StackReconciler<R>) {}
override func unmount(
in reconciler: StackReconciler<R>,
with transaction: Transaction,
parentTask: UnmountTask<R>?
) {
super.unmount(in: reconciler, with: transaction, parentTask: parentTask)
}
override func update(in reconciler: StackReconciler<R>, with transaction: Transaction?) {}
}

View File

@ -33,18 +33,24 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
_ view: AnyView,
_ parentTarget: R.TargetType,
_ environmentValues: EnvironmentValues,
_ viewTraits: _ViewTraitStore,
_ parent: MountedElement<R>?
) {
self.parentTarget = parentTarget
super.init(view, environmentValues, parent)
super.init(view, environmentValues, viewTraits, parent)
}
override func mount(
before sibling: R.TargetType? = nil,
on parent: MountedElement<R>? = nil,
with reconciler: StackReconciler<R>
in reconciler: StackReconciler<R>,
with transaction: Transaction
) {
super.prepareForMount(with: transaction)
self.transaction = transaction
guard let target = reconciler.renderer.mountTarget(
before: sibling,
to: parentTarget,
@ -56,8 +62,17 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
guard !view.children.isEmpty else { return }
let isGroupView = view.type is GroupView.Type
// Don't allow children to transition their mounting since they aren't individually
// appearing (unless its a `GroupView`, which is flattened).
mountedChildren = view.children.map {
$0.makeMountedView(reconciler.renderer, target, environmentValues, self)
$0.makeMountedView(
reconciler.renderer,
target,
environmentValues,
isGroupView ? self.viewTraits : .init(),
self
)
}
/* Remember that `GroupView`s are always "flattened", their `target` instances are targets of
@ -65,46 +80,73 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
are mounted in that case. Thus pass the `sibling` target to the children if `view` is a
`GroupView`.
*/
let isGroupView = view.type is GroupView.Type
mountedChildren.forEach {
$0.mount(before: isGroupView ? sibling : nil, on: self, with: reconciler)
$0.mount(before: isGroupView ? sibling : nil, on: self, in: reconciler, with: transaction)
}
super.mount(before: sibling, on: parent, in: reconciler, with: transaction)
}
override func unmount(with reconciler: StackReconciler<R>) {
private var parentUnmountTask = UnmountTask<R>()
override func unmount(
in reconciler: StackReconciler<R>,
with transaction: Transaction,
parentTask: UnmountTask<R>?
) {
super.unmount(in: reconciler, with: transaction, parentTask: parentTask)
guard let target = target else { return }
let task = UnmountHostTask(self, in: reconciler) {
self.mountedChildren.forEach {
$0.unmount(in: reconciler, with: transaction, parentTask: self.unmountTask)
}
}
task.isCancelled = parentTask?.isCancelled ?? false
unmountTask = task
parentTask?.childTasks.append(task)
reconciler.renderer.unmount(
target: target,
from: parentTarget,
with: self
) {
self.mountedChildren.forEach { $0.unmount(with: reconciler) }
}
with: task
)
}
/// Stop any unfinished unmounts and complete them without transitions.
private func invalidateUnmount() {
parentUnmountTask.cancel()
parentUnmountTask.completeImmediately()
parentUnmountTask = .init()
}
override func update(in reconciler: StackReconciler<R>, with transaction: Transaction) {
guard let target = target else { return }
invalidateUnmount()
updateEnvironment()
target.view = view
reconciler.renderer.update(target: target, with: self)
var childrenViews = view.children
let traits = view.type is GroupView.Type ? viewTraits : .init()
switch (mountedChildren.isEmpty, childrenViews.isEmpty) {
// if existing children present and new children array is empty
// then unmount all existing children
case (false, true):
mountedChildren.forEach { $0.unmount(with: reconciler) }
mountedChildren.forEach {
$0.unmount(in: reconciler, with: transaction, parentTask: self.parentUnmountTask)
}
mountedChildren = []
// if no existing children then mount all new children
case (true, false):
mountedChildren = childrenViews.map {
$0.makeMountedView(reconciler.renderer, target, environmentValues, self)
$0.makeMountedView(reconciler.renderer, target, environmentValues, traits, self)
}
mountedChildren.forEach { $0.mount(on: self, with: reconciler) }
mountedChildren.forEach { $0.mount(on: self, in: reconciler, with: transaction) }
// if both arrays have items then reconcile by types and keys
case (false, false):
@ -130,10 +172,13 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
reconciler.renderer,
target,
environmentValues,
traits,
self
)
newChild.mount(before: mountedChild.firstDescendantTarget, on: self, with: reconciler)
mountedChild.unmount(with: reconciler)
newChild.mount(
before: mountedChild.firstDescendantTarget, on: self, in: reconciler, with: transaction
)
mountedChild.unmount(in: reconciler, with: transaction, parentTask: parentUnmountTask)
}
newChildren.append(newChild)
mountedChildren.removeFirst()
@ -144,15 +189,15 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
// unmount remaining `mountedChildren`
if !mountedChildren.isEmpty {
for child in mountedChildren {
child.unmount(with: reconciler)
child.unmount(in: reconciler, with: transaction, parentTask: parentUnmountTask)
}
} else {
// more views left than children were mounted,
// mount remaining views
for firstChild in childrenViews {
let newChild: MountedElement<R> =
firstChild.makeMountedView(reconciler.renderer, target, environmentValues, self)
newChild.mount(on: self, with: reconciler)
firstChild.makeMountedView(reconciler.renderer, target, environmentValues, traits, self)
newChild.mount(on: self, in: reconciler, with: transaction)
newChildren.append(newChild)
}
}

View File

@ -31,18 +31,28 @@ final class MountedScene<R: Renderer>: MountedCompositeElement<R> {
override func mount(
before sibling: R.TargetType? = nil,
on parent: MountedElement<R>? = nil,
with reconciler: StackReconciler<R>
in reconciler: StackReconciler<R>,
with transaction: Transaction
) {
super.prepareForMount(with: transaction)
let childBody = reconciler.render(mountedScene: self)
let child: MountedElement<R> = childBody
.makeMountedElement(reconciler.renderer, parentTarget, environmentValues, self)
mountedChildren = [child]
child.mount(before: sibling, on: self, with: reconciler)
child.mount(before: sibling, on: self, in: reconciler, with: transaction)
super.mount(before: sibling, on: parent, in: reconciler, with: transaction)
}
override func unmount(with reconciler: StackReconciler<R>) {
mountedChildren.forEach { $0.unmount(with: reconciler) }
override func unmount(
in reconciler: StackReconciler<R>,
with transaction: Transaction,
parentTask: UnmountTask<R>?
) {
super.unmount(in: reconciler, with: transaction, parentTask: parentTask)
mountedChildren
.forEach { $0.unmount(in: reconciler, with: transaction, parentTask: parentTask) }
}
override func update(in reconciler: StackReconciler<R>, with transaction: Transaction) {
@ -89,7 +99,7 @@ extension _AnyScene.BodyResult {
case let .scene(scene):
return scene.makeMountedScene(renderer, parentTarget, environmentValues, parent)
case let .view(view):
return view.makeMountedView(renderer, parentTarget, environmentValues, parent)
return view.makeMountedView(renderer, parentTarget, environmentValues, .init(), parent)
}
}
}
@ -114,6 +124,7 @@ extension _AnyScene {
renderer,
parentTarget,
environmentValues,
.init(),
parent
),
]

View File

@ -0,0 +1,81 @@
// Copyright 2018-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 7/19/21.
//
/// A tree of cancellable in-progress unmounts.
public class UnmountTask<R> where R: Renderer {
public internal(set) var isCancelled = false
var childTasks = [UnmountTask<R>]()
private let callback: () -> ()
init(_ callback: @escaping () -> () = {}) {
self.callback = callback
}
func cancel() {
forEach { $0.isCancelled = true }
}
/// Call after completely unmounting the `host`.
public func finish() {
callback()
}
/// Adds and returns a new child `UnmountTask`
func appendChild() -> UnmountTask<R> {
let child = UnmountTask()
child.isCancelled = isCancelled
childTasks.append(child)
return child
}
/// Forces the element and all child tasks to unmount without transition.
func completeImmediately() {
forEach {
guard $0 is UnmountHostTask<R> else { return }
$0.completeImmediately()
}
}
func forEach(_ f: (UnmountTask<R>) -> ()) {
var stack = [self]
while let last = stack.popLast() {
f(last)
stack.insert(contentsOf: last.childTasks, at: 0)
}
}
}
/// The state for the unmounting of a `MountedHostView` by a `Renderer`.
public final class UnmountHostTask<R>: UnmountTask<R> where R: Renderer {
public private(set) weak var host: MountedHostView<R>!
private unowned var reconciler: StackReconciler<R>
init(
_ host: MountedHostView<R>,
in reconciler: StackReconciler<R>,
callback: @escaping () -> ()
) {
self.host = host
self.reconciler = reconciler
super.init(callback)
}
override func completeImmediately() {
host.viewTraits.insert(false, forKey: CanTransitionTraitKey.self)
host.unmount(in: reconciler, with: .init(animation: nil), parentTask: nil)
}
}

View File

@ -58,13 +58,12 @@ public protocol Renderer: AnyObject {
unmounted: removed from the parent and most likely destroyed.
- parameter target: Existing target instance to be unmounted.
- parameter parent: Parent of target to direct interaction with parent.
- parameter view: The host view that renders to the updated target.
- parameter task: The state associated with the unmount.
*/
func unmount(
target: TargetType,
from parent: TargetType,
with host: MountedHost,
completion: @escaping () -> ()
with task: UnmountHostTask<Self>
)
/** Returns a body of a given pritimive view, or `nil` if `view` is not a primitive view for

View File

@ -86,7 +86,7 @@ public final class StackReconciler<R: Renderer> {
self.scheduler = scheduler
rootTarget = target
rootElement = AnyView(view).makeMountedView(renderer, target, environment, nil)
rootElement = AnyView(view).makeMountedView(renderer, target, environment, .init(), nil)
performInitialMount()
}
@ -112,7 +112,7 @@ public final class StackReconciler<R: Renderer> {
}
private func performInitialMount() {
rootElement.mount(with: self)
rootElement.mount(in: self, with: .init(animation: nil))
performPostrenderCallbacks()
}
@ -287,7 +287,7 @@ public final class StackReconciler<R: Renderer> {
case let (nil, childBody):
let child: MountedElement<R> = mountChild(childBody)
mountedElement.mountedChildren = [child]
child.mount(with: self)
child.mount(in: self, with: transaction)
// some mounted children before and now
case let (mountedChild?, childBody):
@ -300,11 +300,11 @@ public final class StackReconciler<R: Renderer> {
} else {
// new child is of a different type, complete rerender, i.e. unmount the old
// wrapper, then mount a new one with the new `childBody`
mountedChild.unmount(with: self)
mountedChild.unmount(in: self, with: transaction, parentTask: nil)
let newMountedChild: MountedElement<R> = mountChild(childBody)
mountedElement.mountedChildren = [newMountedChild]
newMountedChild.mount(with: self)
newMountedChild.mount(in: self, with: transaction)
}
}
}

View File

@ -0,0 +1,162 @@
// Copyright 2020 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 7/10/21.
//
import Foundation
@frozen public struct AnyTransition {
fileprivate let box: _AnyTransitionBox
private init(_ box: _AnyTransitionBox) {
self.box = box
}
}
@usableFromInline
struct TransitionTraitKey: _ViewTraitKey {
@inlinable static var defaultValue: AnyTransition { .opacity }
@usableFromInline typealias Value = AnyTransition
}
@usableFromInline
struct CanTransitionTraitKey: _ViewTraitKey {
@inlinable static var defaultValue: Bool { false }
@usableFromInline typealias Value = Bool
}
public extension _ViewTraitStore {
var transition: AnyTransition { value(forKey: TransitionTraitKey.self) }
var canTransition: Bool { value(forKey: CanTransitionTraitKey.self) }
}
enum TransitionPhase: Hashable {
case willMount
case normal
case willUnmount
}
public extension View {
@inlinable
func transition(_ t: AnyTransition) -> some View {
_trait(TransitionTraitKey.self, t)
}
}
/// A `ViewModifier` used to apply a primitive transition to a `View`.
public protocol _AnyTransitionModifier: AnimatableModifier
where Body == Self.Content
{
var isActive: Bool { get }
}
public extension _AnyTransitionModifier {
func body(content: Content) -> Body {
content
}
}
public struct _MoveTransition: _AnyTransitionModifier {
public let edge: Edge
public let isActive: Bool
public typealias Body = Self.Content
}
public extension AnyTransition {
static let identity: AnyTransition = .init(IdentityTransitionBox())
static func move(edge: Edge) -> AnyTransition {
modifier(
active: _MoveTransition(edge: edge, isActive: true),
identity: _MoveTransition(edge: edge, isActive: false)
)
}
static func asymmetric(
insertion: AnyTransition,
removal: AnyTransition
) -> AnyTransition {
.init(AsymmetricTransitionBox(insertion: insertion.box, removal: removal.box))
}
static func offset(_ offset: CGSize) -> AnyTransition {
modifier(
active: _OffsetEffect(offset: offset),
identity: _OffsetEffect(offset: .zero)
)
}
static func offset(
x: CGFloat = 0,
y: CGFloat = 0
) -> AnyTransition {
offset(.init(width: x, height: y))
}
static var scale: AnyTransition { scale(scale: 0) }
static func scale(scale: CGFloat, anchor: UnitPoint = .center) -> AnyTransition {
modifier(
active: _ScaleEffect(scale: .init(width: scale, height: scale), anchor: anchor),
identity: _ScaleEffect(scale: .init(width: 1, height: 1), anchor: anchor)
)
}
static let opacity: AnyTransition = modifier(
active: _OpacityEffect(opacity: 0),
identity: _OpacityEffect(opacity: 1)
)
static let slide: AnyTransition = asymmetric(
insertion: .move(edge: .leading),
removal: .move(edge: .trailing)
)
static func modifier<E>(
active: E,
identity: E
) -> AnyTransition where E: ViewModifier {
.init(
ConcreteTransitionBox(
(active: {
AnyView($0.modifier(active))
}, identity: {
AnyView($0.modifier(identity))
})
)
)
}
func combined(with other: AnyTransition) -> AnyTransition {
.init(CombinedTransitionBox(a: box, b: other.box))
}
func animation(_ animation: Animation?) -> AnyTransition {
.init(AnimatedTransitionBox(animation: animation, parent: box))
}
}
public struct _AnyTransitionProxy {
let subject: AnyTransition
public init(_ subject: AnyTransition) { self.subject = subject }
public func resolve(
in environment: EnvironmentValues
) -> _AnyTransitionBox.ResolvedValue {
subject.box.resolve(in: environment)
}
}

View File

@ -0,0 +1,136 @@
// Copyright 2020 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 7/10/21.
//
import Foundation
public class _AnyTransitionBox: AnyTokenBox {
public typealias ResolvedValue = ResolvedTransition
public struct ResolvedTransition {
public var insertion: [Transition]
public var removal: [Transition]
public var insertionAnimation: Animation?
public var removalAnimation: Animation?
init(
insertion: [Transition],
removal: [Transition],
insertionAnimation: Animation?,
removalAnimation: Animation?
) {
self.insertion = insertion
self.removal = removal
self.insertionAnimation = insertionAnimation
self.removalAnimation = removalAnimation
}
init(transitions: [Transition]) {
self.init(
insertion: transitions,
removal: transitions,
insertionAnimation: nil,
removalAnimation: nil
)
}
public typealias Transition = (
active: (AnyView) -> AnyView,
identity: (AnyView) -> AnyView
)
}
public func resolve(in environment: EnvironmentValues) -> ResolvedValue {
fatalError("implement \(#function) in subclass")
}
}
final class IdentityTransitionBox: _AnyTransitionBox {
override func resolve(in environment: EnvironmentValues) -> _AnyTransitionBox.ResolvedValue {
.init(transitions: [])
}
}
final class ConcreteTransitionBox: _AnyTransitionBox {
let transition: ResolvedTransition.Transition
init(_ transition: ResolvedTransition.Transition) {
self.transition = transition
}
override func resolve(in environment: EnvironmentValues) -> _AnyTransitionBox.ResolvedValue {
.init(transitions: [transition])
}
}
final class AsymmetricTransitionBox: _AnyTransitionBox {
let insertion: _AnyTransitionBox
let removal: _AnyTransitionBox
init(insertion: _AnyTransitionBox, removal: _AnyTransitionBox) {
self.insertion = insertion
self.removal = removal
}
override func resolve(in environment: EnvironmentValues) -> ResolvedValue {
let insertionResolved = insertion.resolve(in: environment)
let removalResolved = removal.resolve(in: environment)
return .init(
insertion: insertionResolved.insertion,
removal: removalResolved.removal,
insertionAnimation: insertionResolved.insertionAnimation,
removalAnimation: removalResolved.removalAnimation
)
}
}
final class CombinedTransitionBox: _AnyTransitionBox {
let a: _AnyTransitionBox
let b: _AnyTransitionBox
init(a: _AnyTransitionBox, b: _AnyTransitionBox) {
self.a = a
self.b = b
}
override func resolve(in environment: EnvironmentValues) -> ResolvedValue {
let aResolved = a.resolve(in: environment)
let bResolved = b.resolve(in: environment)
return .init(
insertion: aResolved.insertion + bResolved.insertion,
removal: aResolved.removal + bResolved.removal,
insertionAnimation: bResolved.insertionAnimation ?? aResolved.insertionAnimation,
removalAnimation: bResolved.removalAnimation ?? aResolved.removalAnimation
)
}
}
final class AnimatedTransitionBox: _AnyTransitionBox {
let animation: Animation?
let parent: _AnyTransitionBox
init(animation: Animation?, parent: _AnyTransitionBox) {
self.animation = animation
self.parent = parent
}
override func resolve(in environment: EnvironmentValues) -> ResolvedValue {
var resolved = parent.resolve(in: environment)
resolved.insertionAnimation = animation
resolved.removalAnimation = animation
return resolved
}
}

View File

@ -0,0 +1,61 @@
// Copyright 2020 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 7/10/21.
//
public protocol _ViewTraitKey {
associatedtype Value
static var defaultValue: Value { get }
}
public protocol _TraitWritingModifierProtocol {
func modifyViewTraitStore(_ viewTraitStore: inout _ViewTraitStore)
}
@frozen public struct _TraitWritingModifier<Trait>: ViewModifier, _TraitWritingModifierProtocol
where Trait: _ViewTraitKey
{
public let value: Trait.Value
@inlinable
public init(value: Trait.Value) {
self.value = value
}
public func body(content: Content) -> some View {
content
}
public func modifyViewTraitStore(_ viewTraitStore: inout _ViewTraitStore) {
viewTraitStore.insert(value, forKey: Trait.self)
}
}
extension ModifiedContent: _TraitWritingModifierProtocol
where Modifier: _TraitWritingModifierProtocol
{
public func modifyViewTraitStore(_ viewTraitStore: inout _ViewTraitStore) {
modifier.modifyViewTraitStore(&viewTraitStore)
}
}
public extension View {
@inlinable
func _trait<K>(
_ key: K.Type,
_ value: K.Value
) -> some View where K: _ViewTraitKey {
modifier(_TraitWritingModifier<K>(value: value))
}
}

View File

@ -0,0 +1,36 @@
// Copyright 2020 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 7/10/21.
//
public struct _ViewTraitStore {
public var values = [String: Any]()
public init(values: [String: Any] = [:]) {
self.values = values
}
public func value<Key>(forKey key: Key.Type = Key.self) -> Key.Value
where Key: _ViewTraitKey
{
values[String(reflecting: key)] as? Key.Value ?? Key.defaultValue
}
public mutating func insert<Key>(_ value: Key.Value, forKey key: Key.Type = Key.self)
where Key: _ViewTraitKey
{
values[String(reflecting: key)] = value
}
}

View File

@ -189,6 +189,8 @@ public typealias EmptyAnimatableData = TokamakCore.EmptyAnimatableData
public typealias AnimatableModifier = TokamakCore.AnimatableModifier
public typealias AnyTransition = TokamakCore.AnyTransition
public func withTransaction<Result>(
_ transaction: Transaction,
_ body: () throws -> Result

View File

@ -54,119 +54,24 @@ extension _AnimationBoxBase._Resolved._RepeatStyle {
}
extension AnyHTML {
func update(dom: DOMNode, transaction: Transaction) {
// FIXME: is there a sensible way to diff attributes and listeners to avoid
// crossing the JavaScript bridge and touching DOM if not needed?
func update(
dom: DOMNode,
computeStart: Bool = true,
additionalAttributes: [HTMLAttribute: String],
transaction: Transaction
) {
let attributes = self.attributes.merging(additionalAttributes, uniquingKeysWith: +)
// @carson-katri: For diffing, could you build a Set from the keys and values of the dictionary,
// then use the standard lib to get the difference?
dom.applyAttributes(attributes, with: transaction)
// `checked` attribute on checkboxes is a special one as its value doesn't matter. We only
// need to check whether it exists or not, and set the property if it doesn't.
var containsChecked = false
for (attribute, value) in attributes {
// Animate styles with the Web Animations API further down.
guard transaction.animation == nil || attribute != "style"
else { continue }
if attribute == "style" { // Clear animations
dom.ref.getAnimations?().array?.forEach { _ = $0.cancel() }
}
if attribute.isUpdatedAsProperty {
dom.ref[dynamicMember: attribute.value] = .string(value)
} else {
_ = dom.ref.setAttribute!(attribute.value, value)
}
if attribute == .checked {
containsChecked = true
}
}
// Animate styles
if let style = attributes["style"],
let animation = transaction.animation
if !transaction.disablesAnimations,
let animation = transaction.animation,
let style = attributes["style"]
{
let resolved = _AnimationProxy(animation).resolve()
func extractStyles(compute: Bool = false) -> [String: String] {
var res = [String: String]()
let computedStyle = JSObject.global.getComputedStyle?(dom.ref)
for i in 0..<Int(dom.ref.style.object?.length.number ?? 0) {
guard let key = dom.ref.style.object?[i].string else { continue }
if compute {
res[key] = computedStyle?[dynamicMember: key].string
?? dom.ref.style.object?[key].string
} else {
res[key] = dom.ref.style.object?[key].string
}
}
return res
}
let startStyle = extractStyles(compute: true).jsValue()
dom.ref.style.object?.cssText = .string(style)
let endStyle = Dictionary(uniqueKeysWithValues: extractStyles().map {
($0.animatableProperty, $1)
})
let duration = (resolved.duration / resolved.speed) * 1000
let delay = resolved.delay * 1000
let keyframes: [JSValue]
if case let .solver(solver) = resolved.style {
// Compute styles at several intervals.
var values = [[String: String]]()
for iterationStart in stride(from: 0, to: 1, by: 0.01) {
// Create and immediately cancel an animation after reading the computed values.
if let animation = dom.ref.animate?(
[startStyle, endStyle.jsValue()],
[
"duration": duration,
"delay": delay,
"easing": "linear",
"iterationStart": iterationStart,
]
).object,
let computedStyle = JSObject.global.getComputedStyle?(dom.ref)
{
var styles = [String: String]()
for key in endStyle.keys {
if let string = computedStyle[dynamicMember: key].string {
styles[key] = string
}
}
values.append(styles)
_ = animation.cancel?()
}
}
// Solve the values
keyframes = (0..<values.count).map { t in
let offset = Double(t) / Double(values.count - 1)
let solved = solver.solve(at: offset * (duration / 1000)) * Double(values.count - 1)
var res = values[Int(solved)]
res["offset"] = "\(offset)"
return res.jsValue()
} + [endStyle.jsValue()] // Add the end for good measure.
} else {
keyframes = [startStyle, endStyle.jsValue()]
}
// Animate the styles.
_ = dom.ref.animate?(
keyframes.jsValue(),
[
"duration": duration,
"delay": delay,
"easing": resolved.style.cssValue,
"iterations": resolved.repeatStyle.jsValue,
"direction": resolved.repeatStyle.autoreverses ? "alternate" : "normal",
// Keep the last keyframe applied when done, and the first applied during a delay.
"fill": "both",
]
)
dom.animateStyles(to: style, computeStart: computeStart, with: animation)
}
if !containsChecked && dom.ref.type == "checkbox" &&
if attributes[.checked] == nil && dom.ref.type == "checkbox" &&
dom.ref.tagName.string!.lowercased() == "input"
{
dom.ref.checked = .boolean(false)
@ -217,4 +122,123 @@ final class DOMNode: Target {
self.listeners[event] = jsClosure
}
}
func applyAttributes(
_ attributes: [HTMLAttribute: String],
with transaction: Transaction
) {
// FIXME: is there a sensible way to diff attributes and listeners to avoid
// crossing the JavaScript bridge and touching DOM if not needed?
// @carson-katri: For diffing, could you build a Set from the keys and values of the dictionary,
// then use the standard lib to get the difference?
// `checked` attribute on checkboxes is a special one as its value doesn't matter. We only
// need to check whether it exists or not, and set the property if it doesn't.
for (attribute, value) in attributes {
// Animate styles with the Web Animations API in `animateStyles`.
guard transaction.disablesAnimations
|| transaction.animation == nil
|| attribute != "style"
else { continue }
if attribute == "style" { // Clear animations
ref.getAnimations?().array?.forEach { _ = $0.cancel() }
}
if attribute.isUpdatedAsProperty {
ref[dynamicMember: attribute.value] = .string(value)
} else {
_ = ref.setAttribute!(attribute.value, value)
}
}
}
func extractStyles(compute: Bool = false) -> [String: String] {
var res = [String: String]()
let computedStyle = JSObject.global.getComputedStyle?(ref)
for i in 0..<Int(ref.style.object?.length.number ?? 0) {
guard let key = ref.style.object?[i].string else { continue }
if compute {
res[key] = computedStyle?[dynamicMember: key].string
?? ref.style.object?[key].string
} else {
res[key] = ref.style.object?[key].string
}
}
return res
}
@discardableResult
func animate(
keyframes: [JSValue],
with animation: Animation,
offsetBy iterationStart: Double = 0
) -> JSValue? {
let resolved = _AnimationProxy(animation).resolve()
return ref.animate?(
keyframes.jsValue(),
[
"duration": (resolved.duration / resolved.speed) * 1000,
"delay": resolved.delay * 1000,
"easing": resolved.style.cssValue,
"iterations": resolved.repeatStyle.jsValue,
"direction": resolved.repeatStyle.autoreverses ? "alternate" : "normal",
// Keep the last keyframe applied when done, and the first applied during a delay.
"fill": "both",
"iterationStart": iterationStart,
]
)
}
func animateStyles(
to style: String,
computeStart: Bool,
with animation: Animation
) {
let resolved = _AnimationProxy(animation).resolve()
let startStyle = Dictionary(uniqueKeysWithValues: extractStyles(compute: computeStart).map {
($0.animatableProperty, $1)
}).jsValue()
ref.style.object?.cssText = .string(style)
let endStyle = Dictionary(uniqueKeysWithValues: extractStyles().map {
($0.animatableProperty, $1)
})
let keyframes: [JSValue]
if case let .solver(solver) = resolved.style {
// Compute styles at several intervals.
var values = [[String: String]]()
for iterationStart in stride(from: 0, to: 1, by: 0.01) {
// Create and immediately cancel an animation after reading the computed values.
if let animation = animate(
keyframes: [startStyle, endStyle.jsValue()],
with: Animation.linear(duration: resolved.duration).delay(resolved.delay),
offsetBy: iterationStart
)?.object,
let computedStyle = JSObject.global.getComputedStyle?(ref)
{
values.append(Dictionary(
uniqueKeysWithValues: endStyle.keys
.compactMap { k in computedStyle[dynamicMember: k].string.map { (k, $0) } }
))
_ = animation.cancel?()
}
}
// Solve the values
keyframes = (0..<values.count).map { t in
let offset = Double(t) / Double(values.count - 1)
let solved = solver.solve(at: offset * (resolved.duration / resolved.speed))
* Double(values.count - 1)
var res = values[Int(solved)]
res["offset"] = "\(offset)"
return res.jsValue()
} + [endStyle.jsValue()] // Add the end for good measure.
} else {
keyframes = [startStyle, endStyle.jsValue()]
}
// Animate the styles.
animate(keyframes: keyframes, with: animation)
}
}

View File

@ -110,10 +110,7 @@ final class DOMRenderer: Renderer {
to parent: DOMNode,
with host: MountedHost
) -> DOMNode? {
guard let anyHTML = mapAnyView(
host.view,
transform: { (html: AnyHTML) in html }
) else {
guard let anyHTML: AnyHTML = mapAnyView(host.view, transform: { $0 }) else {
// handle `GroupView` cases (such as `TupleView`, `Group` etc)
if mapAnyView(host.view, transform: { (view: ParentView) in view }) != nil {
return parent
@ -122,17 +119,49 @@ final class DOMRenderer: Renderer {
return nil
}
// Transition the insertion.
let transition = _AnyTransitionProxy(host.viewTraits.transition)
.resolve(in: host.environmentValues)
var additionalAttributes = [HTMLAttribute: String]()
var runTransition: ((DOMNode) -> ())?
if host.viewTraits.canTransition,
let animation = transition.insertionAnimation ?? host.transaction.animation
{
// Apply the active insertion modifier on mount.
additionalAttributes = apply(
transition: transition, \.insertion,
as: \.active,
to: host.view
)
runTransition = { node in
anyHTML.update(
dom: node,
computeStart: false,
additionalAttributes: self.apply(
transition: transition, \.insertion,
as: \.identity,
to: host.view
),
transaction: .init(animation: animation)
)
}
}
let maybeNode: JSObject?
if let sibling = sibling {
_ = sibling.ref.insertAdjacentHTML!(
"beforebegin",
anyHTML.outerHTML(shouldSortAttributes: false, children: [])
anyHTML.outerHTML(
shouldSortAttributes: false, additonalAttributes: additionalAttributes, children: []
)
)
maybeNode = sibling.ref.previousSibling.object
} else {
_ = parent.ref.insertAdjacentHTML!(
"beforeend",
anyHTML.outerHTML(shouldSortAttributes: false, children: [])
anyHTML.outerHTML(
shouldSortAttributes: false, additonalAttributes: additionalAttributes, children: []
)
)
guard
@ -148,18 +177,21 @@ final class DOMRenderer: Renderer {
fixSpacers(host: host, target: resultingNode)
if let dynamicHTML = anyHTML as? AnyDynamicHTML {
return DOMNode(host.view, resultingNode, dynamicHTML.listeners)
} else {
return DOMNode(host.view, resultingNode, [:])
}
let node = DOMNode(host.view, resultingNode, (anyHTML as? AnyDynamicHTML)?.listeners ?? [:])
runTransition?(node)
return node
}
func update(target: DOMNode, with host: MountedHost) {
guard let html = mapAnyView(host.view, transform: { (html: AnyHTML) in html })
else { return }
html.update(dom: target, transaction: host.transaction)
html.update(
dom: target,
additionalAttributes: [:],
transaction: host.transaction
)
fixSpacers(host: host, target: target.ref)
}
@ -167,15 +199,55 @@ final class DOMRenderer: Renderer {
func unmount(
target: DOMNode,
from parent: DOMNode,
with host: MountedHost,
completion: @escaping () -> ()
with task: UnmountHostTask<DOMRenderer>
) {
defer { completion() }
guard let anyHTML = mapAnyView(task.host.view, transform: { (html: AnyHTML) in html })
else { return task.finish() }
guard mapAnyView(host.view, transform: { (html: AnyHTML) in html }) != nil
else { return }
// Transition the removal.
let transition = _AnyTransitionProxy(task.host.viewTraits.transition)
.resolve(in: task.host.environmentValues)
if task.host.viewTraits.canTransition,
let animation = transition.removalAnimation ?? task.host.transaction.animation
{
// First, apply the identity removal modifier /without/ animation
// to be in the initial state.
anyHTML.update(
dom: target,
additionalAttributes: apply(
transition: transition, \.removal,
as: \.identity,
to: task.host.view
),
transaction: .init(animation: nil)
)
// Then apply the active removal modifier /with/ animation.
anyHTML.update(
dom: target,
additionalAttributes: apply(
transition: transition, \.removal,
as: \.active,
to: task.host.view
),
transaction: .init(animation: animation)
)
_ = JSObject.global.setTimeout!(
JSOneshotClosure { _ in
guard !task.isCancelled else { return .undefined }
_ = try? parent.ref.throwing.removeChild!(target.ref)
task.finish()
return .undefined
},
_AnimationProxy(animation).resolve().duration * 1000
)
return
}
_ = try? parent.ref.throwing.removeChild!(target.ref)
task.finish()
}
func primitiveBody(for view: Any) -> AnyView? {
@ -185,6 +257,33 @@ final class DOMRenderer: Renderer {
func isPrimitiveView(_ type: Any.Type) -> Bool {
type is DOMPrimitive.Type || type is _HTMLPrimitive.Type
}
private func apply(
transition: _AnyTransitionBox.ResolvedTransition,
_ direction: KeyPath<
_AnyTransitionBox.ResolvedTransition,
[_AnyTransitionBox.ResolvedTransition.Transition]
>,
as state: KeyPath<
_AnyTransitionBox.ResolvedTransition.Transition,
(AnyView) -> AnyView
>,
to view: AnyView
) -> [HTMLAttribute: String] {
transition[keyPath: direction].reduce([HTMLAttribute: String]()) {
if let modifiedContent = mapAnyView(
$1[keyPath: state](view),
transform: { (v: _AnyModifiedContent) in v }
) {
return $0.merging(
modifiedContent.anyModifier.attributes,
uniquingKeysWith: +
)
} else {
return $0
}
}
}
}
protocol DOMPrimitive {

View File

@ -0,0 +1,35 @@
// Copyright 2020 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 7/13/21.
//
import TokamakCore
import TokamakStaticHTML
extension _MoveTransition: DOMViewModifier {
public var attributes: [HTMLAttribute: String] {
let offset: (String, String)
switch edge {
case .leading: offset = ("-100%", "0%")
case .trailing: offset = ("100%", "0%")
case .top: offset = ("0%", "-100%")
case .bottom: offset = ("0%", "100%")
}
return [
"style":
"transform: translate(\(isActive ? offset.0 : "0%"), \(isActive ? offset.1 : "0%"));",
]
}
}

View File

@ -137,6 +137,7 @@ struct TokamakDemoView: View {
}
Section(header: Text("Misc")) {
NavItem("Animation", destination: AnimationDemo())
NavItem("Transitions", destination: TransitionDemo())
NavItem("Path", destination: PathDemo())
NavItem("ProgressView", destination: ProgressViewDemo())
NavItem("Environment", destination: EnvironmentDemo().font(.system(size: 8)))

View File

@ -0,0 +1,76 @@
// Copyright 2019-2020 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 7/13/21.
//
import TokamakShim
private struct ColorOverlayModifier: ViewModifier {
var color: Color
func body(content: Content) -> some View {
content
.background(color)
}
}
struct TransitionDemo: View {
@State private var isVisible = false
@State private var isInnerVisible = false
var body: some View {
VStack {
Button(isVisible ? "Hide" : "Show") {
withAnimation(.easeInOut(duration: 3)) {
isVisible.toggle()
}
}
if isVisible {
Text(".opacity")
.transition(AnyTransition.opacity)
Text(".offset(x: 100, y: 100)")
.transition(AnyTransition.offset(x: 100, y: 100))
Text(".move(edge: .leading)")
.transition(AnyTransition.move(edge: .leading))
Text(".slide")
.transition(AnyTransition.slide)
Text(".scale")
.transition(AnyTransition.scale)
Text(".opacity/.scale")
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .scale))
Text(".opacity + .slide")
.transition(AnyTransition.opacity.combined(with: .slide))
Text(".modifier")
.transition(AnyTransition.modifier(
active: ColorOverlayModifier(color: .red),
identity: ColorOverlayModifier(color: .clear)
))
Text(".animation")
.transition(AnyTransition.scale.animation(.spring()))
VStack {
Text("Grouped Transition")
Button(isInnerVisible ? "Hide Inner" : "Show Inner") {
withAnimation(.easeInOut(duration: 3)) { isInnerVisible.toggle() }
}
Text(".slide").transition(AnyTransition.slide)
if isInnerVisible {
Text(".slide").transition(AnyTransition.slide)
}
}
.transition(AnyTransition.slide)
}
}
}
}

View File

@ -123,12 +123,11 @@ final class GTKRenderer: Renderer {
func unmount(
target: Widget,
from parent: Widget,
with host: MountedHost,
completion: @escaping () -> ()
with task: UnmountHostTask<GTKRenderer>
) {
defer { completion() }
defer { task.finish() }
guard mapAnyView(host.view, transform: { (widget: AnyWidget) in widget }) != nil
guard mapAnyView(task.host.view, transform: { (widget: AnyWidget) in widget }) != nil
else { return }
target.destroy()

View File

@ -14,17 +14,17 @@
import TokamakCore
private protocol AnyModifiedContent {
public protocol _AnyModifiedContent {
var anyContent: AnyView { get }
var anyModifier: DOMViewModifier { get }
}
extension ModifiedContent: AnyModifiedContent where Modifier: DOMViewModifier, Content: View {
var anyContent: AnyView {
extension ModifiedContent: _AnyModifiedContent where Modifier: DOMViewModifier, Content: View {
public var anyContent: AnyView {
AnyView(content)
}
var anyModifier: DOMViewModifier {
public var anyModifier: DOMViewModifier {
modifier
}
}
@ -33,7 +33,7 @@ extension ModifiedContent: _HTMLPrimitive where Content: View, Modifier: ViewMod
@_spi(TokamakStaticHTML)
public var renderedBody: AnyView {
if let domModifier = modifier as? DOMViewModifier {
if let adjacentModifier = content as? AnyModifiedContent,
if let adjacentModifier = content as? _AnyModifiedContent,
!(adjacentModifier.anyModifier.isOrderDependent || domModifier.isOrderDependent)
{
// Flatten non-order-dependent modifiers

View File

@ -48,7 +48,7 @@ extension _BackgroundStyleModifier: DOMViewModifier {
]
} else if let color = resolved.color(at: 0) {
return [
"style": "background-color: \(color.cssValue(environment))",
"style": "background-color: \(color.cssValue(environment));",
]
}
}

View File

@ -136,8 +136,7 @@ public final class StaticHTMLRenderer: Renderer {
public func unmount(
target: HTMLTarget,
from parent: HTMLTarget,
with host: MountedHost,
completion: @escaping () -> ()
with host: UnmountHostTask<StaticHTMLRenderer>
) {
fatalError("Stateful apps cannot be created with TokamakStaticHTML")
}

View File

@ -53,7 +53,12 @@ public protocol AnyHTML {
}
public extension AnyHTML {
func outerHTML(shouldSortAttributes: Bool, children: [HTMLTarget]) -> String {
func outerHTML(
shouldSortAttributes: Bool,
additonalAttributes: [HTMLAttribute: String] = [:],
children: [HTMLTarget]
) -> String {
let attributes = self.attributes.merging(additonalAttributes, uniquingKeysWith: +)
let renderedAttributes: String
if attributes.isEmpty {
renderedAttributes = ""

View File

@ -35,12 +35,12 @@ struct _HTMLImage: View {
case let .named(name, bundle):
attributes = [
"src": bundle?.path(forResource: name, ofType: nil) ?? name,
"style": "max-width: 100%; max-height: 100%",
"style": "max-width: 100%; max-height: 100%;",
]
case let .resizable(.named(name, bundle), _, _):
attributes = [
"src": bundle?.path(forResource: name, ofType: nil) ?? name,
"style": "width: 100%; height: 100%",
"style": "width: 100%; height: 100%;",
]
default: break
}

View File

@ -39,7 +39,7 @@ extension HStack: _HTMLPrimitive, SpacerContainer {
align-items: \(alignment.cssValue);
\(hasSpacer ? "width: 100%;" : "")
\(fillCrossAxis ? "height: 100%;" : "")
\(spacing != defaultStackSpacing ? "--tokamak-stack-gap: \(spacing)px" : "")
\(spacing != defaultStackSpacing ? "--tokamak-stack-gap: \(spacing)px;" : "")
""",
"class": "_tokamak-stack _tokamak-hstack",
]) { content })

View File

@ -39,7 +39,7 @@ extension VStack: _HTMLPrimitive, SpacerContainer {
justify-items: \(alignment.cssValue);
\(hasSpacer ? "height: 100%;" : "")
\(fillCrossAxis ? "width: 100%;" : "")
\(spacing != defaultStackSpacing ? "--tokamak-stack-gap: \(spacing)px" : "")
\(spacing != defaultStackSpacing ? "--tokamak-stack-gap: \(spacing)px;" : "")
""",
"class": "_tokamak-stack _tokamak-vstack",
]) { content })

View File

@ -31,7 +31,7 @@ extension ZStack: _HTMLPrimitive {
grid-template-columns: 1fr;
width: fit-content;
justify-items: \(alignment.horizontal.cssValue);
align-items: \(alignment.vertical.cssValue)
align-items: \(alignment.vertical.cssValue);
""",
]) {
TupleView(

View File

@ -60,7 +60,7 @@ extension Spacer: _HTMLPrimitive {
@_spi(TokamakStaticHTML)
public var renderedBody: AnyView {
AnyView(HTML("div", [
"style": "flex-grow: 1; \(minLength != nil ? "min-width: \(minLength!)" : "")",
"style": "flex-grow: 1; \(minLength != nil ? "min-width: \(minLength!);" : "")",
]))
}
}

View File

@ -68,10 +68,10 @@ public final class TestRenderer: Renderer {
public func unmount(
target: TestView,
from parent: TestView,
with mountedHost: TestRenderer.MountedHost,
completion: () -> ()
with task: UnmountHostTask<TestRenderer>
) {
target.removeFromSuperview()
task.finish()
}
public func primitiveBody(for view: Any) -> AnyView? {