Skip to content

Commit 5dd51e2

Browse files
authored
automagic FLIP animations for container layout changes (#33)
* wip - FLIP animations * leaving nodes mostly working * fixed removal FLIP glitch * fixed scroll offset trouble (viewport) * fixed reversible leave transitions * a bit of housekeeping * readme
1 parent f341c4a commit 5dd51e2

34 files changed

+1116
-155
lines changed

Examples/Basic/Public/index.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@
33
<head>
44
<title>Basic Embedded Elementary demo</title>
55
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<style>
7+
/* *,
8+
*::before,
9+
*::after {
10+
box-sizing: border-box;
11+
} */
12+
body {
13+
position: relative;
14+
}
15+
</style>
616
</head>
717

8-
<body>
18+
<body id="app">
19+
<!-- <div id="app" style="position: relative; margin: 10px"></div> -->
920
<script type="module">
1021
const module = await import("./lib/example/index.js");
1122
module.init();

Examples/Basic/Sources/App/Views.swift

Lines changed: 80 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,6 @@ struct App {
1212
@State var bindingViewCount = 1
1313

1414
var body: some View {
15-
div {
16-
AnimationsView()
17-
hr()
18-
TextField(value: #Binding(data.name))
19-
20-
div {
21-
p { "Via Binding: \(data.name)" }
22-
p { TestValueView() }
23-
p { TestObjectView() }
24-
}
25-
.environment(#Key(\.myText), data.name)
26-
.environment(data)
27-
}
28-
.onChange(of: bindingViewCount) { oldValue, newValue in
29-
print("bindingViewCount changed to \(oldValue) -> \(newValue)")
30-
}
31-
.onChange(of: bindingViewCount) {
32-
if bindingViewCount > 5 {
33-
data.name = "Binding View Count > 5"
34-
}
35-
}
36-
37-
hr()
3815
div {
3916
for _ in 0..<bindingViewCount {
4017
BindingsView()
@@ -50,7 +27,11 @@ struct App {
5027
bindingViewCount -= 1
5128
}
5229
}
53-
hr()
30+
div {
31+
hr()
32+
ToggleTestView()
33+
hr()
34+
}
5435
// TODE: replaceChildren does not keep animations and similar going....
5536
// if counters.count > 1 {
5637
// span {}.attributes(.style(["display": "none"]))
@@ -75,25 +56,60 @@ struct App {
7556

7657
div {
7758
hr()
59+
div {
60+
button { "Add counter" }
61+
.onClick { _ in
62+
nextCounterName += 1
63+
withAnimation {
64+
counters.append(nextCounterName)
65+
}
66+
}
7867

79-
ForEach(counters, key: { String($0) }) { counter in
80-
div {
81-
h3 { "Counter \(counter)" }
82-
Counter(count: counter)
83-
br()
84-
button { "Remove counter" }
85-
.onClick { _ in
86-
counters.removeAll { $0 == counter }
68+
button { "Shuffle" }
69+
.onClick { _ in
70+
withAnimation {
71+
counters.shuffle()
8772
}
88-
hr()
89-
}
73+
}
9074
}
91-
92-
button { "Add counter" }
93-
.onClick { _ in
94-
nextCounterName += 1
95-
counters.append(nextCounterName)
75+
div(.style(["display": "flex", "flex-direction": "column", "border": "1px solid red"])) {
76+
ForEach(counters, key: { String($0) }) { counter in
77+
div {
78+
h3 { "Counter \(counter)" }
79+
Counter(count: counter)
80+
br()
81+
button { "Remove counter" }
82+
.onClick { _ in
83+
withAnimation(.linear(duration: 2)) {
84+
counters.removeAll { $0 == counter }
85+
}
86+
}
87+
hr()
88+
}.transition(.fade)
9689
}
90+
}.animateContainerLayout()
91+
}
92+
93+
div {
94+
AnimationsView()
95+
hr()
96+
TextField(value: #Binding(data.name))
97+
98+
div {
99+
p { "Via Binding: \(data.name)" }
100+
p { TestValueView() }
101+
p { TestObjectView() }
102+
}
103+
.environment(#Key(\.myText), data.name)
104+
.environment(data)
105+
}
106+
.onChange(of: bindingViewCount) { oldValue, newValue in
107+
print("bindingViewCount changed to \(oldValue) -> \(newValue)")
108+
}
109+
.onChange(of: bindingViewCount) {
110+
if bindingViewCount > 5 {
111+
data.name = "Binding View Count > 5"
112+
}
97113
}
98114
}
99115
}
@@ -156,3 +172,28 @@ struct TestObjectView {
156172
span { "Via optional environment object: \(optionalData?.name ?? "")" }
157173
}
158174
}
175+
176+
@View
177+
struct ToggleTestView {
178+
@State var isVisible: Bool = false
179+
180+
var body: some View {
181+
div(.style(["display": "flex", "flex-direction": "column", "border": "1px solid blue"])) {
182+
button { "Toggle" }
183+
.onClick {
184+
withAnimation(.snappy(duration: 2)) {
185+
isVisible.toggle()
186+
}
187+
}
188+
189+
span { "start " }
190+
if isVisible {
191+
span { "middle " }
192+
.transition(.fade)
193+
}
194+
195+
span { "end" }
196+
197+
}.animateContainerLayout()
198+
}
199+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
import ElementaryDOM
22
import JavaScriptKit
33

4-
App().mount(in: .body)
4+
App().mount(in: .cssSelector("#app"))

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Create client-side web apps in Swift that weigh less than 200 kB.
77
## 🚧 Work In Progress 🚧
88
Based on the [swift.org WebAssembly SDKs](https://forums.swift.org/t/swift-sdks-for-webassembly-now-available-on-swift-org/80405), [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit), and [Elementary](https://github.com/sliemeobn/elementary).
99

10-
For embedded builds, a recent main or 6.2 snapshot with matching *Swift SDKs for WebAssembly* from [swift.org](https://www.swift.org/install) is required.
10+
For embedded builds, Swift 6.2 or later with matching *Swift SDKs for WebAssembly* from [swift.org](https://www.swift.org/install) is required.
1111

1212
> [!IMPORTANT]
1313
> ElementaryDOM is a passion project under active development.\
@@ -33,12 +33,15 @@ For embedded builds, a recent main or 6.2 snapshot with matching *Swift SDKs for
3333
- ~~transitions and animations (ideally CSS-based, probably svelte-like custom easing functions applied through WAAPI)~~
3434
- ~~better control over animations~~
3535
- ~~somehow migrate over to "var body" instead of "var content" (what was I thinking....)~~
36-
- automatic FLIP animations for certain layout changes (child-layout after changes, maybe size of containers)
36+
- ~~automatic FLIP animations for certain layout changes (child-layout after changes, maybe size of containers)~~
37+
- fix those Foundation imports, review thread-local + mutex usage
38+
- more built-in animatable CSS modifiers (colors, borders, borders?, blur)
3739
- basic phaseAnimator implementations
38-
- think about auto-flip animation for custom CSS values
40+
- implement auto-flip animation for custom CSS values (on value triggers)
41+
- maybe add "animateContainerLayout" modifier with value trigger (eg: to animate changes without child-changes)
3942
- support for combined and reversible transitions
4043
- mutli-select bindings (options, radio-buttons, tagged check boxes, ...)
41-
- proper unit testing (once APIs firm up a bit more, partially started)
44+
- more unit testing (FLIP handing, animations, reactivity, ...)
4245
- implement @ViewEquatableIgnored
4346
- split out JavaScriptKit stuff in separate module to contain spread, maybe one day we can switch to faster interop somehow
4447
- add basic docs, a good intro readme, and push a 0.1 out the door! (probably best to wait for Swift 6.2 to drop)

Sources/ElementaryDOM/Animation/AnimatedValue.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -225,19 +225,19 @@ private func calculateAnimationAtTime(
225225
}
226226

227227
internal extension AnimatedValue {
228-
mutating func setValueAndReturnIfAnimationWasStarted(_ value: Value, context: inout _RenderContext) -> Bool {
228+
mutating func setValueAndReturnIfAnimationWasStarted(_ value: Value, transaction: borrowing Transaction, frameTime: Double) -> Bool {
229229
guard value != currentTarget else { return false }
230230

231231
let wasAnimating = isAnimating
232232

233-
if !context.transaction.disablesAnimation,
234-
let animation = context.transaction.animation
235-
{
233+
// TODO: really think about disablesAnimation flag and what it means - we need this at least for FLIP for now
234+
//!transaction.disablesAnimation,
235+
if let animation = transaction.animation {
236236
self.animate(
237237
to: value,
238-
startTime: context.currentFrameTime,
238+
startTime: frameTime,
239239
animation: animation,
240-
tracker: context.transaction._animationTracker
240+
tracker: transaction._animationTracker
241241
)
242242
} else {
243243
self.setValue(value)

Sources/ElementaryDOM/DOM/DOM+Types.swift

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
extension DOM {
2-
public struct Node {
2+
public struct Node: Hashable {
33
let ref: AnyObject
4+
5+
public func hash(into hasher: inout Hasher) {
6+
hasher.combine(ObjectIdentifier(ref))
7+
}
8+
9+
public static func == (lhs: Node, rhs: Node) -> Bool {
10+
ObjectIdentifier(lhs.ref) == ObjectIdentifier(rhs.ref)
11+
}
412
}
513

614
public struct Event {
@@ -11,6 +19,20 @@ extension DOM {
1119
let ref: AnyObject
1220
}
1321

22+
public struct Rect: Equatable {
23+
public var x: Double
24+
public var y: Double
25+
public var width: Double
26+
public var height: Double
27+
28+
public init(x: Double, y: Double, width: Double, height: Double) {
29+
self.x = x
30+
self.y = y
31+
self.width = width
32+
self.height = height
33+
}
34+
}
35+
1436
public enum PropertyValue {
1537
case string(String)
1638
case number(Double)
@@ -61,4 +83,18 @@ extension DOM {
6183
_set(value)
6284
}
6385
}
86+
87+
public struct ComputedStyleAccessor {
88+
let _get: (String) -> String
89+
90+
init(
91+
get: @escaping (String) -> String
92+
) {
93+
self._get = get
94+
}
95+
96+
func get(_ cssName: String) -> String {
97+
_get(cssName)
98+
}
99+
}
64100
}

Sources/ElementaryDOM/DOM/DOMInteractor.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public enum DOM {
1010

1111
func makePropertyAccessor(_ node: Node, name: String) -> PropertyAccessor
1212
func makeStyleAccessor(_ node: Node, cssName: String) -> StyleAccessor
13+
func makeComputedStyleAccessor(_ node: Node) -> ComputedStyleAccessor
1314

1415
// Fine-grained style property operations
1516
func setStyleProperty(_ node: Node, name: String, value: String)
@@ -24,6 +25,13 @@ public enum DOM {
2425

2526
func animateElement(_ element: Node, _ effect: Animation.KeyframeEffect, onFinish: @escaping () -> Void) -> Animation
2627

28+
// Measurement API for FLIP animations
29+
func getBoundingClientRect(_ node: Node) -> Rect
30+
func getOffsetParent(_ node: Node) -> Node?
31+
32+
// Scroll offset API for FLIP animations
33+
func getScrollOffset() -> (x: Double, y: Double)
34+
2735
// Low-level DOM-like event listener APIs
2836
func addEventListener(_ node: Node, event: String, sink: EventSink)
2937
func removeEventListener(_ node: Node, event: String, sink: EventSink)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
final class FLIPAnimation<Value: CSSAnimatable> {
2+
private var node: DOM.Node
3+
private var animatedValue: AnimatedValue<Value>
4+
private var domAnimation: DOM.Animation?
5+
private var isDirty: Bool
6+
7+
var isCompleted: Bool {
8+
!animatedValue.isAnimating
9+
}
10+
11+
init(node: DOM.Node, first: Value, last: Value, transaction: Transaction, frameTime: Double) {
12+
self.node = node
13+
self.animatedValue = AnimatedValue(value: first)
14+
15+
_ = self.animatedValue.setValueAndReturnIfAnimationWasStarted(last, transaction: transaction, frameTime: frameTime)
16+
isDirty = true
17+
}
18+
19+
func cancel() {
20+
domAnimation?.cancel()
21+
domAnimation = nil
22+
animatedValue.cancelAnimation()
23+
}
24+
25+
func commit(context: inout _CommitContext) {
26+
if isDirty {
27+
logTrace("committing dirty animation \(Value.CSSValue.styleKey)")
28+
isDirty = false
29+
let value = animatedValue.nextCSSAnimationValue(frameTime: context.currentFrameTime)
30+
31+
switch value {
32+
case .single(_):
33+
logTrace("cancelling animation \(Value.CSSValue.styleKey)")
34+
domAnimation?.cancel()
35+
domAnimation = nil
36+
case .animated(let track):
37+
let effect = DOM.Animation.KeyframeEffect(.animated(track), isFirst: false)
38+
if let domAnimation = domAnimation {
39+
domAnimation.update(effect)
40+
} else {
41+
// TODO: find a better way to schedule a callback here
42+
domAnimation = context.dom.animateElement(node, effect) { [scheduler = context.scheduler] in
43+
scheduler.registerAnimation(
44+
AnyAnimatable { context in
45+
logTrace("CSS animation of \(Value.CSSValue.styleKey) completed, marking dirty")
46+
self.animatedValue.progressToTime(context.currentFrameTime)
47+
self.isDirty = true
48+
// TODO: fix this nonsense
49+
context.scheduler.addCommitAction(CommitAction { _ in })
50+
return .completed
51+
}
52+
)
53+
}
54+
}
55+
}
56+
}
57+
}
58+
}

0 commit comments

Comments
 (0)