diff --git a/NativeDemo/TokamakDemo.xcodeproj/project.pbxproj b/NativeDemo/TokamakDemo.xcodeproj/project.pbxproj index 5cb32e17e..b164c8fdf 100644 --- a/NativeDemo/TokamakDemo.xcodeproj/project.pbxproj +++ b/NativeDemo/TokamakDemo.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 207C05712610E16E00BBBE54 /* DatePickerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */; }; 262DA7B32695D99500CABEAE /* ShapeStyleDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */; }; 262DA7B42695D99500CABEAE /* ShapeStyleDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */; }; + 26A3BFB0269BD18A0004DA16 /* AnimationDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */; }; + 26A3BFB1269BD18A0004DA16 /* AnimationDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */; }; 3DCDE44424CA6AD400910F17 /* SidebarDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */; }; 3DCDE44524CA6AD400910F17 /* SidebarDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */; }; 4550BD5225B642B80088F4EA /* ShadowDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4550BD5125B642B80088F4EA /* ShadowDemo.swift */; }; @@ -100,6 +102,7 @@ /* Begin PBXFileReference section */ 207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatePickerDemo.swift; sourceTree = ""; }; 262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShapeStyleDemo.swift; sourceTree = ""; }; + 26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimationDemo.swift; sourceTree = ""; }; 3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SidebarDemo.swift; sourceTree = ""; }; 4550BD5125B642B80088F4EA /* ShadowDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowDemo.swift; sourceTree = ""; }; 8500293E24D2FF3E001A2E84 /* SliderDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderDemo.swift; sourceTree = ""; }; @@ -194,10 +197,9 @@ 85ED189924AD425E0085DFA0 /* TokamakDemo */ = { isa = PBXGroup; children = ( + 26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */, 262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */, D120FDDA257E7145008FFBAD /* TextEditorDemo.swift */, - D1D6B62224D817350041E1D9 /* GeometryReaderDemo.swift */, - D1C726F224CB63C6003B576D /* ButtonStyleDemo.swift */, B5C76E4924C73ED4003EABB2 /* AppStorageDemo.swift */, D1C726F224CB63C6003B576D /* ButtonStyleDemo.swift */, B56F22DF24BC89FD001738DF /* ColorDemo.swift */, @@ -372,6 +374,7 @@ D1D6B62324D817350041E1D9 /* GeometryReaderDemo.swift in Sources */, B5DBA22B24D509B4003D3347 /* RedactDemo.swift in Sources */, B56F22E024BC89FD001738DF /* ColorDemo.swift in Sources */, + 26A3BFB0269BD18A0004DA16 /* AnimationDemo.swift in Sources */, B51F215024B920B400CF2583 /* PathDemo.swift in Sources */, 85ED18AF24AD425E0085DFA0 /* EnvironmentDemo.swift in Sources */, 85ED18A324AD425E0085DFA0 /* SpacerDemo.swift in Sources */, @@ -406,6 +409,7 @@ D1B4229324B3B9BB00682F74 /* OutlineGroupDemo.swift in Sources */, D1D6B62424D817350041E1D9 /* GeometryReaderDemo.swift in Sources */, B5DBA22C24D509B4003D3347 /* RedactDemo.swift in Sources */, + 26A3BFB1269BD18A0004DA16 /* AnimationDemo.swift in Sources */, B56F22E124BC89FD001738DF /* ColorDemo.swift in Sources */, B51F215124B920B400CF2583 /* PathDemo.swift in Sources */, 85ED18A424AD425E0085DFA0 /* SpacerDemo.swift in Sources */, diff --git a/Sources/TokamakCore/Animation/Animatable.swift b/Sources/TokamakCore/Animation/Animatable.swift new file mode 100644 index 000000000..c93ebd887 --- /dev/null +++ b/Sources/TokamakCore/Animation/Animatable.swift @@ -0,0 +1,153 @@ +// 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/11/21. +// + +import Foundation + +public protocol Animatable { + associatedtype AnimatableData: VectorArithmetic + var animatableData: Self.AnimatableData { get set } +} + +public protocol _PrimitiveAnimatable {} + +public extension Animatable where Self: VectorArithmetic { + var animatableData: Self { + get { self } + // swiftlint:disable:next unused_setter_value + set {} + } +} + +public extension Animatable where Self.AnimatableData == EmptyAnimatableData { + var animatableData: EmptyAnimatableData { + @inlinable get { EmptyAnimatableData() } + // swiftlint:disable:next unused_setter_value + @inlinable set {} + } +} + +@frozen public struct EmptyAnimatableData: VectorArithmetic { + @inlinable + public init() {} + @inlinable public static var zero: Self { .init() } + @inlinable + public static func += (lhs: inout Self, rhs: Self) {} + @inlinable + public static func -= (lhs: inout Self, rhs: Self) {} + @inlinable + public static func + (lhs: Self, rhs: Self) -> Self { + .zero + } + + @inlinable + public static func - (lhs: Self, rhs: Self) -> Self { + .zero + } + + @inlinable + public mutating func scale(by rhs: Double) {} + @inlinable public var magnitudeSquared: Double { .zero } + public static func == (a: Self, b: Self) -> Bool { true } +} + +@frozen public struct AnimatablePair: VectorArithmetic + where First: VectorArithmetic, Second: VectorArithmetic +{ + public var first: First + public var second: Second + @inlinable + public init(_ first: First, _ second: Second) { + self.first = first + self.second = second + } + + @inlinable + internal subscript() -> (First, Second) { + get { (first, second) } + set { (first, second) = newValue } + } + + @_transparent public static var zero: Self { + @_transparent get { + .init(First.zero, Second.zero) + } + } + + @_transparent + public static func += (lhs: inout Self, rhs: Self) { + lhs.first += rhs.first + lhs.second += rhs.second + } + + @_transparent + public static func -= (lhs: inout Self, rhs: Self) { + lhs.first -= rhs.first + lhs.second -= rhs.second + } + + @_transparent + public static func + (lhs: Self, rhs: Self) -> Self { + .init(lhs.first + rhs.first, lhs.second + rhs.second) + } + + @_transparent + public static func - (lhs: Self, rhs: Self) -> Self { + .init(lhs.first - rhs.first, lhs.second - rhs.second) + } + + @_transparent + public mutating func scale(by rhs: Double) { + first.scale(by: rhs) + second.scale(by: rhs) + } + + @_transparent public var magnitudeSquared: Double { + @_transparent get { + first.magnitudeSquared + second.magnitudeSquared + } + } + + public static func == (a: Self, b: Self) -> Bool { + a.first == b.first + && a.second == b.second + } +} + +extension CGPoint: Animatable { + public var animatableData: AnimatablePair { + @inlinable get { .init(x, y) } + @inlinable set { (x, y) = newValue[] } + } +} + +extension CGSize: Animatable { + public var animatableData: AnimatablePair { + @inlinable get { .init(width, height) } + @inlinable set { (width, height) = newValue[] } + } +} + +extension CGRect: Animatable { + public var animatableData: AnimatablePair { + @inlinable get { + .init(origin.animatableData, size.animatableData) + } + @inlinable set { + (origin.animatableData, size.animatableData) = newValue[] + } + } +} diff --git a/Sources/TokamakCore/Animation/AnimatableModifier.swift b/Sources/TokamakCore/Animation/AnimatableModifier.swift new file mode 100644 index 000000000..9fa624ad3 --- /dev/null +++ b/Sources/TokamakCore/Animation/AnimatableModifier.swift @@ -0,0 +1,18 @@ +// 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/11/21. +// + +public protocol AnimatableModifier: Animatable, ViewModifier {} diff --git a/Sources/TokamakCore/Animation/Animation.swift b/Sources/TokamakCore/Animation/Animation.swift new file mode 100644 index 000000000..2213f3f02 --- /dev/null +++ b/Sources/TokamakCore/Animation/Animation.swift @@ -0,0 +1,216 @@ +// 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. + +import Foundation + +/// This default is specified in SwiftUI on `Animation.timingCurve` as `0.35`. +public let defaultDuration = 0.35 + +public struct Animation: Equatable { + fileprivate var box: _AnimationBoxBase + + private init(_ box: _AnimationBoxBase) { + self.box = box + } + + public static let `default` = Self.easeInOut + + public func delay(_ delay: Double) -> Animation { + .init(DelayedAnimationBox(delay: delay, parent: box)) + } + + public func speed(_ speed: Double) -> Animation { + .init(RetimedAnimationBox(speed: speed, parent: box)) + } + + public func repeatCount( + _ repeatCount: Int, + autoreverses: Bool = true + ) -> Animation { + .init(RepeatedAnimationBox(style: .fixed(repeatCount, autoreverses: autoreverses), parent: box)) + } + + public func repeatForever(autoreverses: Bool = true) -> Animation { + .init(RepeatedAnimationBox(style: .forever(autoreverses: autoreverses), parent: box)) + } + + public static func spring( + response: Double = 0.55, + dampingFraction: Double = 0.825, + blendDuration: Double = 0 + ) -> Animation { + if response == 0 { // Infinitely stiff spring + // (well, not .infinity, but a very high number) + return interpolatingSpring(stiffness: 999, damping: 999) + } else { + return interpolatingSpring( + mass: 1, + stiffness: pow(2 * .pi / response, 2), + damping: 4 * .pi * dampingFraction / response + ) + } + } + + public static func interactiveSpring( + response: Double = 0.15, + dampingFraction: Double = 0.86, + blendDuration: Double = 0.25 + ) -> Animation { + spring( + response: response, + dampingFraction: dampingFraction, + blendDuration: blendDuration + ) + } + + public static func interpolatingSpring( + mass: Double = 1.0, + stiffness: Double, + damping: Double, + initialVelocity: Double = 0.0 + ) -> Animation { + .init(StyleAnimationBox(style: .solver(_AnimationSolvers.Spring( + mass: mass, + stiffness: stiffness, + damping: damping, + initialVelocity: initialVelocity + )))) + } + + public static func easeInOut(duration: Double) -> Animation { + timingCurve(0.42, 0, 0.58, 1.0, duration: duration) + } + + public static var easeInOut: Animation { + easeInOut(duration: defaultDuration) + } + + public static func easeIn(duration: Double) -> Animation { + timingCurve(0.42, 0, 1.0, 1.0, duration: duration) + } + + public static var easeIn: Animation { + easeIn(duration: defaultDuration) + } + + public static func easeOut(duration: Double) -> Animation { + timingCurve(0, 0, 0.58, 1.0, duration: duration) + } + + public static var easeOut: Animation { + easeOut(duration: defaultDuration) + } + + public static func linear(duration: Double) -> Animation { + timingCurve(0, 0, 1, 1, duration: duration) + } + + public static var linear: Animation { + timingCurve(0, 0, 1, 1) + } + + public static func timingCurve( + _ c0x: Double, + _ c0y: Double, + _ c1x: Double, + _ c1y: Double, + duration: Double = defaultDuration + ) -> Animation { + .init(StyleAnimationBox(style: .timingCurve(c0x, c0y, c1x, c1y, duration: duration))) + } +} + +public struct _AnimationProxy { + let subject: Animation + + public init(_ subject: Animation) { self.subject = subject } + + public func resolve() -> _AnimationBoxBase._Resolved { subject.box.resolve() } +} + +@frozen public struct _AnimationModifier: ViewModifier, Equatable + where Value: Equatable +{ + public var animation: Animation? + public var value: Value + + @inlinable + public init(animation: Animation?, value: Value) { + self.animation = animation + self.value = value + } + + private struct ContentWrapper: View, Equatable { + let content: Content + let animation: Animation? + let value: Value + @State private var lastValue: Value? + + var body: some View { + content.transaction { + if lastValue != value { + $0.animation = animation + } + } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } + } + + public func body(content: Content) -> some View { + ContentWrapper(content: content, animation: animation, value: value) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + && lhs.animation == rhs.animation + } +} + +@frozen public struct _AnimationView: View + where Content: Equatable, Content: View +{ + public var content: Content + public var animation: Animation? + + @inlinable + public init(content: Content, animation: Animation?) { + self.content = content + self.animation = animation + } + + public var body: some View { + content + .modifier(_AnimationModifier(animation: animation, value: content)) + } +} + +public extension View { + @inlinable + func animation( + _ animation: Animation?, + value: V + ) -> some View where V: Equatable { + modifier(_AnimationModifier(animation: animation, value: value)) + } +} + +public extension View where Self: Equatable { + @inlinable + func animation(_ animation: Animation?) -> some View { + _AnimationView(content: self, animation: animation) + } +} diff --git a/Sources/TokamakCore/Animation/Transaction.swift b/Sources/TokamakCore/Animation/Transaction.swift new file mode 100644 index 000000000..a47fe2c38 --- /dev/null +++ b/Sources/TokamakCore/Animation/Transaction.swift @@ -0,0 +1,125 @@ +// 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. + +public struct Transaction { + /// The overriden transaction for a state change in a `withTransaction` block. + /// Is always set back to `nil` when the block exits. + static var _active: Self? + + public var animation: Animation? + + /** `true` in the first part of the transition update, this avoids situations when `animation(_:)` + could add more animations to this transaction. + */ + public var disablesAnimations: Bool + + public init(animation: Animation?) { + self.animation = animation + disablesAnimations = true + } +} + +public func withTransaction( + _ transaction: Transaction, + _ body: () throws -> Result +) rethrows -> Result { + Transaction._active = transaction + defer { Transaction._active = nil } + return try body() +} + +public func withAnimation( + _ animation: Animation? = .default, + _ body: () throws -> Result +) rethrows -> Result { + try withTransaction(.init(animation: animation), body) +} + +protocol _TransactionModifierProtocol { + func modifyTransaction(_ transaction: inout Transaction) +} + +@frozen public struct _TransactionModifier: ViewModifier { + public var transform: (inout Transaction) -> () + + @inlinable + public init(transform: @escaping (inout Transaction) -> ()) { + self.transform = transform + } + + public func body(content: Content) -> some View { + content + } +} + +extension _TransactionModifier: _TransactionModifierProtocol { + func modifyTransaction(_ transaction: inout Transaction) { + transform(&transaction) + } +} + +extension ModifiedContent: _TransactionModifierProtocol + where Modifier: _TransactionModifierProtocol +{ + func modifyTransaction(_ transaction: inout Transaction) { + modifier.modifyTransaction(&transaction) + } +} + +@frozen public struct _PushPopTransactionModifier: ViewModifier where V: ViewModifier { + public var content: V + public var base: _TransactionModifier + + @inlinable + public init( + content: V, + transform: @escaping (inout Transaction) -> () + ) { + self.content = content + base = .init(transform: transform) + } + + public func body(content: Content) -> some View { + content + .modifier(self.content) + .modifier(base) + } +} + +public extension View { + @inlinable + func transaction(_ transform: @escaping (inout Transaction) -> ()) -> some View { + modifier(_TransactionModifier(transform: transform)) + } +} + +public extension ViewModifier { + @inlinable + func transaction( + _ transform: @escaping (inout Transaction) -> () + ) -> some ViewModifier { + _PushPopTransactionModifier(content: self, transform: transform) + } + + @inlinable + func animation( + _ animation: Animation? + ) -> some ViewModifier { + transaction { t in + if !t.disablesAnimations { + t.animation = animation + } + } + } +} diff --git a/Sources/TokamakCore/Animation/VectorArithmetic.swift b/Sources/TokamakCore/Animation/VectorArithmetic.swift new file mode 100644 index 000000000..a8c06d9d3 --- /dev/null +++ b/Sources/TokamakCore/Animation/VectorArithmetic.swift @@ -0,0 +1,47 @@ +// 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/11/21. +// + +import Foundation + +public protocol VectorArithmetic: AdditiveArithmetic { + mutating func scale(by rhs: Double) + var magnitudeSquared: Double { get } +} + +extension Float: VectorArithmetic { + @_transparent + public mutating func scale(by rhs: Double) { self *= Float(rhs) } + @_transparent public var magnitudeSquared: Double { + @_transparent get { Double(self * self) } + } +} + +extension Double: VectorArithmetic { + @_transparent + public mutating func scale(by rhs: Double) { self *= rhs } + @_transparent public var magnitudeSquared: Double { + @_transparent get { self * self } + } +} + +extension CGFloat: VectorArithmetic { + @_transparent + public mutating func scale(by rhs: Double) { self *= CGFloat(rhs) } + @_transparent public var magnitudeSquared: Double { + @_transparent get { Double(self * self) } + } +} diff --git a/Sources/TokamakCore/Animation/_AnimationBoxBase.swift b/Sources/TokamakCore/Animation/_AnimationBoxBase.swift new file mode 100644 index 000000000..88d90e2d8 --- /dev/null +++ b/Sources/TokamakCore/Animation/_AnimationBoxBase.swift @@ -0,0 +1,164 @@ +// 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/11/21. +// + +import Foundation + +public class _AnimationBoxBase: Equatable { + public struct _Resolved { + public var duration: Double { + switch style { + case let .timingCurve(_, _, _, _, duration): + return duration + case let .solver(solver): + return solver.restingPoint(precision: 0.01) + } + } + + public var delay: Double + public var speed: Double + public var repeatStyle: _RepeatStyle + public var style: _Style + + public enum _Style: Equatable { + case timingCurve(Double, Double, Double, Double, duration: Double) + case solver(_AnimationSolver) + + public static func == (lhs: Self, rhs: Self) -> Bool { + switch lhs { + case let .timingCurve(lhs0, lhs1, lhs2, lhs3, lhsDuration): + if case let .timingCurve(rhs0, rhs1, rhs2, rhs3, rhsDuration) = rhs { + return lhs0 == rhs0 + && lhs1 == rhs1 + && lhs2 == rhs2 + && lhs3 == rhs3 + && lhsDuration == rhsDuration + } + case let .solver(lhsSolver): + if case let .solver(rhsSolver) = rhs { + return type(of: lhsSolver) == type(of: rhsSolver) + } + } + return false + } + } + + public enum _RepeatStyle: Equatable { + case fixed(Int, autoreverses: Bool) + case forever(autoreverses: Bool) + + public var autoreverses: Bool { + switch self { + case let .fixed(_, autoreverses), + let .forever(autoreverses): + return autoreverses + } + } + } + } + + func resolve() -> _Resolved { + fatalError("implement \(#function) in subclass") + } + + func equals(_ other: _AnimationBoxBase) -> Bool { + fatalError("implement \(#function) in subclass") + } + + public static func == (lhs: _AnimationBoxBase, rhs: _AnimationBoxBase) -> Bool { + lhs.equals(rhs) + } +} + +final class StyleAnimationBox: _AnimationBoxBase { + let style: _Resolved._Style + + init(style: _Resolved._Style) { + self.style = style + } + + override func resolve() -> _AnimationBoxBase._Resolved { + .init(delay: 0, speed: 1, repeatStyle: .fixed(1, autoreverses: true), style: style) + } + + override func equals(_ other: _AnimationBoxBase) -> Bool { + guard let other = other as? StyleAnimationBox else { return false } + return style == other.style + } +} + +final class DelayedAnimationBox: _AnimationBoxBase { + let delay: Double + let parent: _AnimationBoxBase + + init(delay: Double, parent: _AnimationBoxBase) { + self.delay = delay + self.parent = parent + } + + override func resolve() -> _AnimationBoxBase._Resolved { + var resolved = parent.resolve() + resolved.delay = delay + return resolved + } + + override func equals(_ other: _AnimationBoxBase) -> Bool { + guard let other = other as? DelayedAnimationBox else { return false } + return delay == other.delay && parent.equals(other.parent) + } +} + +final class RetimedAnimationBox: _AnimationBoxBase { + let speed: Double + let parent: _AnimationBoxBase + + init(speed: Double, parent: _AnimationBoxBase) { + self.speed = speed + self.parent = parent + } + + override func resolve() -> _AnimationBoxBase._Resolved { + var resolved = parent.resolve() + resolved.speed = speed + return resolved + } + + override func equals(_ other: _AnimationBoxBase) -> Bool { + guard let other = other as? RetimedAnimationBox else { return false } + return speed == other.speed && parent.equals(other.parent) + } +} + +final class RepeatedAnimationBox: _AnimationBoxBase { + let style: _AnimationBoxBase._Resolved._RepeatStyle + let parent: _AnimationBoxBase + + init(style: _AnimationBoxBase._Resolved._RepeatStyle, parent: _AnimationBoxBase) { + self.style = style + self.parent = parent + } + + override func resolve() -> _AnimationBoxBase._Resolved { + var resolved = parent.resolve() + resolved.repeatStyle = style + return resolved + } + + override func equals(_ other: _AnimationBoxBase) -> Bool { + guard let other = other as? RepeatedAnimationBox else { return false } + return style == other.style && parent.equals(other.parent) + } +} diff --git a/Sources/TokamakCore/Animation/_AnimationSolvers.swift b/Sources/TokamakCore/Animation/_AnimationSolvers.swift new file mode 100644 index 000000000..bf210e742 --- /dev/null +++ b/Sources/TokamakCore/Animation/_AnimationSolvers.swift @@ -0,0 +1,66 @@ +// 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/11/21. +// + +import Foundation + +/// A solver for an animation with a duration that depends on its properties. +public protocol _AnimationSolver { + /// Solve value at a specific point in time. + func solve(at t: Double) -> Double + /// Calculates the duration of the animation to a specific presision. + func restingPoint(precision y: Double) -> Double +} + +public enum _AnimationSolvers { + // swiftlint:disable line_length + /// Calculates the animation of a spring with certain properties. + /// + /// For some useful information, see + /// [Demystifying UIKit Spring Animations](https://medium.com/ios-os-x-development/demystifying-uikit-spring-animations-2bb868446773) + public struct Spring: _AnimationSolver { + // swiftlint:enable line_length + let ƛ: Double + let w0: Double + let wd: Double + /// Initial velocity + let v0: Double + /// Target value + let s0: Double = 1 + + public init(mass: Double, stiffness: Double, damping: Double, initialVelocity: Double) { + ƛ = (damping * 0.755) / (mass * 2) + w0 = sqrt(stiffness / 2) + wd = sqrt(abs(pow(w0, 2) - pow(ƛ, 2))) + v0 = initialVelocity + } + + public func solve(at t: Double) -> Double { + let y: Double + if ƛ < w0 { + y = pow(M_E, -(ƛ * t)) * ((s0 * cos(wd * t)) + ((v0 + s0) * sin(wd * t))) +// } else if ƛ > w0 { // Overdamping is unsupported on Apple platforms + } else { + y = pow(M_E, -(ƛ * t)) * (s0 + ((v0 + (ƛ * s0)) * t)) + } + return 1 - y + } + + public func restingPoint(precision y: Double) -> Double { + log(y) / -ƛ + } + } +} diff --git a/Sources/TokamakCore/Animation/_VectorMath.swift b/Sources/TokamakCore/Animation/_VectorMath.swift new file mode 100644 index 000000000..27e08b7c6 --- /dev/null +++ b/Sources/TokamakCore/Animation/_VectorMath.swift @@ -0,0 +1,86 @@ +// 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/11/21. +// + +import Foundation + +public protocol _VectorMath: Animatable {} + +public extension _VectorMath { + @inlinable var magnitude: Double { + animatableData.magnitudeSquared.squareRoot() + } + + @inlinable + mutating func negate() { + animatableData = .zero - animatableData + } + + @inlinable + static prefix func - (operand: Self) -> Self { + var result = operand + result.negate() + return result + } + + @inlinable + static func += (lhs: inout Self, rhs: Self) { + lhs.animatableData += rhs.animatableData + } + + @inlinable + static func + (lhs: Self, rhs: Self) -> Self { + var result = lhs + result += rhs + return result + } + + @inlinable + static func -= (lhs: inout Self, rhs: Self) { + lhs.animatableData -= rhs.animatableData + } + + @inlinable + static func - (lhs: Self, rhs: Self) -> Self { + var result = lhs + result -= rhs + return result + } + + @inlinable + static func *= (lhs: inout Self, rhs: Double) { + lhs.animatableData.scale(by: rhs) + } + + @inlinable + static func * (lhs: Self, rhs: Double) -> Self { + var result = lhs + result *= rhs + return result + } + + @inlinable + static func /= (lhs: inout Self, rhs: Double) { + lhs *= 1 / rhs + } + + @inlinable + static func / (lhs: Self, rhs: Double) -> Self { + var result = lhs + result /= rhs + return result + } +} diff --git a/Sources/TokamakCore/Environment/EnvironmentalModifier.swift b/Sources/TokamakCore/Environment/EnvironmentalModifier.swift new file mode 100644 index 000000000..1923517d5 --- /dev/null +++ b/Sources/TokamakCore/Environment/EnvironmentalModifier.swift @@ -0,0 +1,46 @@ +// 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/11/21. +// + +/// A modifier that resolves to a concrete modifier in an environment. +public protocol EnvironmentalModifier: ViewModifier { + associatedtype ResolvedModifier: ViewModifier + func resolve(in environment: EnvironmentValues) -> ResolvedModifier + static var _requiresMainThread: Bool { get } +} + +private struct EnvironmentalModifierResolver: ViewModifier, EnvironmentReader + where M: EnvironmentalModifier +{ + let modifier: M + var resolved: M.ResolvedModifier! + + func body(content: Content) -> some View { + content.modifier(resolved) + } + + mutating func setContent(from values: EnvironmentValues) { + resolved = modifier.resolve(in: values) + } +} + +public extension EnvironmentalModifier { + static var _requiresMainThread: Bool { true } + + func body(content: _ViewModifier_Content) -> some View { + content.modifier(EnvironmentalModifierResolver(modifier: self)) + } +} diff --git a/Sources/TokamakCore/Modifiers/Effects/ClipEffect.swift b/Sources/TokamakCore/Modifiers/Effects/ClipEffect.swift index 239a1ffbf..2222726b5 100644 --- a/Sources/TokamakCore/Modifiers/Effects/ClipEffect.swift +++ b/Sources/TokamakCore/Modifiers/Effects/ClipEffect.swift @@ -29,6 +29,11 @@ public struct _ClipEffect: ViewModifier where ClipShape: Shape { public func body(content: Content) -> some View { content } + + public var animatableData: ClipShape.AnimatableData { + get { shape.animatableData } + set { shape.animatableData = newValue } + } } public extension View { diff --git a/Sources/TokamakCore/Modifiers/Effects/GeometryEffect.swift b/Sources/TokamakCore/Modifiers/Effects/GeometryEffect.swift index bdd09731d..b0af13330 100644 --- a/Sources/TokamakCore/Modifiers/Effects/GeometryEffect.swift +++ b/Sources/TokamakCore/Modifiers/Effects/GeometryEffect.swift @@ -18,7 +18,7 @@ import Foundation // FIXME: Make `Animatable` -public protocol GeometryEffect: ViewModifier { +public protocol GeometryEffect: Animatable, ViewModifier { func effectValue(size: CGSize) -> ProjectionTransform } diff --git a/Sources/TokamakCore/Modifiers/Effects/OffsetEffect.swift b/Sources/TokamakCore/Modifiers/Effects/OffsetEffect.swift new file mode 100644 index 000000000..5bd13e814 --- /dev/null +++ b/Sources/TokamakCore/Modifiers/Effects/OffsetEffect.swift @@ -0,0 +1,56 @@ +// Copyright 2020-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/12/21. +// + +import Foundation + +@frozen public struct _OffsetEffect: GeometryEffect, Equatable { + public var offset: CGSize + + @inlinable + public init(offset: CGSize) { + self.offset = offset + } + + public func effectValue(size: CGSize) -> ProjectionTransform { + .init(.init(translationX: offset.width, y: offset.height)) + } + + public var animatableData: CGSize.AnimatableData { + get { + offset.animatableData + } + set { + offset.animatableData = newValue + } + } + + public func body(content: Content) -> some View { + content + } +} + +public extension View { + @inlinable + func offset(_ offset: CGSize) -> some View { + modifier(_OffsetEffect(offset: offset)) + } + + @inlinable + func offset(x: CGFloat = 0, y: CGFloat = 0) -> some View { + offset(CGSize(width: x, height: y)) + } +} diff --git a/Sources/TokamakCore/Modifiers/Effects/OpacityEffect.swift b/Sources/TokamakCore/Modifiers/Effects/OpacityEffect.swift index 1b48bfc3c..382ae053d 100644 --- a/Sources/TokamakCore/Modifiers/Effects/OpacityEffect.swift +++ b/Sources/TokamakCore/Modifiers/Effects/OpacityEffect.swift @@ -15,8 +15,8 @@ // Created by Carson Katri on 1/20/21. // -public struct _OpacityEffect: ViewModifier, Equatable { - public let opacity: Double +public struct _OpacityEffect: Animatable, ViewModifier, Equatable { + public var opacity: Double public init(opacity: Double) { self.opacity = opacity @@ -25,6 +25,11 @@ public struct _OpacityEffect: ViewModifier, Equatable { public func body(content: Content) -> some View { content } + + public var animatableData: Double { + get { opacity } + set { opacity = newValue } + } } public extension View { diff --git a/Sources/TokamakCore/Modifiers/Effects/RotationEffect.swift b/Sources/TokamakCore/Modifiers/Effects/RotationEffect.swift index 812b046b0..e9e4c4ffd 100644 --- a/Sources/TokamakCore/Modifiers/Effects/RotationEffect.swift +++ b/Sources/TokamakCore/Modifiers/Effects/RotationEffect.swift @@ -33,6 +33,15 @@ public struct _RotationEffect: GeometryEffect { public func body(content: Content) -> some View { content } + + public var animatableData: AnimatablePair { + get { + .init(angle.animatableData, anchor.animatableData) + } + set { + (angle.animatableData, anchor.animatableData) = newValue[] + } + } } public extension View { diff --git a/Sources/TokamakCore/Modifiers/FlexFrameLayout.swift b/Sources/TokamakCore/Modifiers/FlexFrameLayout.swift index 11719694a..ab5efe899 100644 --- a/Sources/TokamakCore/Modifiers/FlexFrameLayout.swift +++ b/Sources/TokamakCore/Modifiers/FlexFrameLayout.swift @@ -56,6 +56,10 @@ public struct _FlexFrameLayout: ViewModifier { } } +extension _FlexFrameLayout: Animatable { + public typealias AnimatableData = EmptyAnimatableData +} + public extension View { func frame( minWidth: CGFloat? = nil, diff --git a/Sources/TokamakCore/Modifiers/FrameLayout.swift b/Sources/TokamakCore/Modifiers/FrameLayout.swift index bbbc6ebb9..0f7d99bba 100644 --- a/Sources/TokamakCore/Modifiers/FrameLayout.swift +++ b/Sources/TokamakCore/Modifiers/FrameLayout.swift @@ -30,6 +30,10 @@ public struct _FrameLayout: ViewModifier { } } +extension _FrameLayout: Animatable { + public typealias AnimatableData = EmptyAnimatableData +} + public extension View { func frame( width: CGFloat? = nil, diff --git a/Sources/TokamakCore/Modifiers/PaddingLayout.swift b/Sources/TokamakCore/Modifiers/PaddingLayout.swift index 549201c21..bc747c524 100644 --- a/Sources/TokamakCore/Modifiers/PaddingLayout.swift +++ b/Sources/TokamakCore/Modifiers/PaddingLayout.swift @@ -28,6 +28,10 @@ public struct _PaddingLayout: ViewModifier { } } +extension _PaddingLayout: Animatable { + public typealias AnimatableData = EmptyAnimatableData +} + public extension View { func padding(_ insets: EdgeInsets) -> ModifiedContent { modifier(_PaddingLayout(insets: insets)) diff --git a/Sources/TokamakCore/Modifiers/ShadowLayout.swift b/Sources/TokamakCore/Modifiers/ShadowLayout.swift index 991da8ae8..30e1fd069 100644 --- a/Sources/TokamakCore/Modifiers/ShadowLayout.swift +++ b/Sources/TokamakCore/Modifiers/ShadowLayout.swift @@ -14,29 +14,93 @@ import Foundation -public struct _ShadowLayout: ViewModifier, EnvironmentReader { +public struct _ShadowEffect: EnvironmentalModifier, Equatable { public var color: Color public var radius: CGFloat - public var x: CGFloat - public var y: CGFloat - public var environment: EnvironmentValues! + public var offset: CGSize - public func body(content: Content) -> some View { - content + @inlinable + init( + color: Color, + radius: CGFloat, + offset: CGSize + ) { + self.color = color + self.radius = radius + self.offset = offset + } + + public func resolve(in environment: EnvironmentValues) -> _Resolved { + .init( + color: color.provider.resolve(in: environment), + radius: radius, + offset: offset + ) } - mutating func setContent(from values: EnvironmentValues) { - environment = values + public struct _Resolved: ViewModifier, Animatable { + public var color: AnyColorBox.ResolvedValue + public var radius: CGFloat + public var offset: CGSize + + public func body(content: Content) -> some View { + content + } + + public typealias AnimatableData = AnimatablePair< + AnimatablePair< + Float, + AnimatablePair< + Float, + AnimatablePair + > + >, + AnimatablePair + > + public var animatableData: _Resolved.AnimatableData { + get { + .init( + .init( + Float(color.red), + .init( + Float(color.green), + .init( + Float(color.blue), + Float(color.opacity) + ) + ) + ), + .init(radius, offset.animatableData) + ) + } + set { + color = .init( + red: Double(newValue[].0[].0), + green: Double(newValue[].0[].1[].0), + blue: Double(newValue[].0[].1[].1[].0), + opacity: Double(newValue[].0[].1[].1[].1), + space: .sRGB + ) + (radius, offset.animatableData) = newValue[].1[] + } + } } } public extension View { + @inlinable func shadow( color: Color = Color(.sRGBLinear, white: 0, opacity: 0.33), radius: CGFloat, x: CGFloat = 0, y: CGFloat = 0 ) -> some View { - modifier(_ShadowLayout(color: color, radius: radius, x: x, y: y)) + modifier( + _ShadowEffect( + color: color, + radius: radius, + offset: .init(width: x, height: y) + ) + ) } } diff --git a/Sources/TokamakCore/MountedViews/MountedApp.swift b/Sources/TokamakCore/MountedViews/MountedApp.swift index dd79a9f30..084e14a99 100644 --- a/Sources/TokamakCore/MountedViews/MountedApp.swift +++ b/Sources/TokamakCore/MountedViews/MountedApp.swift @@ -55,15 +55,17 @@ final class MountedApp: MountedCompositeElement { return mountedScene } - override func update(with reconciler: StackReconciler) { + override func update(in reconciler: StackReconciler, with transaction: Transaction) { let element = reconciler.render(mountedApp: self) reconciler.reconcile( self, with: element, + transaction: transaction, getElementType: { $0.type }, updateChild: { $0.environmentValues = environmentValues $0.scene = _AnyScene(element) + $0.transaction = transaction }, mountChild: { mountChild(reconciler.renderer, $0) } ) diff --git a/Sources/TokamakCore/MountedViews/MountedCompositeView.swift b/Sources/TokamakCore/MountedViews/MountedCompositeView.swift index 0bbd8598a..9b393f1e4 100644 --- a/Sources/TokamakCore/MountedViews/MountedCompositeView.swift +++ b/Sources/TokamakCore/MountedViews/MountedCompositeView.swift @@ -23,6 +23,8 @@ final class MountedCompositeView: MountedCompositeElement { on parent: MountedElement? = nil, with reconciler: StackReconciler ) { + (view.view as? _TransactionModifierProtocol)?.modifyTransaction(&transaction) + let childBody = reconciler.render(compositeView: self) let child: MountedElement = childBody.makeMountedView( @@ -81,15 +83,19 @@ final class MountedCompositeView: MountedCompositeElement { } } - override func update(with reconciler: StackReconciler) { + override func update(in reconciler: StackReconciler, with transaction: Transaction) { + var transaction = transaction + (view.view as? _TransactionModifierProtocol)?.modifyTransaction(&transaction) let element = reconciler.render(compositeView: self) reconciler.reconcile( self, with: element, + transaction: transaction, getElementType: { $0.type }, updateChild: { $0.environmentValues = environmentValues $0.view = AnyView(element) + $0.transaction = transaction }, mountChild: { $0.makeMountedView(reconciler.renderer, parentTarget, environmentValues, self) diff --git a/Sources/TokamakCore/MountedViews/MountedElement.swift b/Sources/TokamakCore/MountedViews/MountedElement.swift index a32e380fc..a28702047 100644 --- a/Sources/TokamakCore/MountedViews/MountedElement.swift +++ b/Sources/TokamakCore/MountedViews/MountedElement.swift @@ -85,6 +85,9 @@ public class MountedElement { } var mountedChildren = [MountedElement]() + + public internal(set) var transaction: Transaction = .init(animation: nil) + var environmentValues: EnvironmentValues unowned var parent: MountedElement? @@ -140,7 +143,7 @@ public class MountedElement { fatalError("implement \(#function) in subclass") } - func update(with reconciler: StackReconciler) { + func update(in reconciler: StackReconciler, with transaction: Transaction) { fatalError("implement \(#function) in subclass") } diff --git a/Sources/TokamakCore/MountedViews/MountedEmptyView.swift b/Sources/TokamakCore/MountedViews/MountedEmptyView.swift index f070e798e..2ea5f4081 100644 --- a/Sources/TokamakCore/MountedViews/MountedEmptyView.swift +++ b/Sources/TokamakCore/MountedViews/MountedEmptyView.swift @@ -24,5 +24,5 @@ final class MountedEmptyView: MountedElement { override func unmount(with reconciler: StackReconciler) {} - override func update(with reconciler: StackReconciler) {} + override func update(in reconciler: StackReconciler, with transaction: Transaction?) {} } diff --git a/Sources/TokamakCore/MountedViews/MountedHostView.swift b/Sources/TokamakCore/MountedViews/MountedHostView.swift index 8930c79f0..f65938aaa 100644 --- a/Sources/TokamakCore/MountedViews/MountedHostView.swift +++ b/Sources/TokamakCore/MountedViews/MountedHostView.swift @@ -83,7 +83,7 @@ public final class MountedHostView: MountedElement { } } - override func update(with reconciler: StackReconciler) { + override func update(in reconciler: StackReconciler, with transaction: Transaction) { guard let target = target else { return } updateEnvironment() @@ -119,7 +119,7 @@ public final class MountedHostView: MountedElement { mountedChild.environmentValues = environmentValues mountedChild.view = childView mountedChild.updateEnvironment() - mountedChild.update(with: reconciler) + mountedChild.update(in: reconciler, with: transaction) newChild = mountedChild } else { /* note the order of operations here: we mount the new child first, use the mounted child diff --git a/Sources/TokamakCore/MountedViews/MountedScene.swift b/Sources/TokamakCore/MountedViews/MountedScene.swift index b73ad3bb3..428a3f029 100644 --- a/Sources/TokamakCore/MountedViews/MountedScene.swift +++ b/Sources/TokamakCore/MountedViews/MountedScene.swift @@ -45,11 +45,12 @@ final class MountedScene: MountedCompositeElement { mountedChildren.forEach { $0.unmount(with: reconciler) } } - override func update(with reconciler: StackReconciler) { + override func update(in reconciler: StackReconciler, with transaction: Transaction) { let element = reconciler.render(mountedScene: self) reconciler.reconcile( self, with: element, + transaction: transaction, getElementType: { $0.type }, updateChild: { $0.environmentValues = environmentValues @@ -59,6 +60,7 @@ final class MountedScene: MountedCompositeElement { case let .view(view): $0.view = AnyView(view) } + $0.transaction = transaction }, mountChild: { $0.makeMountedElement(reconciler.renderer, parentTarget, environmentValues, self) diff --git a/Sources/TokamakCore/Shapes/ModifiedShapes.swift b/Sources/TokamakCore/Shapes/ModifiedShapes.swift index 4eff3696a..67b681d8b 100644 --- a/Sources/TokamakCore/Shapes/ModifiedShapes.swift +++ b/Sources/TokamakCore/Shapes/ModifiedShapes.swift @@ -34,6 +34,16 @@ public struct _StrokedShape: Shape, DynamicProperty where S: Shape { } public static var role: ShapeRole { .stroke } + + public typealias AnimatableData = AnimatablePair + public var animatableData: AnimatableData { + get { + .init(shape.animatableData, style.animatableData) + } + set { + (shape.animatableData, style.animatableData) = newValue[] + } + } } public struct _TrimmedShape: Shape where S: Shape { @@ -52,6 +62,20 @@ public struct _TrimmedShape: Shape where S: Shape { .path(in: rect) .trimmedPath(from: startFraction, to: endFraction) } + + public typealias AnimatableData = AnimatablePair< + S.AnimatableData, + AnimatablePair + > + public var animatableData: AnimatableData { + get { + .init(shape.animatableData, .init(startFraction, endFraction)) + } + set { + shape.animatableData = newValue[].0 + (startFraction, endFraction) = newValue[].1[] + } + } } public struct OffsetShape: Shape where Content: Shape { @@ -68,6 +92,16 @@ public struct OffsetShape: Shape where Content: Shape { .path(in: rect) .offsetBy(dx: offset.width, dy: offset.height) } + + public typealias AnimatableData = AnimatablePair + public var animatableData: AnimatableData { + get { + .init(shape.animatableData, offset.animatableData) + } + set { + (shape.animatableData, offset.animatableData) = newValue[] + } + } } extension OffsetShape: InsettableShape where Content: InsettableShape { @@ -94,6 +128,20 @@ public struct ScaledShape: Shape where Content: Shape { .path(in: rect) .applying(.init(scaleX: scale.width, y: scale.height)) } + + public typealias AnimatableData = AnimatablePair< + Content.AnimatableData, + AnimatablePair + > + public var animatableData: AnimatableData { + get { + .init(shape.animatableData, .init(scale.animatableData, anchor.animatableData)) + } + set { + shape.animatableData = newValue[].0 + (scale.animatableData, anchor.animatableData) = newValue[].1[] + } + } } public struct RotatedShape: Shape where Content: Shape { @@ -112,6 +160,20 @@ public struct RotatedShape: Shape where Content: Shape { .path(in: rect) .applying(.init(rotationAngle: CGFloat(angle.radians))) } + + public typealias AnimatableData = AnimatablePair< + Content.AnimatableData, + AnimatablePair + > + public var animatableData: AnimatableData { + get { + .init(shape.animatableData, .init(angle.animatableData, anchor.animatableData)) + } + set { + shape.animatableData = newValue[].0 + (angle.animatableData, anchor.animatableData) = newValue[].1[] + } + } } extension RotatedShape: InsettableShape where Content: InsettableShape { @@ -134,6 +196,11 @@ public struct TransformedShape: Shape where Content: Shape { .path(in: rect) .applying(transform) } + + public var animatableData: Content.AnimatableData { + get { shape.animatableData } + set { shape.animatableData = newValue } + } } public struct _SizedShape: Shape where S: Shape { @@ -150,4 +217,14 @@ public struct _SizedShape: Shape where S: Shape { shape .path(in: rect) } + + public typealias AnimatableData = AnimatablePair + public var animatableData: AnimatableData { + get { + .init(shape.animatableData, size.animatableData) + } + set { + (shape.animatableData, size.animatableData) = newValue[] + } + } } diff --git a/Sources/TokamakCore/Shapes/Shape.swift b/Sources/TokamakCore/Shapes/Shape.swift index 1a30e68a8..8eb6ce7ab 100644 --- a/Sources/TokamakCore/Shapes/Shape.swift +++ b/Sources/TokamakCore/Shapes/Shape.swift @@ -17,7 +17,7 @@ import Foundation -public protocol Shape: View { +public protocol Shape: Animatable, View { func path(in rect: CGRect) -> Path static var role: ShapeRole { get } diff --git a/Sources/TokamakCore/Shapes/StrokeStyle.swift b/Sources/TokamakCore/Shapes/StrokeStyle.swift index d67a3c5e1..e05bdd276 100644 --- a/Sources/TokamakCore/Shapes/StrokeStyle.swift +++ b/Sources/TokamakCore/Shapes/StrokeStyle.swift @@ -41,3 +41,16 @@ public struct StrokeStyle: Equatable { self.dashPhase = dashPhase } } + +extension StrokeStyle: Animatable { + public var animatableData: AnimatablePair> { + get { + .init(lineWidth, .init(miterLimit, dashPhase)) + } + set { + lineWidth = newValue[].0 + miterLimit = newValue[].1[].0 + dashPhase = newValue[].1[].1 + } + } +} diff --git a/Sources/TokamakCore/StackReconciler.swift b/Sources/TokamakCore/StackReconciler.swift index 8b2339394..c6074123f 100644 --- a/Sources/TokamakCore/StackReconciler.swift +++ b/Sources/TokamakCore/StackReconciler.swift @@ -35,7 +35,20 @@ public final class StackReconciler { haven't been proven in the absence of benchmarks, so this could be updated to a simple `Array` in the future if that's proven to be more effective. */ - private var queuedRerenders = Set>() + private var queuedRerenders = Set() + + struct Rerender: Hashable { + let element: MountedCompositeElement + let transaction: Transaction + + func hash(into hasher: inout Hasher) { + hasher.combine(element) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.element == rhs.element + } + } /** A root renderer's target instance. We establish the "host-target" terminology where a "host" is a primitive `View` that doesn't have any children, and a "target" is an instance of a type @@ -106,15 +119,24 @@ public final class StackReconciler { private func queueStorageUpdate( for mountedElement: MountedCompositeElement, id: Int, + transaction: Transaction, updater: (inout Any) -> () ) { updater(&mountedElement.storage[id]) - queueUpdate(for: mountedElement) + queueUpdate(for: mountedElement, transaction: transaction) } - internal func queueUpdate(for mountedElement: MountedCompositeElement) { + internal func queueUpdate( + for mountedElement: MountedCompositeElement, + transaction: Transaction + ) { let shouldSchedule = queuedRerenders.isEmpty - queuedRerenders.insert(mountedElement) + queuedRerenders.insert( + .init( + element: mountedElement, + transaction: transaction + ) + ) guard shouldSchedule else { return } @@ -126,7 +148,7 @@ public final class StackReconciler { queuedRerenders.removeAll() for mountedView in queued { - mountedView.update(with: self) + mountedView.element.update(in: self, with: mountedView.transaction) } performPostrenderCallbacks() @@ -155,9 +177,9 @@ public final class StackReconciler { // Avoiding an indirect reference cycle here: this closure can be owned by callbacks // owned by view's target, which is strongly referenced by the reconciler. - writableStorage.setter = { [weak self, weak compositeElement] newValue in + writableStorage.setter = { [weak self, weak compositeElement] newValue, transaction in guard let element = compositeElement else { return } - self?.queueStorageUpdate(for: element, id: id) { $0 = newValue } + self?.queueStorageUpdate(for: element, id: id, transaction: transaction) { $0 = newValue } } property.set(value: writableStorage, on: &compositeElement[keyPath: bodyKeypath]) @@ -180,7 +202,7 @@ public final class StackReconciler { // instance property observed.objectWillChange.sink { [weak self, weak compositeElement] _ in if let compositeElement = compositeElement { - self?.queueUpdate(for: compositeElement) + self?.queueUpdate(for: compositeElement, transaction: .init(animation: nil)) } }.store(in: &compositeElement.transientSubscriptions) } @@ -197,7 +219,7 @@ public final class StackReconciler { else { return } mountedApp.environmentValues[keyPath: keyPath] = value - self?.queueUpdate(for: mountedApp) + self?.queueUpdate(for: mountedApp, transaction: .init(animation: nil)) }.store(in: &mountedApp.persistentSubscriptions) } @@ -247,9 +269,11 @@ public final class StackReconciler { mountedScene.scene.bodyClosure(body(of: mountedScene, keyPath: \.scene.scene)) } + // swiftlint:disable function_parameter_count func reconcile( _ mountedElement: MountedCompositeElement, with element: Element, + transaction: Transaction, getElementType: (Element) -> Any.Type, updateChild: (MountedElement) -> (), mountChild: (Element) -> MountedElement @@ -272,7 +296,7 @@ public final class StackReconciler { // new child has the same type as existing child if mountedChild.typeConstructorName == typeConstructorName(childBodyType) { updateChild(mountedChild) - mountedChild.update(with: self) + mountedChild.update(in: self, with: transaction) } 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` @@ -285,6 +309,8 @@ public final class StackReconciler { } } + // swiftlint:enable function_parameter_count + private var queuedPostrenderCallbacks = [() -> ()]() func afterCurrentRender(perform callback: @escaping () -> ()) { queuedPostrenderCallbacks.append(callback) diff --git a/Sources/TokamakCore/State/Binding.swift b/Sources/TokamakCore/State/Binding.swift index 672bc76ff..6a75eda6b 100644 --- a/Sources/TokamakCore/State/Binding.swift +++ b/Sources/TokamakCore/State/Binding.swift @@ -15,8 +15,6 @@ // Created by Max Desiatov on 09/02/2019. // -typealias Updater = (inout T) -> () - /** Note that `set` functions are not `mutating`, they never update the view's state in-place synchronously, but only schedule an update with the renderer at a later time. @@ -26,17 +24,28 @@ typealias Updater = (inout T) -> () public struct Binding: DynamicProperty { public var wrappedValue: Value { get { get() } - nonmutating set { set(newValue) } + nonmutating set { set(newValue, transaction) } } + public var transaction: Transaction + private let get: () -> Value - private let set: (Value) -> () + private let set: (Value, Transaction) -> () public var projectedValue: Binding { self } public init(get: @escaping () -> Value, set: @escaping (Value) -> ()) { self.get = get - self.set = set + self.set = { v, _ in set(v) } + transaction = .init(animation: nil) + } + + public init(get: @escaping () -> Value, set: @escaping (Value, Transaction) -> ()) { + self.transaction = .init(animation: nil) + self.get = get + self.set = { + set($0, $1) + } } public subscript( @@ -55,3 +64,64 @@ public struct Binding: DynamicProperty { .init(get: { value }, set: { _ in }) } } + +public extension Binding { + func transaction(_ transaction: Transaction) -> Binding { + var binding = self + binding.transaction = transaction + return binding + } + + func animation(_ animation: Animation? = .default) -> Binding { + transaction(.init(animation: animation)) + } +} + +extension Binding: Identifiable where Value: Identifiable { + public var id: Value.ID { wrappedValue.id } +} + +extension Binding: Sequence where Value: MutableCollection { + public typealias Element = Binding + public typealias Iterator = IndexingIterator> + public typealias SubSequence = Slice> +} + +extension Binding: Collection where Value: MutableCollection { + public typealias Index = Value.Index + public typealias Indices = Value.Indices + public var startIndex: Binding.Index { wrappedValue.startIndex } + public var endIndex: Binding.Index { wrappedValue.endIndex } + public var indices: Value.Indices { wrappedValue.indices } + + public func index(after i: Binding.Index) -> Binding.Index { + wrappedValue.index(after: i) + } + + public func formIndex(after i: inout Binding.Index) { + wrappedValue.formIndex(after: &i) + } + + public subscript(position: Binding.Index) -> Binding.Element { + Binding { + wrappedValue[position] + } set: { + wrappedValue[position] = $0 + } + } +} + +extension Binding: BidirectionalCollection where Value: BidirectionalCollection, + Value: MutableCollection +{ + public func index(before i: Binding.Index) -> Binding.Index { + wrappedValue.index(before: i) + } + + public func formIndex(before i: inout Binding.Index) { + wrappedValue.formIndex(before: &i) + } +} + +extension Binding: RandomAccessCollection where Value: MutableCollection, + Value: RandomAccessCollection {} diff --git a/Sources/TokamakCore/State/State.swift b/Sources/TokamakCore/State/State.swift index a10de4daa..615f71641 100644 --- a/Sources/TokamakCore/State/State.swift +++ b/Sources/TokamakCore/State/State.swift @@ -20,7 +20,7 @@ protocol ValueStorage { } protocol WritableValueStorage: ValueStorage { - var setter: ((Any) -> ())? { get set } + var setter: ((Any, Transaction) -> ())? { get set } } @propertyWrapper public struct State: DynamicProperty { @@ -29,7 +29,7 @@ protocol WritableValueStorage: ValueStorage { var anyInitialValue: Any { initialValue } var getter: (() -> Any)? - var setter: ((Any) -> ())? + var setter: ((Any, Transaction) -> ())? public init(wrappedValue value: Value) { initialValue = value @@ -37,15 +37,21 @@ protocol WritableValueStorage: ValueStorage { public var wrappedValue: Value { get { getter?() as? Value ?? initialValue } - nonmutating set { setter?(newValue) } + nonmutating set { setter?(newValue, Transaction._active ?? .init(animation: nil)) } } public var projectedValue: Binding { guard let getter = getter, let setter = setter else { fatalError("\(#function) not available outside of `body`") } - // swiftlint:disable:next force_cast - return .init(get: { getter() as! Value }, set: { setter($0) }) + // swiftlint:disable force_cast + return .init( + get: { getter() as! Value }, + set: { newValue, transaction in + setter(newValue, Transaction._active ?? transaction) + } + ) + // swiftlint:enable force_cast } } diff --git a/Sources/TokamakCore/Tokens/Angle.swift b/Sources/TokamakCore/Tokens/Angle.swift index cec8e3709..eb1602470 100644 --- a/Sources/TokamakCore/Tokens/Angle.swift +++ b/Sources/TokamakCore/Tokens/Angle.swift @@ -68,3 +68,10 @@ extension Angle: Hashable, Comparable { lhs.radians < rhs.radians } } + +extension Angle: Animatable, _VectorMath { + public var animatableData: Double { + get { radians } + set { radians = newValue } + } +} diff --git a/Sources/TokamakCore/Tokens/Color/Color.swift b/Sources/TokamakCore/Tokens/Color/Color.swift new file mode 100644 index 000000000..7501c36c8 --- /dev/null +++ b/Sources/TokamakCore/Tokens/Color/Color.swift @@ -0,0 +1,182 @@ +// Copyright 2018-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 Max Desiatov on 16/10/2018. +// + +public struct Color: Hashable, Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.provider == rhs.provider + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(provider) + } + + let provider: AnyColorBox + + internal init(_ provider: AnyColorBox) { + self.provider = provider + } + + public init( + _ colorSpace: RGBColorSpace = .sRGB, + red: Double, + green: Double, + blue: Double, + opacity: Double = 1 + ) { + self.init(_ConcreteColorBox( + .init(red: red, green: green, blue: blue, opacity: opacity, space: colorSpace) + )) + } + + public init(_ colorSpace: RGBColorSpace = .sRGB, white: Double, opacity: Double = 1) { + self.init(colorSpace, red: white, green: white, blue: white, opacity: opacity) + } + + // Source for the formula: + // https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative + public init(hue: Double, saturation: Double, brightness: Double, opacity: Double = 1) { + let a = saturation * min(brightness / 2, 1 - (brightness / 2)) + let f = { (n: Int) -> Double in + let k = Double((n + Int(hue * 12)) % 12) + return brightness - (a * max(-1, min(k - 3, 9 - k, 1))) + } + self.init(.sRGB, red: f(0), green: f(8), blue: f(4), opacity: opacity) + } + + /// Create a `Color` dependent on the current `ColorScheme`. + @_spi(TokamakCore) + public static func _withScheme(_ resolver: @escaping (ColorScheme) -> Self) -> Self { + .init(_EnvironmentDependentColorBox { + resolver($0.colorScheme) + }) + } +} + +public extension Color { + func opacity(_ opacity: Double) -> Self { + Self(_OpacityColorBox(provider, opacity: opacity)) + } +} + +public struct _ColorProxy { + let subject: Color + public init(_ subject: Color) { self.subject = subject } + public func resolve(in environment: EnvironmentValues) -> AnyColorBox.ResolvedValue { + if let deferred = subject.provider as? AnyColorBoxDeferredToRenderer { + return deferred.deferredResolve(in: environment) + } else { + return subject.provider.resolve(in: environment) + } + } +} + +public extension Color { + enum RGBColorSpace { + case sRGB + case sRGBLinear + case displayP3 + } +} + +extension Color: CustomStringConvertible { + public var description: String { + if let providerDescription = provider as? CustomStringConvertible { + return providerDescription.description + } else { + return "Color: \(provider.self)" + } + } +} + +public extension Color { + private init(systemColor: _SystemColorBox.SystemColor) { + self.init(_SystemColorBox(systemColor)) + } + + static let clear: Self = .init(systemColor: .clear) + static let black: Self = .init(systemColor: .black) + static let white: Self = .init(systemColor: .white) + static let gray: Self = .init(systemColor: .gray) + static let red: Self = .init(systemColor: .red) + static let green: Self = .init(systemColor: .green) + static let blue: Self = .init(systemColor: .blue) + static let orange: Self = .init(systemColor: .orange) + static let yellow: Self = .init(systemColor: .yellow) + static let pink: Self = .init(systemColor: .pink) + static let purple: Self = .init(systemColor: .purple) + static let primary: Self = .init(systemColor: .primary) + + static let secondary: Self = .init(systemColor: .secondary) + static let accentColor: Self = .init(_EnvironmentDependentColorBox { + $0.accentColor ?? Self.blue + }) + + init(_ color: UIColor) { + self = color.color + } +} + +extension Color: ExpressibleByIntegerLiteral { + /// Allows initializing value of `Color` type from hex values + public init(integerLiteral bitMask: UInt32) { + self.init( + .sRGB, + red: Double((bitMask & 0xFF0000) >> 16) / 255, + green: Double((bitMask & 0x00FF00) >> 8) / 255, + blue: Double(bitMask & 0x0000FF) / 255, + opacity: 1 + ) + } +} + +public extension Color { + init?(hex: String) { + let cArray = Array(hex.count > 6 ? String(hex.dropFirst()) : hex) + + guard cArray.count == 6 else { return nil } + + guard + let red = Int(String(cArray[0...1]), radix: 16), + let green = Int(String(cArray[2...3]), radix: 16), + let blue = Int(String(cArray[4...5]), radix: 16) + else { + return nil + } + self.init( + .sRGB, + red: Double(red) / 255, + green: Double(green) / 255, + blue: Double(blue) / 255, + opacity: 1 + ) + } +} + +extension Color: ShapeStyle { + public func _apply(to shape: inout _ShapeStyle_Shape) { + shape.result = .color(self) + } + + public static func _apply(to type: inout _ShapeStyle_ShapeType) {} +} + +extension Color: View { + @_spi(TokamakCore) + public var body: some View { + _ShapeView(shape: Rectangle(), style: self) + } +} diff --git a/Sources/TokamakCore/Tokens/Color.swift b/Sources/TokamakCore/Tokens/Color/ColorBoxes.swift similarity index 56% rename from Sources/TokamakCore/Tokens/Color.swift rename to Sources/TokamakCore/Tokens/Color/ColorBoxes.swift index f78f615b1..6bfb55ac3 100644 --- a/Sources/TokamakCore/Tokens/Color.swift +++ b/Sources/TokamakCore/Tokens/Color/ColorBoxes.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // -// Created by Max Desiatov on 16/10/2018. +// Created by Carson Katri on 7/12/21. // /// Override `TokamakCore`'s default `Color` resolvers with a Renderer-specific one. @@ -79,7 +79,7 @@ public class AnyColorBox: AnyTokenBox, Hashable { } } -public class _ConcreteColorBox: AnyColorBox { +public final class _ConcreteColorBox: AnyColorBox { public let rgba: AnyColorBox._RGBA override public func equals(_ other: AnyColorBox) -> Bool { @@ -101,7 +101,7 @@ public class _ConcreteColorBox: AnyColorBox { } } -public class _EnvironmentDependentColorBox: AnyColorBox { +public final class _EnvironmentDependentColorBox: AnyColorBox { public let resolver: (EnvironmentValues) -> Color override public func equals(_ other: AnyColorBox) -> Bool { @@ -123,7 +123,7 @@ public class _EnvironmentDependentColorBox: AnyColorBox { } } -public class _OpacityColorBox: AnyColorBox { +public final class _OpacityColorBox: AnyColorBox { public let parent: AnyColorBox public let opacity: Double @@ -155,7 +155,7 @@ public class _OpacityColorBox: AnyColorBox { } } -public class _SystemColorBox: AnyColorBox, CustomStringConvertible { +public final class _SystemColorBox: AnyColorBox, CustomStringConvertible { public enum SystemColor: String, Equatable, Hashable { case clear case black @@ -188,7 +188,7 @@ public class _SystemColorBox: AnyColorBox, CustomStringConvertible { hasher.combine(value) } - fileprivate init(_ value: SystemColor) { + init(_ value: SystemColor) { self.value = value } @@ -229,211 +229,3 @@ public class _SystemColorBox: AnyColorBox, CustomStringConvertible { } } } - -public struct Color: Hashable, Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.provider == rhs.provider - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(provider) - } - - let provider: AnyColorBox - - internal init(_ provider: AnyColorBox) { - self.provider = provider - } - - public init( - _ colorSpace: RGBColorSpace = .sRGB, - red: Double, - green: Double, - blue: Double, - opacity: Double = 1 - ) { - self.init(_ConcreteColorBox( - .init(red: red, green: green, blue: blue, opacity: opacity, space: colorSpace) - )) - } - - public init(_ colorSpace: RGBColorSpace = .sRGB, white: Double, opacity: Double = 1) { - self.init(colorSpace, red: white, green: white, blue: white, opacity: opacity) - } - - // Source for the formula: - // https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative - public init(hue: Double, saturation: Double, brightness: Double, opacity: Double = 1) { - let a = saturation * min(brightness / 2, 1 - (brightness / 2)) - let f = { (n: Int) -> Double in - let k = Double((n + Int(hue * 12)) % 12) - return brightness - (a * max(-1, min(k - 3, 9 - k, 1))) - } - self.init(.sRGB, red: f(0), green: f(8), blue: f(4), opacity: opacity) - } - - /// Create a `Color` dependent on the current `ColorScheme`. - @_spi(TokamakCore) - public static func _withScheme(_ resolver: @escaping (ColorScheme) -> Self) -> Self { - .init(_EnvironmentDependentColorBox { - resolver($0.colorScheme) - }) - } -} - -public extension Color { - func opacity(_ opacity: Double) -> Self { - Self(_OpacityColorBox(provider, opacity: opacity)) - } -} - -public struct _ColorProxy { - let subject: Color - public init(_ subject: Color) { self.subject = subject } - public func resolve(in environment: EnvironmentValues) -> AnyColorBox.ResolvedValue { - if let deferred = subject.provider as? AnyColorBoxDeferredToRenderer { - return deferred.deferredResolve(in: environment) - } else { - return subject.provider.resolve(in: environment) - } - } -} - -public extension Color { - enum RGBColorSpace { - case sRGB - case sRGBLinear - case displayP3 - } -} - -extension Color: CustomStringConvertible { - public var description: String { - if let providerDescription = provider as? CustomStringConvertible { - return providerDescription.description - } else { - return "Color: \(provider.self)" - } - } -} - -public extension Color { - private init(systemColor: _SystemColorBox.SystemColor) { - self.init(_SystemColorBox(systemColor)) - } - - static let clear: Self = .init(systemColor: .clear) - static let black: Self = .init(systemColor: .black) - static let white: Self = .init(systemColor: .white) - static let gray: Self = .init(systemColor: .gray) - static let red: Self = .init(systemColor: .red) - static let green: Self = .init(systemColor: .green) - static let blue: Self = .init(systemColor: .blue) - static let orange: Self = .init(systemColor: .orange) - static let yellow: Self = .init(systemColor: .yellow) - static let pink: Self = .init(systemColor: .pink) - static let purple: Self = .init(systemColor: .purple) - static let primary: Self = .init(systemColor: .primary) - - static let secondary: Self = .init(systemColor: .secondary) - static let accentColor: Self = .init(_EnvironmentDependentColorBox { - $0.accentColor ?? Self.blue - }) - - init(_ color: UIColor) { - self = color.color - } -} - -extension Color: ExpressibleByIntegerLiteral { - /// Allows initializing value of `Color` type from hex values - public init(integerLiteral bitMask: UInt32) { - self.init( - .sRGB, - red: Double((bitMask & 0xFF0000) >> 16) / 255, - green: Double((bitMask & 0x00FF00) >> 8) / 255, - blue: Double(bitMask & 0x0000FF) / 255, - opacity: 1 - ) - } -} - -public extension Color { - init?(hex: String) { - let cArray = Array(hex.count > 6 ? String(hex.dropFirst()) : hex) - - guard cArray.count == 6 else { return nil } - - guard - let red = Int(String(cArray[0...1]), radix: 16), - let green = Int(String(cArray[2...3]), radix: 16), - let blue = Int(String(cArray[4...5]), radix: 16) - else { - return nil - } - self.init( - .sRGB, - red: Double(red) / 255, - green: Double(green) / 255, - blue: Double(blue) / 255, - opacity: 1 - ) - } -} - -extension Color: ShapeStyle { - public func _apply(to shape: inout _ShapeStyle_Shape) { - shape.result = .color(self) - } - - public static func _apply(to type: inout _ShapeStyle_ShapeType) {} -} - -extension Color: View { - @_spi(TokamakCore) - public var body: some View { - _ShapeView(shape: Rectangle(), style: self) - } -} - -struct AccentColorKey: EnvironmentKey { - static let defaultValue: Color? = nil -} - -public extension EnvironmentValues { - var accentColor: Color? { - get { - self[AccentColorKey.self] - } - set { - self[AccentColorKey.self] = newValue - } - } -} - -public extension View { - func accentColor(_ accentColor: Color?) -> some View { - environment(\.accentColor, accentColor) - } -} - -struct ForegroundColorKey: EnvironmentKey { - static let defaultValue: Color? = nil -} - -public extension EnvironmentValues { - var foregroundColor: Color? { - get { - self[ForegroundColorKey.self] - } - set { - self[ForegroundColorKey.self] = newValue - } - } -} - -public extension View { - func foregroundColor(_ color: Color?) -> some View { - environment(\.foregroundColor, color) - } -} diff --git a/Sources/TokamakCore/Tokens/Color/ColorKeys.swift b/Sources/TokamakCore/Tokens/Color/ColorKeys.swift new file mode 100644 index 000000000..404969ec9 --- /dev/null +++ b/Sources/TokamakCore/Tokens/Color/ColorKeys.swift @@ -0,0 +1,60 @@ +// Copyright 2018-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/12/21. +// + +import Foundation + +struct AccentColorKey: EnvironmentKey { + static let defaultValue: Color? = nil +} + +public extension EnvironmentValues { + var accentColor: Color? { + get { + self[AccentColorKey.self] + } + set { + self[AccentColorKey.self] = newValue + } + } +} + +public extension View { + func accentColor(_ accentColor: Color?) -> some View { + environment(\.accentColor, accentColor) + } +} + +struct ForegroundColorKey: EnvironmentKey { + static let defaultValue: Color? = nil +} + +public extension EnvironmentValues { + var foregroundColor: Color? { + get { + self[ForegroundColorKey.self] + } + set { + self[ForegroundColorKey.self] = newValue + } + } +} + +public extension View { + func foregroundColor(_ color: Color?) -> some View { + environment(\.foregroundColor, color) + } +} diff --git a/Sources/TokamakCore/Tokens/Edge.swift b/Sources/TokamakCore/Tokens/Edge.swift index 09dadf73d..00c08abd4 100644 --- a/Sources/TokamakCore/Tokens/Edge.swift +++ b/Sources/TokamakCore/Tokens/Edge.swift @@ -65,3 +65,30 @@ public struct EdgeInsets: Equatable { self.init(top: _all, leading: _all, bottom: _all, trailing: _all) } } + +extension EdgeInsets: Animatable, _VectorMath { + public typealias AnimatableData = AnimatablePair< + CGFloat, + AnimatablePair< + CGFloat, + AnimatablePair + > + > + + public var animatableData: AnimatableData { + @inlinable get { + .init(top, .init(leading, .init(bottom, trailing))) + } + @inlinable set { + let top = newValue[].0 + let leading = newValue[].1[].0 + let (bottom, trailing) = newValue[].1[].1[] + self = .init( + top: top, + leading: leading, + bottom: bottom, + trailing: trailing + ) + } + } +} diff --git a/Sources/TokamakCore/Tokens/UnitPoint.swift b/Sources/TokamakCore/Tokens/UnitPoint.swift index 676390094..238041334 100644 --- a/Sources/TokamakCore/Tokens/UnitPoint.swift +++ b/Sources/TokamakCore/Tokens/UnitPoint.swift @@ -41,3 +41,14 @@ public struct UnitPoint: Hashable { public static let bottomLeading: UnitPoint = .init(x: 0, y: 0) public static let bottomTrailing: UnitPoint = .init(x: 1, y: 0) } + +extension UnitPoint: Animatable { + public var animatableData: AnimatablePair { + get { + .init(x, y) + } + set { + (x, y) = newValue[] + } + } +} diff --git a/Sources/TokamakDOM/Core.swift b/Sources/TokamakDOM/Core.swift index 2b904a887..d99ab0cf3 100644 --- a/Sources/TokamakDOM/Core.swift +++ b/Sources/TokamakDOM/Core.swift @@ -165,6 +165,31 @@ public typealias SceneStorage = TokamakCore.SceneStorage public typealias ViewBuilder = TokamakCore.ViewBuilder +// MARK: Animation + +public typealias Animation = TokamakCore.Animation +public typealias Transaction = TokamakCore.Transaction + +public typealias Animatable = TokamakCore.Animatable +public typealias AnimatablePair = TokamakCore.AnimatablePair +public typealias EmptyAnimatableData = TokamakCore.EmptyAnimatableData + +public typealias AnimatableModifier = TokamakCore.AnimatableModifier + +public func withTransaction( + _ transaction: Transaction, + _ body: () throws -> Result +) rethrows -> Result { + try TokamakCore.withTransaction(transaction, body) +} + +public func withAnimation( + _ animation: Animation? = .default, + _ body: () throws -> Result +) rethrows -> Result { + try TokamakCore.withAnimation(animation, body) +} + // FIXME: I would put this inside TokamakCore, but for // some reason it doesn't get exported with the typealias public extension Text { diff --git a/Sources/TokamakDOM/DOMNode.swift b/Sources/TokamakDOM/DOMNode.swift index 5a5cd703d..08a84c726 100644 --- a/Sources/TokamakDOM/DOMNode.swift +++ b/Sources/TokamakDOM/DOMNode.swift @@ -16,8 +16,45 @@ import JavaScriptKit import TokamakCore import TokamakStaticHTML +private extension String { + var animatableProperty: String { + if self == "float" { + return "cssFloat" + } else if self == "offset" { + return "cssProperty" + } else { + return split(separator: "-") + .reduce("") { prev, next in + "\(prev)\(prev.isEmpty ? next : next.prefix(1).uppercased() + next.dropFirst())" + } + } + } +} + +extension _AnimationBoxBase._Resolved._Style { + var cssValue: String { + switch self { + case let .timingCurve(c0x, c0y, c1x, c1y, _): + return "cubic-bezier(\(c0x), \(c0y), \(c1x), \(c1y))" + case .solver: + return "linear" + } + } +} + +extension _AnimationBoxBase._Resolved._RepeatStyle { + var jsValue: JSValue { + switch self { + case let .fixed(count, _): + return count.jsValue() + case .forever: + return JSObject.global.Infinity + } + } +} + extension AnyHTML { - func update(dom: DOMNode) { + 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? @@ -28,6 +65,14 @@ extension AnyHTML { // 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 { @@ -39,6 +84,88 @@ extension AnyHTML { } } + // Animate styles + if let style = attributes["style"], + let animation = transaction.animation + { + 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.. String { - let rgba = _ColorProxy(self).resolve(in: environment) - return "rgba(\(rgba.red * 255), \(rgba.green * 255), \(rgba.blue * 255), \(rgba.opacity))" + _ColorProxy(self).resolve(in: environment).cssValue + } +} + +extension AnyColorBox.ResolvedValue { + var cssValue: String { + "rgba(\(red * 255), \(green * 255), \(blue * 255), \(opacity))" } }