Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Comment thread
honghaoz marked this conversation as resolved.
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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String>

private let animate: (Renderable, Context, @escaping () -> Void) -> Void
Expand Down
10 changes: 8 additions & 2 deletions ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading