Skip to content

Commit 0474e3f

Browse files
committed
fixed reversible leave transitions
1 parent 36e2b12 commit 0474e3f

File tree

8 files changed

+78
-222
lines changed

8 files changed

+78
-222
lines changed

Examples/Basic/Sources/App/Views.swift

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ struct App {
2727
bindingViewCount -= 1
2828
}
2929
}
30-
hr()
30+
div {
31+
hr()
32+
ToggleTestView()
33+
hr()
34+
}
3135
// TODE: replaceChildren does not keep animations and similar going....
3236
// if counters.count > 1 {
3337
// span {}.attributes(.style(["display": "none"]))
@@ -70,7 +74,7 @@ struct App {
7074
}
7175
div(.style(["display": "flex", "flex-direction": "column", "border": "1px solid red"])) {
7276
ForEach(counters, key: { String($0) }) { counter in
73-
div(.style(["display": "flex", "flex-direction": "column"])) {
77+
div {
7478
h3 { "Counter \(counter)" }
7579
Counter(count: counter)
7680
br()
@@ -168,3 +172,28 @@ struct TestObjectView {
168172
span { "Via optional environment object: \(optionalData?.name ?? "")" }
169173
}
170174
}
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+
}

Sources/ElementaryDOM/FLIP/FLIPLayoutObserver.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ final class FLIPLayoutObserver: DOMLayoutObserver {
2525

2626
func setLeaveStatus(_ node: DOM.Node, isLeaving: Bool, context: inout _RenderContext) {
2727
logTrace("setting leave status for node \(node) to \(isLeaving)")
28-
context.scheduler.flip.markAsLeaving(node, isReentering: !isLeaving)
28+
if isLeaving {
29+
context.scheduler.flip.markAsLeaving(node)
30+
} else {
31+
context.scheduler.flip.markAsReentering(node)
32+
}
2933
}
3034

3135
func didLayoutChildren(parent: DOM.Node, entries: [ContainerLayoutPass.Entry], context: inout _CommitContext) {

Sources/ElementaryDOM/FLIP/FLIPScheduler.swift

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ final class FLIPScheduler {
44
// NOTE: extend this to support css properties as well - for now it is always the bounding rect stuff
55
private var scheduledAnimations: [DOM.Node: ScheduledNode] = [:]
66
private var runningAnimations: [DOM.Node: GeometryAnimation] = [:]
7+
private var absolutePositionOriginals: [DOM.Node: PreviousStyleValues] = [:]
78
private var firstWindowScrollOffset: (x: Double, y: Double)? = nil
89

910
init(dom: any DOM.Interactor) {
@@ -44,11 +45,12 @@ final class FLIPScheduler {
4445

4546
func markAsRemoved(_ node: DOM.Node) {
4647
scheduledAnimations.removeValue(forKey: node)
48+
absolutePositionOriginals.removeValue(forKey: node)
4749
let running = runningAnimations.removeValue(forKey: node)
4850
running?.cancelAll()
4951
}
5052

51-
func markAsLeaving(_ node: DOM.Node, isReentering: Bool = false) {
53+
func markAsLeaving(_ node: DOM.Node) {
5254
assert(scheduledAnimations[node] != nil, "node not scheduled for animation")
5355

5456
if dom.needsAbsolutePositioning(node) {
@@ -57,6 +59,14 @@ final class FLIPScheduler {
5759
}
5860
}
5961

62+
func markAsReentering(_ node: DOM.Node) {
63+
assert(scheduledAnimations[node] != nil, "node not scheduled for animation")
64+
65+
if let style = absolutePositionOriginals.removeValue(forKey: node) {
66+
scheduledAnimations[node]?.layoutAction = .undoMoveAbsolute(style: style)
67+
}
68+
}
69+
6070
func commitScheduledAnimations(context: inout _CommitContext) {
6171
let scroll = dom.getScrollOffset()
6272
let firstWindowScroll = firstWindowScrollOffset ?? (x: Double(scroll.x), y: Double(scroll.y))
@@ -82,7 +92,6 @@ final class FLIPScheduler {
8292

8393
for (node, animation) in scheduledAnimations {
8494
// TODO: find a good way to preserve velocities of redirected animations
85-
// TODO: preserve previous position if it was absolute
8695
// undo all running animations that are effected
8796
runningAnimations[node]?.cancelAll()
8897

@@ -91,8 +100,9 @@ final class FLIPScheduler {
91100
continue
92101
case .moveAbsolute(let rect):
93102
let previousValues = context.dom.fixAbsolutePosition(node, toRect: rect)
94-
// TODO: store previousValues for reversal when animation completes
95-
_ = previousValues
103+
absolutePositionOriginals[node] = previousValues
104+
case .undoMoveAbsolute(let style):
105+
context.dom.undoFixAbsolutePosition(node, style: style)
96106
}
97107
}
98108
}
@@ -159,6 +169,7 @@ private extension FLIPScheduler {
159169
enum NodeLayoutAction {
160170
case none
161171
case moveAbsolute(rect: DOM.Rect)
172+
case undoMoveAbsolute(style: PreviousStyleValues)
162173
}
163174

164175
struct PreviousStyleValues {
@@ -316,7 +327,7 @@ extension DOM.Interactor {
316327
func needsAbsolutePositioning(_ node: DOM.Node) -> Bool {
317328
let computedStyle = makeComputedStyleAccessor(node)
318329
let position = computedStyle.get("position")
319-
return position != "absolute" && position != "fixed"
330+
return !position.utf8Equals("absolute") && !position.utf8Equals("fixed")
320331
}
321332

322333
func getAbsolutePositionCoordinates(_ node: DOM.Node) -> DOM.Rect {
@@ -362,4 +373,18 @@ private extension DOM.Interactor {
362373

363374
return previousValues
364375
}
376+
377+
func undoFixAbsolutePosition(_ node: DOM.Node, style: FLIPScheduler.PreviousStyleValues) {
378+
let stylePosition = makeStyleAccessor(node, cssName: "position")
379+
let styleLeft = makeStyleAccessor(node, cssName: "left")
380+
let styleTop = makeStyleAccessor(node, cssName: "top")
381+
let styleWidth = makeStyleAccessor(node, cssName: "width")
382+
let styleHeight = makeStyleAccessor(node, cssName: "height")
383+
384+
stylePosition.set(style.position)
385+
styleLeft.set(style.left)
386+
styleTop.set(style.top)
387+
styleWidth.set(style.width)
388+
styleHeight.set(style.height)
389+
}
365390
}

Sources/ElementaryDOM/Interop/View+DOMEvents.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ public extension View {
1212
onEvent(DOMEventHandlers.Click.self, handler: handler)
1313
}
1414

15+
consuming func onClick(_ handler: @escaping () -> Void) -> some View {
16+
onClick { _ in handler() }
17+
}
18+
1519
consuming func onKeyDown(_ handler: @escaping (KeyboardEvent) -> Void) -> some View {
1620
onEvent(DOMEventHandlers.KeyDown.self, handler: handler)
1721
}

Sources/ElementaryDOM/Transition/Transition+builtin.swift

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,6 @@ public struct FadeTransition: Transition {
44
}
55
}
66

7-
public struct SlideInTransition: Transition {
8-
public func body(content: Content, phase: TransitionPhase) -> some View {
9-
content.offset(x: phase.isIdentity ? 0 : 100)
10-
}
11-
}
12-
137
extension Transition where Self == FadeTransition {
148
public static var fade: Self { FadeTransition() }
159
}
16-
17-
extension Transition where Self == SlideInTransition {
18-
public static var slideIn: Self { SlideInTransition() }
19-
}

Sources/ElementaryDOM/Transition/_TransitionNode.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ public final class _TransitionNode<T: Transition, V: View>: _Reconcilable {
66
private var placeholderNode: _PlaceholderNode?
77
// a transition can theoretically duplicate the content node, but it will be rare
88
private var additionalPlaceholderNodes: [_PlaceholderNode] = []
9+
private var currentRemovalAnimationTime: Double?
910

1011
init(view: consuming _TransitionView<T, V>, context: borrowing _ViewContext, reconciler: inout _RenderContext) {
1112
self.value = view
@@ -93,7 +94,11 @@ public final class _TransitionNode<T: Transition, V: View>: _Reconcilable {
9394
reconciler: &reconciler
9495
)
9596

96-
reconciler.transaction.addAnimationCompletion(criteria: .removed) { [scheduler = reconciler.scheduler] in
97+
currentRemovalAnimationTime = reconciler.currentFrameTime
98+
99+
reconciler.transaction.addAnimationCompletion(criteria: .removed) {
100+
[scheduler = reconciler.scheduler, frameTime = currentRemovalAnimationTime] in
101+
guard let currentTime = self.currentRemovalAnimationTime, currentTime == frameTime else { return }
97102
// TODO: think if this is the right scheduling, we remove the node in the frame after we flush the final values
98103
// probably correct, actually...
99104
scheduler.registerAnimation(
@@ -105,6 +110,7 @@ public final class _TransitionNode<T: Transition, V: View>: _Reconcilable {
105110
)
106111
}
107112
case .cancelRemoval:
113+
currentRemovalAnimationTime = nil
108114
// TODO: check this, stuff is for sure missing for reversible transitions
109115
node?.apply(.cancelRemoval, &reconciler)
110116
T.Body._patchNode(

0 commit comments

Comments
 (0)