diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd4562..efab255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ completion is also called when its animation is torn down before finishing (superseded, reset, or the layer leaving the layer tree). +### Changes + +- Slide transitions now continue a revival from wherever the removal left the renderable, for any side configuration: + the `from` side applies only to fresh insertions, and a renderable that fully slid out re-enters from its exit side. + The insert transition context gains `revivalPosition`, the model position captured for taking-over transitions. + ## [0.0.5](https://github.com/honghaoz/ComposeUI/releases/tag/0.0.5) (2026-08-08) ### Breaking Changes diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift index a4e7d3b..a3f7db6 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift @@ -48,14 +48,19 @@ public extension RenderableTransition { /// For removal, the renderable slides from its current frame to outside the content view on the `to` side (or `from` /// when `to` is nil). /// - /// Reviving a renderable while its slide-out is in flight continues the motion: the insertion's entry offset cancels - /// the leftover exit offset, so the rendered position doesn't jump, and both additive animations decay into the - /// resting position. The two offsets only cancel when the renderable enters from the side it exits to, so a - /// transition that slides out to a different side than it slides in from doesn't take over an in-flight removal, and - /// a revival snaps to the resting position before sliding in. + /// Reviving a renderable while its slide-out is in flight continues the motion: the insertion's offset from the + /// removal's model position cancels the model change, so the rendered position doesn't jump, and the leftover exit + /// offset keeps decaying on top while both animations settle into the resting position. This holds for any side + /// configuration, so a revival re-enters from wherever the removal left it: a renderable that fully slid out + /// re-enters from its exit side, and the `from` side only applies to fresh insertions. /// - /// A zero-duration timing applies the end frame and completes immediately when there is no delay. With a delay, - /// the end frame is scheduled as a snap that applies right after the delay window. + /// The composed revival motion's quality depends on the timings: curves that start at rest (springs, ease-in-out) + /// keep the velocity continuous, an equal-duration linear pair cancels to a standstill until the leftover decays, + /// and strongly mismatched durations can overshoot the resting position before settling. + /// + /// A zero-duration timing applies the end frame and completes immediately when there is no delay, and a zero-duration + /// revival also clears the leftover exit animations so the snap lands at rest. With a delay, the end frame is + /// scheduled as a snap that applies right after the delay window. /// /// - Parameters: /// - from: The side of the slide transition to slide from. @@ -69,35 +74,49 @@ public extension RenderableTransition { timing: AnimationTiming = .spring(), options: RenderableTransition.Options = .both) -> Self { - let entersFromExitSide = (toSide ?? fromSide) == fromSide - return RenderableTransition( - insert: options.contains(.insert) ? InsertTransition(takesOverKeyPaths: entersFromExitSide ? ["position"] : []) { renderable, context, completion in + RenderableTransition( + insert: options.contains(.insert) ? InsertTransition(takesOverKeyPaths: ["position"]) { renderable, context, completion in let layer = renderable.layer let targetFrame = context.targetFrame guard timing.timing.duration > 0 || timing.delay > 0 else { + if context.revivalPosition != nil { + // the taken-over leftover exit animations would render the snapped model off the target until they decay, + // so a snap clears them + layer.removeAnimations(forKeyPath: "position") + } renderable.setFrame(targetFrame) completion() return } - let initialFrame: CGRect - switch fromSide { - case .top: - initialFrame = targetFrame.translate(dy: -targetFrame.maxY - overshoot) - case .bottom: - initialFrame = targetFrame.translate(dy: context.contentView.bounds().height - targetFrame.minY + overshoot) - case .left: - initialFrame = targetFrame.translate(dx: -targetFrame.maxX - overshoot) - case .right: - initialFrame = targetFrame.translate(dx: context.contentView.bounds().width - targetFrame.minX + overshoot) + let startPosition: CGPoint + if let revivalPosition = context.revivalPosition { + // a revival continues from the removal's model position: the offset from that position cancels the model + // change exactly, so the rendered position doesn't move at the revival instant, and the removal's leftover + // offset keeps decaying on top + startPosition = revivalPosition + } else { + let startFrame: CGRect + switch fromSide { + case .top: + startFrame = targetFrame.translate(dy: -targetFrame.maxY - overshoot) + case .bottom: + startFrame = targetFrame.translate(dy: context.contentView.bounds().height - targetFrame.minY + overshoot) + case .left: + startFrame = targetFrame.translate(dx: -targetFrame.maxX - overshoot) + case .right: + startFrame = targetFrame.translate(dx: context.contentView.bounds().width - targetFrame.minX + overshoot) + } + startPosition = layer.position(from: startFrame) } - renderable.setFrame(initialFrame) + + renderable.setFrame(targetFrame) layer.animate( keyPath: "position", timing: timing, - from: { $0.position(from: $0.frame) - $0.position(from: targetFrame) }, + from: { startPosition - $0.position(from: targetFrame) }, to: { _ in .zero }, model: { $0.position(from: targetFrame) }, updateAnimation: { diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift index 804cc4b..8bb2b26 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift @@ -42,8 +42,19 @@ public struct RenderableTransition { /// The target frame of the renderable. public let targetFrame: CGRect + /// The renderable's root-layer model position before the render pass applied `targetFrame`, set when this + /// insertion revives a removing renderable and takes over its residue (see `takesOverKeyPaths`). + /// `nil` for a fresh insertion, or a revival that was reset. + public let revivalPosition: CGPoint? + /// The content view that the renderable is being inserted into. public private(set) weak var contentView: ComposeView! + + init(targetFrame: CGRect, revivalPosition: CGPoint? = nil, contentView: ComposeView!) { + self.targetFrame = targetFrame + self.revivalPosition = revivalPosition + self.contentView = contentView + } } /// The key paths of a revived renderable's in-flight removal state that this transition takes over. @@ -55,8 +66,12 @@ public struct RenderableTransition { /// animations, or by adding additive animations that compose with them. Otherwise the framework first restores /// the renderable to its resting state via the remove transition's `resetForReuse`. /// + /// A takeover matches key paths, not animation shapes: continuing by composition assumes the removal's residue + /// is additive, which the built-in transitions' animations satisfy. + /// /// The renderable's content update runs before this transition, so content-written model values of the taken-over - /// properties land before this transition observes the state. + /// properties land before this transition observes the state. The insertion context's `revivalPosition` is + /// captured earlier, before the render pass applies the target frame. public let takesOverKeyPaths: Set private let animate: (Renderable, Context, @escaping () -> Void) -> Void diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index b685b9b..dec1981 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift @@ -1161,6 +1161,10 @@ open class ComposeView: BaseScrollView { // the insert transition that will animate this insertion, if any. let insertTransition = context.shouldAnimate(contentView: self, animationBehavior: animationBehavior) ? renderableItem.transition?.insert : nil + // the root-layer model position the removal left behind, captured before this pass applies the target frame, + // so a taking-over insert transition can anchor its animation to the removal's live state. + var revivalPosition: CGPoint? + if let removingRenderable = removingRenderableMap[id] { // found a matching removing renderable, should add it back to the renderable hierarchy. // cancelling the completion cancels the removal and clears it from `removingRenderableMap`. @@ -1169,7 +1173,9 @@ open class ComposeView: BaseScrollView { // the cancelled removal's residue is undone by whoever takes over the renderable: a taking-over insert // transition continues from the live in-flight state, otherwise the remove transition's `resetForReuse` // snaps the renderable to its resting state. - if !removingRenderable.removeTransition.isTakenOver(by: insertTransition) { + if removingRenderable.removeTransition.isTakenOver(by: insertTransition) { + revivalPosition = removingRenderable.renderable.layer.position + } else { removingRenderable.removeTransition.resetForReuse(renderable: removingRenderable.renderable) } @@ -1237,7 +1243,7 @@ open class ComposeView: BaseScrollView { insertTransition.animate( renderable: renderable, - context: RenderableTransition.InsertTransition.Context(targetFrame: newFrame, contentView: self), + context: RenderableTransition.InsertTransition.Context(targetFrame: newFrame, revivalPosition: revivalPosition, contentView: self), completion: completion.execute ) } else { diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+SlideTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+SlideTests.swift index 05b5260..0c192be 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+SlideTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+SlideTests.swift @@ -61,7 +61,7 @@ class RenderableTransition_SlideTests: XCTestCase { width: targetFrame.width, height: targetFrame.height ) - expect(layer.capturedFrame) == expectedInitialFrame + expect(layer.capturedFrame) == targetFrame let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() expect(layer.addedAnimationKey) == "position" @@ -99,7 +99,7 @@ class RenderableTransition_SlideTests: XCTestCase { width: targetFrame.width, height: targetFrame.height ) - expect(layer.capturedFrame) == expectedInitialFrame + expect(layer.capturedFrame) == targetFrame let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() expect(layer.addedAnimationKey) == "position" @@ -137,7 +137,7 @@ class RenderableTransition_SlideTests: XCTestCase { width: targetFrame.width, height: targetFrame.height ) - expect(layer.capturedFrame) == expectedInitialFrame + expect(layer.capturedFrame) == targetFrame let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() expect(layer.addedAnimationKey) == "position" @@ -175,7 +175,7 @@ class RenderableTransition_SlideTests: XCTestCase { width: targetFrame.width, height: targetFrame.height ) - expect(layer.capturedFrame) == expectedInitialFrame + expect(layer.capturedFrame) == targetFrame let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() expect(layer.addedAnimationKey) == "position" @@ -190,6 +190,130 @@ class RenderableTransition_SlideTests: XCTestCase { expect(animation.fillMode) == .both } + func test_insertTransition_revival_continuesFromRevivalPosition() throws { + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + + // an in-flight removal: the model frame is off-screen right, with a leftover additive animation attached + let removalFrame = targetFrame.translate(dx: Constants.contentSize.width - targetFrame.minX + Constants.overshoot) + let leftoverAnimation = CABasicAnimation(keyPath: "position") + leftoverAnimation.fromValue = layer.position(from: targetFrame) - layer.position(from: removalFrame) + leftoverAnimation.toValue = CGPoint.zero + leftoverAnimation.duration = 10 + leftoverAnimation.isAdditive = true + layer.add(leftoverAnimation, forKey: "position") + + // the framework applies the target frame as the model value before the transition runs + layer.frame = targetFrame + + let transition = RenderableTransition.slide( + from: .left, + to: .right, + overshoot: Constants.overshoot, + timing: Constants.timing, + options: .insert + ) + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context(targetFrame: targetFrame, revivalPosition: layer.position(from: removalFrame), contentView: contentView), + completion: {} + ) + + // the revival keeps the leftover animation and anchors its own offset to the removal's model position, cancelling + // the model change, so the renderable re-enters from the exit side + expect(layer.frame) == targetFrame + expect(layer.basicAnimations(forKeyPath: "position").count) == 2 + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGPoint) == layer.position(from: removalFrame) - layer.position(from: targetFrame) + expect(animation.toValue as? CGPoint) == .zero + expect(animation.isAdditive) == true + } + + func test_insertTransition_zeroDurationRevival_clearsLeftoverAndSnapsToTarget() throws { + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + + // an in-flight removal: the model frame is off-screen right, with a leftover additive animation attached + let removalFrame = targetFrame.translate(dx: Constants.contentSize.width - targetFrame.minX + Constants.overshoot) + let leftoverAnimation = CABasicAnimation(keyPath: "position") + leftoverAnimation.fromValue = layer.position(from: targetFrame) - layer.position(from: removalFrame) + leftoverAnimation.toValue = CGPoint.zero + leftoverAnimation.duration = 10 + leftoverAnimation.isAdditive = true + layer.add(leftoverAnimation, forKey: "position") + + // the framework applies the target frame as the model value before the transition runs + layer.frame = targetFrame + + let transition = RenderableTransition.slide( + from: .left, + to: .right, + overshoot: Constants.overshoot, + timing: .linear(duration: 0), + options: .insert + ) + + var completionCallCount = 0 + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context(targetFrame: targetFrame, revivalPosition: layer.position(from: removalFrame), contentView: contentView), + completion: { completionCallCount += 1 } + ) + + // the snap clears the taken-over leftover animations, so the renderable lands at rest at the target instead of + // rendering off it until the leftover decays + expect(layer.frame) == targetFrame + expect(layer.basicAnimations(forKeyPath: "position").count) == 0 + expect(completionCallCount) == 1 + } + + func test_insertTransition_delayedRevival_holdsAndKeepsLeftover() throws { + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + + // an in-flight removal: the model frame is off-screen right, with a leftover additive animation attached + let removalFrame = targetFrame.translate(dx: Constants.contentSize.width - targetFrame.minX + Constants.overshoot) + let leftoverAnimation = CABasicAnimation(keyPath: "position") + leftoverAnimation.fromValue = layer.position(from: targetFrame) - layer.position(from: removalFrame) + leftoverAnimation.toValue = CGPoint.zero + leftoverAnimation.duration = 10 + leftoverAnimation.isAdditive = true + layer.add(leftoverAnimation, forKey: "position") + + // the framework applies the target frame as the model value before the transition runs + layer.frame = targetFrame + + let transition = RenderableTransition.slide( + from: .left, + to: .right, + overshoot: Constants.overshoot, + timing: .linear(duration: Constants.duration, delay: 0.5), + options: .insert + ) + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context(targetFrame: targetFrame, revivalPosition: layer.position(from: removalFrame), contentView: contentView), + completion: {} + ) + + // the leftover keeps playing during the delay window while the scheduled insert offset holds the revival + // position through its fill mode, so the composed motion stays continuous until the insert begins + expect(layer.basicAnimations(forKeyPath: "position").count) == 2 + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGPoint) == layer.position(from: removalFrame) - layer.position(from: targetFrame) + expect(animation.toValue as? CGPoint) == .zero + expect(animation.isAdditive) == true + expect(animation.fillMode) == .both + + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) + } + // MARK: - Remove Transition func test_removeTransition_top() throws { @@ -538,7 +662,7 @@ class RenderableTransition_SlideTests: XCTestCase { let expectedTargetFrame = CGRect(origin: .zero, size: targetSize) let expectedInitialFrame = expectedTargetFrame.translate(dx: Constants.contentSize.width + Constants.overshoot) - expect(layer.capturedFrame) == expectedInitialFrame + expect(layer.capturedFrame) == expectedTargetFrame let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() expect(layer.addedAnimationKey) == "position" diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+TransitionTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+TransitionTests.swift index b42f7e2..c50158d 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+TransitionTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+TransitionTests.swift @@ -310,12 +310,12 @@ class ComposeView_TransitionTests: XCTestCase { /// animate opacity but declares the given taken-over key paths. private func makeResidueTransition(takesOverKeyPaths: Set, removedLayer: @escaping (CALayer) -> Void, - insertTransitionDidRun: @escaping () -> Void) -> RenderableTransition + insertTransitionDidRun: @escaping (RenderableTransition.InsertTransition.Context) -> Void) -> RenderableTransition { RenderableTransition( insert: RenderableTransition.InsertTransition(takesOverKeyPaths: takesOverKeyPaths) { renderable, context, completion in renderable.setFrame(context.targetFrame) - insertTransitionDidRun() + insertTransitionDidRun(context) completion() }, remove: RenderableTransition.RemoveTransition( @@ -341,10 +341,14 @@ class ComposeView_TransitionTests: XCTestCase { var removedLayer: CALayer? var insertTransitionRunCount = 0 + var insertContext: RenderableTransition.InsertTransition.Context? let transition = makeResidueTransition( takesOverKeyPaths: ["opacity"], removedLayer: { removedLayer = $0 }, - insertTransitionDidRun: { insertTransitionRunCount += 1 } + insertTransitionDidRun: { + insertTransitionRunCount += 1 + insertContext = $0 + } ) let makeContent: () -> ComposeContent = { @@ -377,6 +381,9 @@ class ComposeView_TransitionTests: XCTestCase { expect(removedLayer?.opacity) == 0 expect(removedLayer?.animation(forKey: "fade")) != nil expect(insertTransitionRunCount) == 1 + + // a taking-over insert receives the revival position, even when the taken-over residue doesn't animate position + expect(insertContext?.revivalPosition) == CGPoint(x: 50, y: 50) } func test_reinsertRemovingRenderable_insertTransitionDoesNotTakeOver_residueIsReset() { @@ -384,12 +391,16 @@ class ComposeView_TransitionTests: XCTestCase { var removedLayer: CALayer? var insertTransitionRunCount = 0 + var insertContext: RenderableTransition.InsertTransition.Context? // the insert transition takes over a different key path than the one the remove transition animates, // e.g. a slide insert reviving an opacity removal let transition = makeResidueTransition( takesOverKeyPaths: ["position"], removedLayer: { removedLayer = $0 }, - insertTransitionDidRun: { insertTransitionRunCount += 1 } + insertTransitionDidRun: { + insertTransitionRunCount += 1 + insertContext = $0 + } ) let makeContent: () -> ComposeContent = { @@ -421,6 +432,9 @@ class ComposeView_TransitionTests: XCTestCase { expect(removedLayer?.opacity) == 1 expect(removedLayer?.animation(forKey: "fade")) == nil expect(insertTransitionRunCount) == 1 + + // a reset revival provides no revival position: the insert starts fresh + expect(insertContext?.revivalPosition) == nil } func test_reinsertRemovingRenderable_slideTransition_insertComposesWithInFlightRemoval() throws { @@ -455,7 +469,7 @@ class ComposeView_TransitionTests: XCTestCase { expect(layer.position) == layer.position(from: CGRect(x: 0, y: 0, width: 100, height: 100)) } - func test_reinsertRemovingRenderable_crossSideSlideTransition_insertResets() throws { + func test_reinsertRemovingRenderable_crossSideSlideTransition_insertContinuesFromRemovalPosition() throws { let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) let makeContent: () -> ComposeContent = { @@ -472,17 +486,67 @@ class ComposeView_TransitionTests: XCTestCase { } contentView.refresh(animated: true) + // the remove transition is in flight, sliding the renderable out to the right: the model position is off-screen + // right, with a single additive animation holding the rendered position let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) expect(layer.basicAnimations(forKeyPath: "position").count) == 1 + let removalPosition = layer.position + expect(removalPosition.x) > 100 - // revive the renderable with an animated insert: the transition enters from a different side than it exits to, so - // the entry offset doesn't cancel the leftover exit offset. it doesn't take over the removal, and the revival - // resets the leftover instead, so the renderable slides in cleanly from the entry side. + // revive the renderable with an animated insert: the insert anchors its offset to the removal's model position, + // so the offset cancels the model change and the rendered position is continuous at the revival instant, even + // though the transition's entry side differs from its exit side. the leftover exit offset keeps decaying on top. contentView.setContent(content: makeContent) contentView.refresh(animated: true) - expect(layer.basicAnimations(forKeyPath: "position").count) == 1 - expect(layer.position) == layer.position(from: CGRect(x: 0, y: 0, width: 100, height: 100)) + let targetFrame = CGRect(x: 0, y: 0, width: 100, height: 100) + let animations = layer.basicAnimations(forKeyPath: "position") + expect(animations.count) == 2 + expect(layer.position) == layer.position(from: targetFrame) + + // the insert's offset starts from the removal's model position, towards the target: the revival re-enters from + // where the removal left it (the exit side), not from the configured entry side + let insertAnimation = try unwrap(animations.last) + expect(insertAnimation.fromValue as? CGPoint) == removalPosition - layer.position(from: targetFrame) + expect(insertAnimation.toValue as? CGPoint) == .zero + } + + func test_reinsertRemovingRenderable_crossSideSlideTransition_renderedPositionIsContinuous() throws { + let window = TestWindow() + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.contentView().addSubview(contentView) + + let makeContent: () -> ComposeContent = { + ColorNode(.red) + .transition(.slide(from: .left, to: .right, timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + + contentView.setContent(content: makeContent) + contentView.refresh(animated: false) + + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) + + // let the removal render, so the presentation is mid-flight + expect(layer.presentation()).toEventuallyNot(beNil()) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + let positionBefore = try unwrap(layer.presentation()).position + + // revive mid-flight and let the revival commit + contentView.setContent(content: makeContent) + contentView.refresh(animated: true) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + let positionAfter = try unwrap(layer.presentation()).position + + // the rendered position is continuous at the revival: the 10s linear motion drifts a few points between the + // samples, far from the content-width jump a restart from the entry side would show + expect(abs(positionAfter.x - positionBefore.x) < 25) == true + expect(abs(positionAfter.y - positionBefore.y) < 5) == true } func test_reinsertRemovingRenderable_opacityTransition_insertRetargetsFromInFlightState() throws { diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift index 95dfe96..1c6d48d 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift @@ -43,8 +43,8 @@ extension Playground { /// An interactive insert/remove page for verifying transition revivals. /// /// The transitions run slowly so a removal can be interrupted mid-flight, and the transition button cycles - /// through the kinds that revive differently: a fade retargets from the interrupted opacity, a same-side slide - /// continues the interrupted motion, and a cross-side slide snaps to the resting position before sliding in. + /// through a fade, which retargets from the interrupted opacity, and two slide configurations, which continue the + /// interrupted motion from wherever the removal left it and differ only in their entry and exit sides. /// A non-animated re-insert always snaps to the resting state. /// /// The page logs button taps, the box renderable's lifecycle events, and a continuous sample of @@ -61,9 +61,9 @@ extension Playground { /// A slide that enters from the side it exits to, which continues the interrupted motion on a revival. case slide - /// A slide that enters from a different side than it exits to. Its entry offset doesn't cancel the leftover - /// exit offset, so it doesn't take over an in-flight removal: a revival snaps to the resting position before - /// sliding in. + /// A slide that enters from a different side than it exits to. A revival continues the interrupted motion + /// from the removal's model frame, so the box returns from wherever it is instead of restarting from the + /// entry side. case crossSideSlide var title: String {