From 3b59722cdb727d03caeebcd2bed50a85f82c0fbf Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Sun, 6 Sep 2026 02:46:33 -0700 Subject: [PATCH 1/8] [transition] add scale transition with configurable anchor --- AGENTS.md | 21 +- CHANGELOG.md | 23 +- .../RenderableTransition+Opacity.swift | 23 +- .../RenderableTransition+Scale.swift | 254 ++++ .../RenderableTransition+Slide.swift | 95 +- .../RenderItem/RenderableTransition.swift | 10 +- .../ComposeUI/ComposeView/ComposeView.swift | 9 +- .../RenderableTransition+ScaleTests.swift | 1083 +++++++++++++++++ .../Playground+TransitionRevivalView.swift | 131 +- .../ViewController.swift | 7 +- .../ViewController.swift | 14 +- 11 files changed, 1553 insertions(+), 117 deletions(-) create mode 100644 ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift create mode 100644 ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift diff --git a/AGENTS.md b/AGENTS.md index cc276cb..be4f39e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,14 +57,19 @@ The render pipeline: a `ComposeView` hosts `ComposeContent`, lays out `ComposeNo - Place a new `ComposeNode` extension API in a dedicated file named after its concern (for example `ComposeNode+Transform.swift`), not in an unrelated extension file. - Comments must explain the decision, not just state a fact: prefer "X can happen, so we do Y (instead of Z)" over "X can happen". - No em-dashes (—) and no semicolons in code comments or markdown docs. Use commas, hyphens, colons, or separate sentences. -- Use `private enum Constants` at the bottom of the file for repeated literals and magic numbers where it improves clarity. For example: +- Use `private enum Constants` for repeated literals and magic numbers where it improves clarity, nested at the bottom of the primary scope. A top-level private `Constants` collides with the module's public `Constants` type. For example: ```swift - // MARK: - Constants + public class SomeType { - private enum Constants { + // APIs... - /// The spacing between the items. - static let spacing: CGFloat = 8 + // MARK: - Constants + + private enum Constants { + + /// The spacing between the items. + static let spacing: CGFloat = 8 + } } ``` @@ -83,7 +88,7 @@ The render pipeline: a `ComposeView` hosts `ComposeContent`, lays out `ComposeNo Before reporting a change complete, verify in order: 1. Focused tests pass: `cd ComposeUI && swift test --filter `. -2. New code has full test coverage, including guard/assertion paths and both branches of conditionals. Verify with `swift test --enable-code-coverage` + `xcrun llvm-cov report` on the touched files. +2. New code MUST have full test coverage, including guard/assertion paths and both branches of conditionals. Enumerate and test the logic's edge cases (boundary values, zero or empty inputs, interrupted in-flight states) and assert their observable outcomes. Full coverage must fall out of covering every case, not be the goal itself: a test can execute many lines without checking any corner case. Verify with `swift test --enable-code-coverage` + `xcrun llvm-cov report` on the touched files. 3. `make format` and `make lint` pass. 4. Cross-platform changes: both `AppKit` and `UIKit` conditional compilation paths build and are exercised by platform tests. 5. User-facing behavior changes have an entry under `Unreleased` in `CHANGELOG.md`. @@ -130,6 +135,10 @@ Hard-won rules from past corrections, grouped by theme. - On few-core CI runners, do not overlap simulator boot with compilation. Both are CPU-heavy, and contention makes the total slower than running them serially (build first, then boot). - To get CI telemetry without log access, emit `::notice::` workflow commands. They become check-run annotations readable via the public Checks API (capped at 10 annotations per step, so emit before noisy output). +## Cross-platform + +- Do not ship platform-dependent rendered output with a doc note. When a platform primitive differs (for example AppKit anchors view-backing layers at the bottom left corner while everything else anchors at the center), compensate in the implementation so the visual result matches across platforms, instead of documenting the difference. + ## Scripts - When rewriting or porting a script, audit the new version against the original behavior by behavior (selection logic, guard conditions, exit codes, environment propagation, output ordering), and disclose every intentional deviation. Do not assume a rewrite is equivalent because the happy path passes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8964947..a551382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,25 +4,16 @@ ### Breaking Changes -- Delayed animations are now scheduled with Core Animation's `beginTime` instead of a GCD timer. The animation is added - and the model value is set at dispatch, the layer's rendered output holds the pre-animation state for the delay window. - An interrupted in-flight opacity transition freezes at its sampled value for a delayed retargeting's delay window - instead of continuing to play, and a delayed spring retargeting launches from rest. -- Zero-duration transitions now call their completion. Without a delay, the end state applies and the completion runs - immediately. With a delay, the change is scheduled as a snap that applies right after the delay window. A transition - completion is also called when its animation is torn down before finishing (superseded, reset, or the layer leaving - the layer tree). +- Delayed animations are now scheduled with Core Animation's `beginTime` instead of a GCD timer. +- Zero-duration transitions now call their completion, and a completion is also called when its animation is torn down early. ### 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. -- `ComposeView` now adopts display scale changes on iOS/tvOS (for example, when the window moves to a screen with a - different scale) and re-renders, matching the existing macOS backing scale handling. -- `ComposeView.setNeedsRefresh(animated:)` now merges coalesced requests to non-animated when any request was - non-animated (previously the last request's flag won), so a scale-driven or window-driven snap is never animated by a - concurrent theme change. +- Added a scale transition, `.scale(from:anchor:timing:options:)`. +- Slide transitions now continue a revival from wherever the removal left the renderable. +- The insert transition context gains `revivalPosition` and `revivalTransform` for taking-over transitions. +- `ComposeView` now re-renders on display scale changes on iOS/tvOS, matching the existing macOS handling. +- `ComposeView.setNeedsRefresh(animated:)` now merges coalesced requests to non-animated when any request was non-animated. ## [0.0.5](https://github.com/honghaoz/ComposeUI/releases/tag/0.0.5) (2026-08-08) diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift index fa3f949..7351485 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift @@ -52,16 +52,19 @@ public extension RenderableTransition { options: RenderableTransition.Options = .both) -> RenderableTransition { RenderableTransition( - insert: options.contains(.insert) ? InsertTransition(takesOverKeyPaths: ["opacity"]) { renderable, context, completion in - renderable.setFrame(context.targetFrame) - - renderable.layer.retargetOpacity( - freshStartValue: Float(from), - targetValue: Float(to), - timing: timing, - completion: completion - ) - } : nil, + insert: options.contains(.insert) ? InsertTransition( + takesOverKeyPaths: ["opacity"], + animate: { renderable, context, completion in + renderable.setFrame(context.targetFrame) + + renderable.layer.retargetOpacity( + freshStartValue: Float(from), + targetValue: Float(to), + timing: timing, + completion: completion + ) + } + ) : nil, remove: options.contains(.remove) ? RemoveTransition( animatedKeyPaths: ["opacity"], animate: { renderable, _, completion in diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift new file mode 100644 index 0000000..6ab5280 --- /dev/null +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift @@ -0,0 +1,254 @@ +// +// RenderableTransition+Scale.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/1/26. +// Copyright © 2024 Honghao Zhang. +// +// MIT License +// +// Copyright (c) 2024 Honghao Zhang (github.com/honghaoz) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// + +import QuartzCore + +public extension RenderableTransition { + + /// Creates a scale transition. + /// + /// For insertion, the renderable starts at `from` scale and scales to its natural size at `targetFrame`. + /// For removal, the renderable scales from its current scale down to `from`. + /// + /// The scaling pivots about `anchor`, the point of the renderable that stays fixed while it scales: each + /// "transform.scale" animation is paired with a same-timing "transform.translation" compensation that keeps the + /// anchor fixed for any layer anchor point (AppKit anchors view-backing layers at a corner, unlike the center anchor + /// elsewhere). The compensation is skipped when it is zero, for a center anchor on a center-anchored layer. + /// + /// The scale is rendered by additive animations while the model transform rests at identity. The transition owns the + /// layer's transform and assumes no other writer touches it. + /// + /// Reviving a renderable while its scale-out is in flight continues the motion: the insertion's offset from the + /// removal's model scale cancels the model change, so the rendered scale doesn't jump, and the leftover exit offset + /// keeps decaying on top while both animations settle into the resting scale. An underdamped timing can overshoot, + /// which renders a momentarily mirrored scale when the composed scale crosses below zero. + /// + /// A zero-duration timing applies the end state 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 state is + /// scheduled as a snap that applies right after the delay window. + /// + /// - Parameters: + /// - from: The scale to insert from and remove to. Values above 1 zoom down into place. Defaults to 0. + /// - anchor: The point of the renderable that stays fixed while it scales. Defaults to `.center`. + /// - timing: The timing of the scale transition. + /// - options: The options for the scale transition. + static func scale(from: CGFloat = 0, + anchor: Layout.Alignment = .center, // TODO: replace with https://github.com/honghaoz/ChouTiUI/blob/master/ChouTiUI/Sources/ChouTiUI/Universal/Layout/UnitPoint.swift + timing: AnimationTiming = .spring(), + options: RenderableTransition.Options = .both) -> Self + { + RenderableTransition( + insert: options.contains(.insert) ? InsertTransition( + takesOverKeyPaths: Constants.animatedKeyPaths, + animate: { renderable, context, completion in + let layer = renderable.layer + + guard timing.timing.duration > 0 || timing.delay > 0 else { + if context.revivalTransform != nil { + // the taken-over leftover exit animations would render the snapped model off the resting scale until they + // decay, so a snap clears them + for keyPath in Constants.animatedKeyPaths { + layer.removeAnimations(forKeyPath: keyPath) + } + } + layer.restoreIdentityTransformIfNeeded() + renderable.setFrame(context.targetFrame) + completion() + return + } + + let startScale: CGFloat + let startTranslation: CGSize + if let revivalTransform = context.revivalTransform { + // a revival continues from the removal's model transform: the offsets from its scale and translation + // components cancel the model change exactly, so the rendered transform doesn't move at the revival instant, + // and the removal's leftover offsets keep decaying on top. + // the transition owns the transform and writes it through the "transform.scale" and "transform.translation" + // key paths, so the captured transform's x scale and translation components are those written values. + startScale = revivalTransform.m11 + startTranslation = CGSize(width: revivalTransform.m41, height: revivalTransform.m42) + } else { + startScale = from + startTranslation = layer.pivotTranslation(towards: anchor.unitPoint, for: from, size: context.targetFrame.size) + } + + layer.restoreIdentityTransformIfNeeded() + renderable.setFrame(context.targetFrame) + + layer.animate( + keyPath: "transform.scale", + timing: timing, + from: { _ in startScale - 1 }, + to: { _ in CGFloat(0) }, + model: { _ in CGFloat(1) }, + updateAnimation: { + $0.isAdditive = true + $0.delegate = AnimationDelegate(animationDidStop: { _, _ in + completion() + }) + } + ) + + // the pair shares the scale animation's timing, so the compensation tracks the rendered scale exactly and the + // completion can ride on the scale animation alone + if startTranslation != .zero { + layer.animate( + keyPath: "transform.translation", + timing: timing, + from: { _ in startTranslation }, + to: { _ in CGSize.zero }, + model: { _ in CGSize.zero }, + updateAnimation: { + $0.isAdditive = true + } + ) + } + } + ) : nil, + remove: options.contains(.remove) ? RemoveTransition( + animatedKeyPaths: Constants.animatedKeyPaths, + animate: { renderable, _, completion in + let layer = renderable.layer + let endTranslation = layer.pivotTranslation(towards: anchor.unitPoint, for: from, size: layer.bounds.size) + + guard timing.timing.duration > 0 || timing.delay > 0 else { + layer.setKeyPathValue("transform.scale", from) + if endTranslation != .zero { + layer.setKeyPathValue("transform.translation", endTranslation) + } + completion() + return + } + + layer.animate( + keyPath: "transform.scale", + timing: timing, + from: { ($0.value(forKeyPath: "transform.scale") as! CGFloat) - from }, // swiftlint:disable:this force_cast + to: { _ in CGFloat(0) }, + model: { _ in from }, + updateAnimation: { + $0.isAdditive = true + $0.delegate = AnimationDelegate(animationDidStop: { _, _ in + completion() + }) + } + ) + + if endTranslation != .zero { + layer.animate( + keyPath: "transform.translation", + timing: timing, + from: { ($0.value(forKeyPath: "transform.translation") as! CGSize) - endTranslation }, // swiftlint:disable:this force_cast + to: { _ in CGSize.zero }, + model: { _ in endTranslation }, + updateAnimation: { + $0.isAdditive = true + } + ) + } + }, + resetForReuse: { renderable in + renderable.layer.restoreIdentityTransformIfNeeded() + } + ) : nil + ) + } + + // MARK: - Constants + + /// The enum is nested so it doesn't collide with the module's public `Constants` type. + private enum Constants { + + /// The root-layer key paths the scale transition animates. + static let animatedKeyPaths: Set = ["transform.scale", "transform.translation"] + } +} + +private extension Layout.Alignment { + + /// The alignment as a point in the unit coordinate space, with the origin at the top left. + var unitPoint: CGPoint { + switch self { + case .center: + return CGPoint(x: 0.5, y: 0.5) + case .left: + return CGPoint(x: 0, y: 0.5) + case .right: + return CGPoint(x: 1, y: 0.5) + case .top: + return CGPoint(x: 0.5, y: 0) + case .bottom: + return CGPoint(x: 0.5, y: 1) + case .topLeft: + return CGPoint(x: 0, y: 0) + case .topRight: + return CGPoint(x: 1, y: 0) + case .bottomLeft: + return CGPoint(x: 0, y: 1) + case .bottomRight: + return CGPoint(x: 1, y: 1) + } + } +} + +private extension CALayer { + + /// The translation that keeps the layer's `pivot` unit point fixed when it renders at `scale`. + /// + /// The transform applies about the layer's `anchorPoint`, so scaling shifts every point except the anchor. The + /// compensation is the pivot's offset from the anchor, `(pivot - anchorPoint) * size`, scaled by how far the scale + /// is from resting. + /// + /// - Parameters: + /// - pivot: The unit point to keep fixed. + /// - scale: The rendered scale. + /// - size: The layer's rendered size. + /// - Returns: The compensating translation. Zero when the pivot coincides with the layer's anchor point. + func pivotTranslation(towards pivot: CGPoint, for scale: CGFloat, size: CGSize) -> CGSize { + CGSize( + width: (pivot.x - anchorPoint.x) * size.width * (1 - scale), + height: (pivot.y - anchorPoint.y) * size.height * (1 - scale) + ) + } + + /// Restores the layer's model transform to identity so a following frame application is well-defined. + /// + /// The framework resets a revived renderable's transform before the render pass applies frames, so this is a no-op + /// in framework flows. A direct invocation can still carry the removal's model transform, which would corrupt the + /// frame application, so the transition restores the transform itself. + func restoreIdentityTransformIfNeeded() { + guard !CATransform3DIsIdentity(transform) else { + return + } + disableActions(for: "transform") { + transform = CATransform3DIdentity + } + } +} diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift index a3f7db6..a6bcfab 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift @@ -75,58 +75,61 @@ public extension RenderableTransition { options: RenderableTransition.Options = .both) -> Self { RenderableTransition( - insert: options.contains(.insert) ? InsertTransition(takesOverKeyPaths: ["position"]) { renderable, context, completion in - let layer = renderable.layer - let targetFrame = context.targetFrame + insert: options.contains(.insert) ? InsertTransition( + takesOverKeyPaths: ["position"], + animate: { 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") + 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 } - renderable.setFrame(targetFrame) - completion() - return - } - 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) + 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) } - startPosition = layer.position(from: startFrame) - } - renderable.setFrame(targetFrame) + renderable.setFrame(targetFrame) - layer.animate( - keyPath: "position", - timing: timing, - from: { startPosition - $0.position(from: targetFrame) }, - to: { _ in .zero }, - model: { $0.position(from: targetFrame) }, - updateAnimation: { - $0.isAdditive = true - $0.delegate = AnimationDelegate(animationDidStop: { _, _ in - completion() - }) - } - ) - } : nil, + layer.animate( + keyPath: "position", + timing: timing, + from: { startPosition - $0.position(from: targetFrame) }, + to: { _ in .zero }, + model: { $0.position(from: targetFrame) }, + updateAnimation: { + $0.isAdditive = true + $0.delegate = AnimationDelegate(animationDidStop: { _, _ in + completion() + }) + } + ) + } + ) : nil, remove: options.contains(.remove) ? RemoveTransition( animatedKeyPaths: ["position"], animate: { renderable, context, completion in diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift index 8bb2b26..58dfcd0 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift @@ -28,7 +28,7 @@ // IN THE SOFTWARE. // -import Foundation +import QuartzCore /// A model contains the view insert and remove transition. public struct RenderableTransition { @@ -47,12 +47,18 @@ public struct RenderableTransition { /// `nil` for a fresh insertion, or a revival that was reset. public let revivalPosition: CGPoint? + /// The renderable's root-layer model transform before the render pass reset it to identity, 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 revivalTransform: CATransform3D? + /// 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!) { + init(targetFrame: CGRect, revivalPosition: CGPoint? = nil, revivalTransform: CATransform3D? = nil, contentView: ComposeView!) { self.targetFrame = targetFrame self.revivalPosition = revivalPosition + self.revivalTransform = revivalTransform self.contentView = contentView } } diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index f7fdf9e..fc654f8 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift @@ -1190,9 +1190,11 @@ 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. + // the root-layer model position and transform the removal left behind, captured before this pass applies the + // target frame and resets the transform to identity, so a taking-over insert transition can anchor its + // animation to the removal's live state. var revivalPosition: CGPoint? + var revivalTransform: CATransform3D? if let removingRenderable = removingRenderableMap[id] { // found a matching removing renderable, should add it back to the renderable hierarchy. @@ -1204,6 +1206,7 @@ open class ComposeView: BaseScrollView { // snaps the renderable to its resting state. if removingRenderable.removeTransition.isTakenOver(by: insertTransition) { revivalPosition = removingRenderable.renderable.layer.position + revivalTransform = removingRenderable.renderable.layer.transform } else { removingRenderable.removeTransition.resetForReuse(renderable: removingRenderable.renderable) } @@ -1272,7 +1275,7 @@ open class ComposeView: BaseScrollView { insertTransition.animate( renderable: renderable, - context: RenderableTransition.InsertTransition.Context(targetFrame: newFrame, revivalPosition: revivalPosition, contentView: self), + context: RenderableTransition.InsertTransition.Context(targetFrame: newFrame, revivalPosition: revivalPosition, revivalTransform: revivalTransform, contentView: self), completion: completion.execute ) } else { diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift new file mode 100644 index 0000000..7324909 --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift @@ -0,0 +1,1083 @@ +// +// RenderableTransition+ScaleTests.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/1/26. +// Copyright © 2024 Honghao Zhang. +// +// MIT License +// +// Copyright (c) 2024 Honghao Zhang (github.com/honghaoz) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// + +import QuartzCore + +import ChouTiTest + +@_spi(Private) @testable import ComposeUI + +class RenderableTransition_ScaleTests: XCTestCase { + + // MARK: - Insert Transition + + func test_insertTransition() throws { + // given: a layer renderable and a scale-in transition + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(timing: Constants.timing, options: .insert) + + let insertTransition = try transition.insert.unwrap() + let context = RenderableTransition.InsertTransition.Context(targetFrame: targetFrame, contentView: contentView) + + // when: the insert transition animates the renderable + insertTransition.animate(renderable: renderable, context: context, completion: {}) + + // then: the layer lands at the target frame with an additive scale animation growing in from 0 + expect(insertTransition.takesOverKeyPaths) == ["transform.scale", "transform.translation"] + expect(layer.capturedFrame) == targetFrame + expect(try CATransform3DIsIdentity(layer.capturedTransform.unwrap())) == true + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(layer.addedAnimationKey) == "transform.scale" + expect(layer.animationKeys()) == ["transform.scale"] + expect(animation.keyPath) == "transform.scale" + expect(animation.fromValue as? CGFloat) == -1 + expect(animation.toValue as? CGFloat) == 0 + expect(animation.timingFunction) == CAMediaTimingFunction(name: .linear) + expect(animation.duration) == Constants.duration + expect(animation.isAdditive) == true + expect(animation.isRemovedOnCompletion) == true + expect(animation.fillMode) == .both + + // then: the model transform rests at identity + expect(CATransform3DIsIdentity(layer.transform)) == true + } + + func test_insertTransition_customFrom() throws { + // given: a layer renderable and a scale-in transition zooming down from 1.5 + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(from: 1.5, timing: Constants.timing, options: .insert) + + // when: the insert transition animates the renderable + let context = RenderableTransition.InsertTransition.Context(targetFrame: targetFrame, contentView: contentView) + try transition.insert.unwrap().animate(renderable: renderable, context: context, completion: {}) + + // then: the additive scale animation starts from the custom scale's offset + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGFloat) == 0.5 + expect(animation.toValue as? CGFloat) == 0 + expect(layer.frame) == targetFrame + expect(CATransform3DIsIdentity(layer.transform)) == true + } + + func test_insertTransition_revival_continuesFromRevivalTransform() throws { + // given: a layer mid-removal in the framework flow: the model transform is already reset to identity, with a + // leftover additive scale animation attached and the removal's model scale captured in the revival transform + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + + let leftoverAnimation = CABasicAnimation(keyPath: "transform.scale") + leftoverAnimation.fromValue = CGFloat(1) - Constants.revivalScale + leftoverAnimation.toValue = CGFloat(0) + leftoverAnimation.duration = 10 + leftoverAnimation.isAdditive = true + layer.add(leftoverAnimation, forKey: "transform.scale") + + // the framework applies the target frame as the model value before the transition runs + layer.frame = targetFrame + + // when: an insert transition animates with a revival transform + let transition = RenderableTransition.scale(timing: Constants.timing, options: .insert) + let revivalScale = Constants.revivalScale + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context( + targetFrame: targetFrame, + revivalTransform: CATransform3DMakeScale(revivalScale, revivalScale, revivalScale), + contentView: contentView + ), + completion: {} + ) + + // then: the revival keeps the leftover animation and anchors its own offset to the removal's model scale, + // cancelling the model change, so the renderable continues from the removal's scale instead of the configured + // `from` scale + expect(layer.frame) == targetFrame + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 2 + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGFloat) == revivalScale - 1 + expect(animation.toValue as? CGFloat) == 0 + expect(animation.isAdditive) == true + expect(CATransform3DIsIdentity(layer.transform)) == true + } + + func test_insertTransition_revival_directInvocation_restoresModelScale() throws { + // given: a layer mid-removal invoked directly: the model transform still carries the removal's scale, with a + // leftover additive scale animation attached + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + + let leftoverAnimation = CABasicAnimation(keyPath: "transform.scale") + leftoverAnimation.fromValue = CGFloat(1) - Constants.revivalScale + leftoverAnimation.toValue = CGFloat(0) + leftoverAnimation.duration = 10 + leftoverAnimation.isAdditive = true + layer.add(leftoverAnimation, forKey: "transform.scale") + + layer.setValue(Constants.revivalScale, forKeyPath: "transform.scale") + + // when: an insert transition animates with a revival transform + let transition = RenderableTransition.scale(timing: Constants.timing, options: .insert) + let revivalScale = Constants.revivalScale + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context( + targetFrame: targetFrame, + revivalTransform: CATransform3DMakeScale(revivalScale, revivalScale, revivalScale), + contentView: contentView + ), + completion: {} + ) + + // then: the model scale is restored to identity before the frame applies, so the frame application is well-defined, + // and the additive offset continues from the removal's scale + expect(layer.frame) == targetFrame + expect(CATransform3DIsIdentity(layer.transform)) == true + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 2 + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGFloat) == revivalScale - 1 + expect(animation.toValue as? CGFloat) == 0 + } + + func test_insertTransition_zeroDuration_appliesTargetAndCompletes() throws { + // given: a layer renderable and a zero-duration scale-in transition + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(timing: .linear(duration: 0), options: .insert) + + // when: the insert transition animates the renderable + var completionCallCount = 0 + try transition.insert.unwrap().animate( + renderable: renderable, + context: RenderableTransition.InsertTransition.Context(targetFrame: Constants.targetFrame, contentView: contentView), + completion: { completionCallCount += 1 } + ) + + // then: the target frame applies and the transition completes immediately, with no animation added + expect(layer.frame) == Constants.targetFrame + expect(layer.animationKeys()) == nil + expect(CATransform3DIsIdentity(layer.transform)) == true + expect(completionCallCount) == 1 + } + + func test_insertTransition_zeroDurationRevival_clearsLeftoverAndSnapsToRest() throws { + // given: a layer mid-removal invoked directly: the model transform still carries the removal's scale, with a + // leftover additive scale animation attached + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + + let leftoverAnimation = CABasicAnimation(keyPath: "transform.scale") + leftoverAnimation.fromValue = CGFloat(1) - Constants.revivalScale + leftoverAnimation.toValue = CGFloat(0) + leftoverAnimation.duration = 10 + leftoverAnimation.isAdditive = true + layer.add(leftoverAnimation, forKey: "transform.scale") + + layer.setValue(Constants.revivalScale, forKeyPath: "transform.scale") + + // when: a zero-duration insert transition animates with a revival transform + let transition = RenderableTransition.scale(timing: .linear(duration: 0), options: .insert) + let revivalScale = Constants.revivalScale + + var completionCallCount = 0 + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context( + targetFrame: targetFrame, + revivalTransform: CATransform3DMakeScale(revivalScale, revivalScale, revivalScale), + contentView: contentView + ), + completion: { completionCallCount += 1 } + ) + + // then: the leftover animations are cleared and the renderable snaps to rest at its natural size + // 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: "transform.scale").count) == 0 + expect(CATransform3DIsIdentity(layer.transform)) == true + expect(completionCallCount) == 1 + } + + func test_insertTransition_delayedRevival_holdsAndKeepsLeftover() throws { + // given: a layer mid-removal in the framework flow, with a leftover additive scale animation attached + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + + let leftoverAnimation = CABasicAnimation(keyPath: "transform.scale") + leftoverAnimation.fromValue = CGFloat(1) - Constants.revivalScale + leftoverAnimation.toValue = CGFloat(0) + leftoverAnimation.duration = 10 + leftoverAnimation.isAdditive = true + layer.add(leftoverAnimation, forKey: "transform.scale") + + layer.frame = targetFrame + + // when: a delayed insert transition animates with a revival transform + let transition = RenderableTransition.scale(timing: .linear(duration: Constants.duration, delay: 0.5), options: .insert) + let revivalScale = Constants.revivalScale + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context( + targetFrame: targetFrame, + revivalTransform: CATransform3DMakeScale(revivalScale, revivalScale, revivalScale), + contentView: contentView + ), + completion: {} + ) + + // then: the leftover animation is kept and the scheduled insert offset holds the revival scale + // the leftover keeps playing during the delay window while the scheduled insert offset holds the revival scale + // through its fill mode, so the composed motion stays continuous until the insert begins + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 2 + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGFloat) == revivalScale - 1 + expect(animation.toValue as? CGFloat) == 0 + 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() throws { + // given: a layer at its natural size and a scale-out transition + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let currentFrame = Constants.targetFrame + let layer = TestLayer() + layer.frame = currentFrame + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(timing: Constants.timing, options: .remove) + + let removeTransition = try transition.remove.unwrap() + let context = RenderableTransition.RemoveTransition.Context(contentView: contentView) + + // the position is captured before the removal writes a non-identity model transform, because reading a + // frame-derived position requires an identity transform + let positionBeforeRemoval = layer.position + + // when: the remove transition animates the renderable + removeTransition.animate(renderable: renderable, context: context, completion: {}) + + // then: the model scale becomes the removal's end scale with an additive animation holding the rendered scale, + // decaying from the current scale + expect(removeTransition.animatedKeyPaths) == ["transform.scale", "transform.translation"] + expect(layer.capturedFrame) == currentFrame + expect(try CATransform3DIsIdentity(layer.capturedTransform.unwrap())) == true + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(layer.addedAnimationKey) == "transform.scale" + expect(layer.animationKeys()) == ["transform.scale"] + expect(animation.keyPath) == "transform.scale" + expect(animation.fromValue as? CGFloat) == 1 + expect(animation.toValue as? CGFloat) == 0 + expect(animation.timingFunction) == CAMediaTimingFunction(name: .linear) + expect(animation.duration) == Constants.duration + expect(animation.isAdditive) == true + expect(animation.isRemovedOnCompletion) == true + expect(animation.fillMode) == .both + + // then: the model holds the end scale so the layer stays at the end scale when the animation is removed, and the + // layout-owned bounds and position are untouched + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + expect(layer.bounds.size) == currentFrame.size + expect(layer.position) == positionBeforeRemoval + } + + func test_removeTransition_customFrom() throws { + // given: a layer at its natural size and a scale-out transition removing to 0.5 + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + layer.frame = Constants.targetFrame + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(from: 0.5, timing: Constants.timing, options: .remove) + + // when: the remove transition animates the renderable + let context = RenderableTransition.RemoveTransition.Context(contentView: contentView) + try transition.remove.unwrap().animate(renderable: renderable, context: context, completion: {}) + + // then: the additive animation decays the current scale's offset to the custom end scale + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGFloat) == 0.5 + expect(animation.toValue as? CGFloat) == 0 + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0.5 + } + + func test_removeTransition_midInsert_stacksOnInsertResidue() throws { + // given: a layer with an in-flight additive scale-in animation, the model scale resting at identity + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + layer.frame = Constants.targetFrame + + let insertResidue = CABasicAnimation(keyPath: "transform.scale") + insertResidue.fromValue = CGFloat(-1) + insertResidue.toValue = CGFloat(0) + insertResidue.duration = 10 + insertResidue.isAdditive = true + layer.add(insertResidue, forKey: "transform.scale") + + // when: a remove transition animates the renderable + let transition = RenderableTransition.scale(timing: Constants.timing, options: .remove) + let context = RenderableTransition.RemoveTransition.Context(contentView: contentView) + try transition.remove.unwrap().animate(renderable: .layer(layer), context: context, completion: {}) + + // then: the removal stacks its own additive animation on the kept insert residue, anchored to the model scale, so + // the rendered scale is continuous while both animations settle + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 2 + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGFloat) == 1 + expect(animation.toValue as? CGFloat) == 0 + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + } + + func test_removeTransition_zeroDuration_appliesEndScaleAndCompletes() throws { + // given: a layer at its natural size and a zero-duration scale-out transition + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + layer.frame = Constants.targetFrame + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(timing: .linear(duration: 0), options: .remove) + + // when: the remove transition animates the renderable + var completionCallCount = 0 + try transition.remove.unwrap().animate( + renderable: renderable, + context: RenderableTransition.RemoveTransition.Context(contentView: contentView), + completion: { completionCallCount += 1 } + ) + + // then: the end scale applies and the transition completes immediately, with no animation added + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + expect(layer.animationKeys()) == nil + expect(completionCallCount) == 1 + } + + func test_removeTransition_delayedZeroDuration_schedulesSnap() throws { + // given: a layer at its natural size and a delayed zero-duration scale-out transition + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + layer.frame = Constants.targetFrame + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(timing: .linear(duration: 0, delay: 0.5), options: .remove) + + // when: the remove transition animates the renderable + var completionCallCount = 0 + try transition.remove.unwrap().animate( + renderable: renderable, + context: RenderableTransition.RemoveTransition.Context(contentView: contentView), + completion: { completionCallCount += 1 } + ) + + // then: a zero-duration timing with a delay is a scheduled snap + // the renderable holds its current scale for the delay window, then snaps to the end scale, completing through the + // animation + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.keyPath) == "transform.scale" + expect(animation.fromValue as? CGFloat) == 1 + expect(animation.duration).to(beApproximatelyEqual(to: 0.001, within: 1e-6)) + expect(completionCallCount) == 0 + + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) + } + + func test_removeTransition_resetForReuse() throws { + // given: a layer with a scale remove transition in flight and an unrelated spin animation + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + layer.frame = Constants.targetFrame + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(timing: Constants.timing, options: .remove) + + let removeTransition = try transition.remove.unwrap() + let context = RenderableTransition.RemoveTransition.Context(contentView: contentView) + removeTransition.animate(renderable: renderable, context: context, completion: {}) + expect(layer.animationKeys()) == ["transform.scale"] + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + + // an animation the transition didn't add, e.g. a persistent content animation owned by the renderable + let spinAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + spinAnimation.duration = 60 + layer.add(spinAnimation, forKey: "spin") + + // when: the transition resets the renderable for reuse + removeTransition.resetForReuse(renderable: renderable) + + // then: the reset removes the scale's in-flight animations, restores the model transform to identity so frame + // applications on the reused renderable are well-defined, and leaves other animations alone + expect(layer.animation(forKey: "transform.scale")) == nil + expect(layer.animation(forKey: "spin")) != nil + expect(CATransform3DIsIdentity(layer.transform)) == true + } + + // MARK: - Center Pivot Compensation + + func test_insertTransition_anchoredLayer_addsCenterPivotCompensation() throws { + // given: a layer anchored at the bottom left corner, like an AppKit view-backing layer, and a scale-in transition + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + layer.anchorPoint = .zero + let transition = RenderableTransition.scale(timing: Constants.timing, options: .insert) + + // when: the insert transition animates the renderable + let context = RenderableTransition.InsertTransition.Context(targetFrame: targetFrame, contentView: contentView) + try transition.insert.unwrap().animate(renderable: .layer(layer), context: context, completion: {}) + + // then: the scale animation is paired with a same-timing translation compensation that starts at the center's + // offset from the anchor and decays to rest, so the rendered scaling pivots about the visual center + expect(layer.frame) == targetFrame + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 1 + let translationAnimations = layer.basicAnimations(forKeyPath: "transform.translation") + expect(translationAnimations.count) == 1 + + let expectedTranslation = CGSize(width: 0.5 * targetFrame.width, height: 0.5 * targetFrame.height) + let animation = try unwrap(translationAnimations.first) + expect(animation.fromValue as? CGSize) == expectedTranslation + expect(animation.toValue as? CGSize) == .zero + expect(animation.timingFunction) == CAMediaTimingFunction(name: .linear) + expect(animation.duration) == Constants.duration + expect(animation.isAdditive) == true + + // then: the model transform rests at identity + expect(CATransform3DIsIdentity(layer.transform)) == true + } + + func test_removeTransition_anchoredLayer_addsCenterPivotCompensation() throws { + // given: a layer anchored at the bottom left corner and a scale-out transition + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let currentFrame = Constants.targetFrame + let layer = TestLayer() + layer.anchorPoint = .zero + layer.frame = currentFrame + let transition = RenderableTransition.scale(timing: Constants.timing, options: .remove) + + // when: the remove transition animates the renderable + let context = RenderableTransition.RemoveTransition.Context(contentView: contentView) + try transition.remove.unwrap().animate(renderable: .layer(layer), context: context, completion: {}) + + // then: the model transform holds the end scale and its center-pivot translation, each with an additive animation + // decaying the current offset, so the miniature shrinks about the visual center and rests there + let expectedTranslation = CGSize(width: 0.5 * currentFrame.width, height: 0.5 * currentFrame.height) + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + expect(layer.value(forKeyPath: "transform.translation") as? CGSize) == expectedTranslation + + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 1 + let translationAnimations = layer.basicAnimations(forKeyPath: "transform.translation") + expect(translationAnimations.count) == 1 + + let animation = try unwrap(translationAnimations.first) + expect(animation.fromValue as? CGSize) == CGSize(width: -expectedTranslation.width, height: -expectedTranslation.height) + expect(animation.toValue as? CGSize) == .zero + expect(animation.isAdditive) == true + } + + func test_insertTransition_anchoredRevival_cancelsScaleAndTranslation() throws { + // given: a layer mid-removal in the framework flow, with leftover scale and translation animations attached and + // the removal's model transform captured in the revival transform + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + layer.anchorPoint = .zero + + let leftoverScale = CABasicAnimation(keyPath: "transform.scale") + leftoverScale.fromValue = CGFloat(1) - Constants.revivalScale + leftoverScale.toValue = CGFloat(0) + leftoverScale.duration = 10 + leftoverScale.isAdditive = true + layer.add(leftoverScale, forKey: "transform.scale") + + let leftoverTranslation = CABasicAnimation(keyPath: "transform.translation") + leftoverTranslation.fromValue = CGSize(width: -12, height: -15) + leftoverTranslation.toValue = CGSize.zero + leftoverTranslation.duration = 10 + leftoverTranslation.isAdditive = true + layer.add(leftoverTranslation, forKey: "transform.translation") + + layer.frame = targetFrame + + // the removal's model transform: the end scale with its center-pivot translation + let revivalScale = Constants.revivalScale + var revivalTransform = CATransform3DMakeScale(revivalScale, revivalScale, revivalScale) + revivalTransform.m41 = 12 + revivalTransform.m42 = 15 + + // when: an insert transition animates with the revival transform + let transition = RenderableTransition.scale(timing: Constants.timing, options: .insert) + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context( + targetFrame: targetFrame, + revivalTransform: revivalTransform, + contentView: contentView + ), + completion: {} + ) + + // then: the revival keeps the leftover animations and cancels both model component changes, so the rendered + // transform is continuous at the revival instant + let scaleAnimations = layer.basicAnimations(forKeyPath: "transform.scale") + expect(scaleAnimations.count) == 2 + let translationAnimations = layer.basicAnimations(forKeyPath: "transform.translation") + expect(translationAnimations.count) == 2 + + let insertScale = try unwrap(scaleAnimations.last) + expect(insertScale.fromValue as? CGFloat) == revivalScale - 1 + expect(insertScale.toValue as? CGFloat) == 0 + + let insertTranslation = try unwrap(translationAnimations.last) + expect(insertTranslation.fromValue as? CGSize) == CGSize(width: 12, height: 15) + expect(insertTranslation.toValue as? CGSize) == .zero + expect(CATransform3DIsIdentity(layer.transform)) == true + } + + func test_removeTransition_anchoredLayer_zeroDuration_appliesCompensatedEndState() throws { + // given: a layer anchored at the bottom left corner and a zero-duration scale-out transition removing to 0.5 + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let currentFrame = Constants.targetFrame + let layer = TestLayer() + layer.anchorPoint = .zero + layer.frame = currentFrame + let transition = RenderableTransition.scale(from: 0.5, timing: .linear(duration: 0), options: .remove) + + // when: the remove transition animates the renderable + var completionCallCount = 0 + try transition.remove.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.RemoveTransition.Context(contentView: contentView), + completion: { completionCallCount += 1 } + ) + + // then: the end scale applies together with its center-pivot translation, with no animation added + let expectedTranslation = CGSize(width: 0.5 * currentFrame.width * 0.5, height: 0.5 * currentFrame.height * 0.5) + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0.5 + expect(layer.value(forKeyPath: "transform.translation") as? CGSize) == expectedTranslation + expect(layer.animationKeys()) == nil + expect(completionCallCount) == 1 + } + + func test_insertTransition_anchoredLayer_zeroDurationRevival_clearsResidueAndRestoresIdentity() throws { + // given: a layer mid-removal invoked directly: the model transform carries the removal's scale and translation, + // with leftover animations on both key paths + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let targetFrame = Constants.targetFrame + let layer = TestLayer() + layer.anchorPoint = .zero + + let leftoverScale = CABasicAnimation(keyPath: "transform.scale") + leftoverScale.fromValue = CGFloat(1) - Constants.revivalScale + leftoverScale.toValue = CGFloat(0) + leftoverScale.duration = 10 + leftoverScale.isAdditive = true + layer.add(leftoverScale, forKey: "transform.scale") + + let leftoverTranslation = CABasicAnimation(keyPath: "transform.translation") + leftoverTranslation.fromValue = CGSize(width: -12, height: -15) + leftoverTranslation.toValue = CGSize.zero + leftoverTranslation.duration = 10 + leftoverTranslation.isAdditive = true + layer.add(leftoverTranslation, forKey: "transform.translation") + + let revivalScale = Constants.revivalScale + layer.setValue(revivalScale, forKeyPath: "transform.scale") + layer.setValue(CGSize(width: 12, height: 15), forKeyPath: "transform.translation") + + var revivalTransform = CATransform3DMakeScale(revivalScale, revivalScale, revivalScale) + revivalTransform.m41 = 12 + revivalTransform.m42 = 15 + + // when: a zero-duration insert transition animates with the revival transform + let transition = RenderableTransition.scale(timing: .linear(duration: 0), options: .insert) + var completionCallCount = 0 + try transition.insert.unwrap().animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context( + targetFrame: targetFrame, + revivalTransform: revivalTransform, + contentView: contentView + ), + completion: { completionCallCount += 1 } + ) + + // then: the leftover animations on both key paths are cleared and the model transform snaps to identity at the + // target frame + expect(layer.frame) == targetFrame + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 0 + expect(layer.basicAnimations(forKeyPath: "transform.translation").count) == 0 + expect(CATransform3DIsIdentity(layer.transform)) == true + expect(completionCallCount) == 1 + } + + func test_removeTransition_anchoredLayer_resetForReuse_restoresIdentity() throws { + // given: a layer anchored at the bottom left corner with a scale remove transition in flight + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + layer.anchorPoint = .zero + layer.frame = Constants.targetFrame + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.scale(timing: Constants.timing, options: .remove) + + let removeTransition = try transition.remove.unwrap() + removeTransition.animate(renderable: renderable, context: RenderableTransition.RemoveTransition.Context(contentView: contentView), completion: {}) + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 1 + expect(layer.basicAnimations(forKeyPath: "transform.translation").count) == 1 + + // when: the transition resets the renderable for reuse + removeTransition.resetForReuse(renderable: renderable) + + // then: the reset removes both key paths' animations and restores the model transform to identity, clearing the + // scale and its center-pivot translation + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 0 + expect(layer.basicAnimations(forKeyPath: "transform.translation").count) == 0 + expect(CATransform3DIsIdentity(layer.transform)) == true + } + + // MARK: - Anchor + + func test_insertTransition_anchor_mapsToUnitPoints() throws { + // given: expected pivot compensations for every anchor, for a center-anchored layer scaling in from 0: + // the compensation is (anchor unit point - 0.5) * target size + let targetFrame = Constants.targetFrame + let expectedTranslations: [Layout.Alignment: CGSize] = [ + .center: .zero, + .left: CGSize(width: -0.5 * targetFrame.width, height: 0), + .right: CGSize(width: 0.5 * targetFrame.width, height: 0), + .top: CGSize(width: 0, height: -0.5 * targetFrame.height), + .bottom: CGSize(width: 0, height: 0.5 * targetFrame.height), + .topLeft: CGSize(width: -0.5 * targetFrame.width, height: -0.5 * targetFrame.height), + .topRight: CGSize(width: 0.5 * targetFrame.width, height: -0.5 * targetFrame.height), + .bottomLeft: CGSize(width: -0.5 * targetFrame.width, height: 0.5 * targetFrame.height), + .bottomRight: CGSize(width: 0.5 * targetFrame.width, height: 0.5 * targetFrame.height), + ] + + for anchor in Layout.Alignment.allCases { + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + let transition = RenderableTransition.scale(anchor: anchor, timing: Constants.timing, options: .insert) + + // when: the insert transition animates the renderable + let context = RenderableTransition.InsertTransition.Context(targetFrame: targetFrame, contentView: contentView) + try transition.insert.unwrap().animate(renderable: .layer(layer), context: context, completion: {}) + + // then: the compensation matches the anchor's unit point, and a zero compensation adds no animation + let expectedTranslation = try expectedTranslations[anchor].unwrap() + let translationAnimations = layer.basicAnimations(forKeyPath: "transform.translation") + if expectedTranslation == .zero { + expect(translationAnimations.count, "\(anchor)") == 0 + } else { + expect(translationAnimations.count, "\(anchor)") == 1 + let animation = try unwrap(translationAnimations.first) + expect(animation.fromValue as? CGSize, "\(anchor)") == expectedTranslation + expect(animation.toValue as? CGSize, "\(anchor)") == .zero + } + } + } + + func test_removeTransition_anchoredLayer_customAnchor_compensatesTowardsPivot() throws { + // given: a layer anchored at the top left corner and a scale-out transition pivoting about the bottom right corner + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let currentFrame = Constants.targetFrame + let layer = TestLayer() + layer.anchorPoint = .zero + layer.frame = currentFrame + let transition = RenderableTransition.scale(anchor: .bottomRight, timing: Constants.timing, options: .remove) + + // when: the remove transition animates the renderable + let context = RenderableTransition.RemoveTransition.Context(contentView: contentView) + try transition.remove.unwrap().animate(renderable: .layer(layer), context: context, completion: {}) + + // then: the compensation offsets the full anchor-to-pivot distance, keeping the bottom right corner fixed + let expectedTranslation = CGSize(width: currentFrame.width, height: currentFrame.height) + expect(layer.value(forKeyPath: "transform.translation") as? CGSize) == expectedTranslation + + let animation = try unwrap(layer.basicAnimations(forKeyPath: "transform.translation").first) + expect(animation.fromValue as? CGSize) == CGSize(width: -expectedTranslation.width, height: -expectedTranslation.height) + expect(animation.toValue as? CGSize) == .zero + } + + func test_composeViewIntegration_topAnchor_renderedTopEdgeStaysFixed() throws { + // given: a hosted compose view showing content with a slow top-anchored scale transition + 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(.scale(anchor: .top, timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + + contentView.setContent(content: makeContent) + contentView.refresh(animated: false) + + // when: the content is removed with animation and the removal renders mid-flight + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) + expect(layer.presentation()).toEventuallyNot(beNil()) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + // then: the rendered frame stays anchored at the visual top while it shrinks: the top edge and the horizontal + // center hold still on both platforms, pinning the anchor's top-left-origin unit space + let frameBefore = try unwrap(layer.presentation()).frame + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + let frameAfter = try unwrap(layer.presentation()).frame + + expect(frameAfter.height) < frameBefore.height + expect(abs(frameAfter.minY - frameBefore.minY)).to(beApproximatelyEqual(to: 0, within: 0.5)) + expect(abs(frameAfter.midX - frameBefore.midX)).to(beApproximatelyEqual(to: 0, within: 0.5)) + expect(abs(frameBefore.minY)).to(beApproximatelyEqual(to: 0, within: 0.5)) + } + + func test_composeViewIntegration_bottomRightAnchor_renderedBottomRightCornerStaysFixed() throws { + // given: a hosted compose view showing content with a slow bottom-right-anchored scale transition + 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(.scale(anchor: .bottomRight, timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + + contentView.setContent(content: makeContent) + contentView.refresh(animated: false) + + // when: the content is removed with animation and the removal renders mid-flight + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) + expect(layer.presentation()).toEventuallyNot(beNil()) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + // then: the rendered frame stays anchored at the visual bottom right corner while it shrinks + let frameBefore = try unwrap(layer.presentation()).frame + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + let frameAfter = try unwrap(layer.presentation()).frame + + expect(frameAfter.height) < frameBefore.height + expect(abs(frameAfter.maxX - frameBefore.maxX)).to(beApproximatelyEqual(to: 0, within: 0.5)) + expect(abs(frameAfter.maxY - frameBefore.maxY)).to(beApproximatelyEqual(to: 0, within: 0.5)) + expect(abs(frameBefore.maxX - 100)).to(beApproximatelyEqual(to: 0, within: 0.5)) + expect(abs(frameBefore.maxY - 100)).to(beApproximatelyEqual(to: 0, within: 0.5)) + } + + // MARK: - ComposeView Integration + + func test_composeViewIntegration() throws { + // given: a compose view with a layer node using a scale-in transition + let layer = TestLayer() + let targetSize = Constants.targetFrame.size + layer.bounds = CGRect(origin: .zero, size: targetSize) + + let composeView = ComposeView { + LayerNode(layer) + .alignment(.topLeft) + .transition(.scale(timing: Constants.timing, options: .insert)) + } + + // when: the view is sized and refreshed with animation + composeView.frame = CGRect(origin: .zero, size: Constants.contentSize) + composeView.refresh(animated: true) + + // then: the layer lands at the target frame with an additive scale-in animation + let expectedTargetFrame = CGRect(origin: .zero, size: targetSize) + expect(layer.capturedFrame) == expectedTargetFrame + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(layer.addedAnimationKey) == "transform.scale" + expect(layer.animationKeys()) == ["transform.scale"] + expect(animation.keyPath) == "transform.scale" + expect(animation.fromValue as? CGFloat) == -1 + expect(animation.toValue as? CGFloat) == 0 + expect(animation.timingFunction) == CAMediaTimingFunction(name: .linear) + expect(animation.duration) == Constants.duration + expect(animation.isAdditive) == true + expect(animation.isRemovedOnCompletion) == true + expect(animation.fillMode) == .both + expect(CATransform3DIsIdentity(layer.transform)) == true + } + + func test_composeViewIntegration_revival_composesWithInFlightRemoval() throws { + // given: a compose view showing content with a slow scale transition + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let makeContent: () -> ComposeContent = { + ColorNode(.red) + .transition(.scale(timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + + contentView.setContent(content: makeContent) + contentView.refresh(animated: false) + + // when: the content is removed with animation + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + // then: the remove transition is in flight: the model scale is the end scale, with a single additive animation + // holding the rendered scale + let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 1 + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + + // when: revive the renderable with an animated insert + // the scale insert takes over the in-flight removal by composing additively with it. the leftover remove animation + // is kept, the insert stacks its own animation on top, and the insert's starting offset exactly compensates the + // model scale change, so the rendered scale is continuous at the revival instant. + contentView.setContent(content: makeContent) + contentView.refresh(animated: true) + + // then: the insert animation stacks on the kept remove animation, anchored to the removal's model scale + let animations = layer.basicAnimations(forKeyPath: "transform.scale") + expect(animations.count) == 2 + expect(CATransform3DIsIdentity(layer.transform)) == true + + let insertAnimation = try unwrap(animations.last) + expect(insertAnimation.fromValue as? CGFloat) == -1 + expect(insertAnimation.toValue as? CGFloat) == 0 + expect(insertAnimation.isAdditive) == true + + expect(contentView.test.removingRenderableMap.count) == 0 + } + + func test_composeViewIntegration_revival_renderedScaleIsContinuous() throws { + // given: a hosted compose view showing content with a slow scale transition + 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(.scale(timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + + contentView.setContent(content: makeContent) + contentView.refresh(animated: false) + + // when: the content is removed with animation and then revived mid-flight + 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 scaleBefore = try unwrap(unwrap(layer.presentation()).value(forKeyPath: "transform.scale") as? CGFloat) + + // 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 scaleAfter = try unwrap(unwrap(layer.presentation()).value(forKeyPath: "transform.scale") as? CGFloat) + + // then: the rendered scale is continuous at the revival: the 10s linear motion drifts a few percent between the + // samples, far from the near-full-scale jump a restart from the configured `from` scale would show + expect(abs(scaleAfter - scaleBefore) < 0.15) == true + } + + func test_composeViewIntegration_scaleRemovalRevivedBySlideInsert_resetsScaleResidue() throws { + // given: a compose view showing content with a slow scale transition + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + contentView.setContent { + ColorNode(.red) + .transition(.scale(timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + contentView.refresh(animated: false) + + // when: the content is removed with animation + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + // then: the scale removal is in flight, with the end scale in the model transform + let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 1 + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + + // when: revive the renderable with a slide insert + // the slide insert doesn't take over the scale removal's animated key path, so the scale removal's residue (the + // in-flight animation and the degenerate model scale) is undone via its `resetForReuse` before the slide insert + // runs, making the frame application on the revived renderable well-defined + contentView.setContent { + ColorNode(.red) + .transition(.slide(from: .left, timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + contentView.refresh(animated: true) + + // then: the scale residue is fully reset and the slide insert runs fresh + expect(contentView.test.removingRenderableMap.count) == 0 + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 0 + expect(CATransform3DIsIdentity(layer.transform)) == true + expect(layer.basicAnimations(forKeyPath: "position").count) == 1 + expect(layer.position) == layer.position(from: CGRect(x: 0, y: 0, width: 100, height: 100)) + } + + func test_composeViewIntegration_viewRenderable_centerPivotCompensation() throws { + // given: a compose view showing a view renderable with a slow scale transition + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + contentView.setContent { + ViewNode() + .transition(.scale(timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + contentView.refresh(animated: false) + + // when: the content is removed with animation + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + // then: the anchor point of the view's backing layer drives the compensation + let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) + expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 1 + #if canImport(AppKit) + // AppKit anchors view-backing layers at the bottom left corner, so the removal pairs the scale with a center-pivot + // translation towards the visual center + expect(layer.anchorPoint) == .zero + expect(layer.basicAnimations(forKeyPath: "transform.translation").count) == 1 + expect(layer.value(forKeyPath: "transform.translation") as? CGSize) == CGSize(width: 50, height: 50) + #else + // UIKit view-backing layers anchor at the center, so no compensation is needed + expect(layer.anchorPoint) == CGPoint(x: 0.5, y: 0.5) + expect(layer.basicAnimations(forKeyPath: "transform.translation").count) == 0 + #endif + } + + func test_composeViewIntegration_anchoredLayer_renderedCenterStaysFixed() throws { + // given: a hosted compose view showing a bottom-left-anchored layer with a slow scale transition + let window = TestWindow() + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.contentView().addSubview(contentView) + + let layer = TestLayer() + layer.anchorPoint = .zero + layer.bounds = CGRect(x: 0, y: 0, width: 100, height: 100) + + let makeContent: () -> ComposeContent = { + LayerNode(layer) + .transition(.scale(timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + + contentView.setContent(content: makeContent) + contentView.refresh(animated: false) + + // when: the content is removed with animation and the removal renders mid-flight + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + expect(layer.presentation()).toEventuallyNot(beNil()) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + // then: at any mid-flight moment, the rendered translation offsets the rendered scale about the visual center: + // translation == (0.5 - anchor) * size * (1 - scale), here (50, 50) * (1 - scale) + let assertCenterInvariant: () throws -> Void = { [weak layer] in + let presentation = try unwrap(layer?.presentation()) + let scale = presentation.transform.m11 + let expectedOffset = 50 * (1 - scale) + expect(scale) < 1 + expect(abs(presentation.transform.m41 - expectedOffset)).to(beApproximatelyEqual(to: 0, within: 0.5)) + expect(abs(presentation.transform.m42 - expectedOffset)).to(beApproximatelyEqual(to: 0, within: 0.5)) + } + try assertCenterInvariant() + + // then: the invariant still holds later in the animation + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + try assertCenterInvariant() + } + + // MARK: - Constants + + private enum Constants { + + static let contentSize = CGSize(width: 200, height: 120) + static let targetFrame = CGRect(x: 20, y: 30, width: 40, height: 50) + static let revivalScale: CGFloat = 0.4 + static let duration: TimeInterval = 0.5 + static let timing: AnimationTiming = .linear(duration: duration) + } +} + +private final class TestLayer: CALayer { + + var capturedFrame: CGRect? + var capturedTransform: CATransform3D? + var addedAnimation: CAAnimation? + var addedAnimationKey: String? + + override func add(_ animation: CAAnimation, forKey key: String?) { + capturedFrame = frame + capturedTransform = transform + addedAnimation = animation + addedAnimationKey = key + super.add(animation, forKey: key) + } +} diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift index bc7531d..a944fcd 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift @@ -41,24 +41,8 @@ import ComposeUI extension Playground { /// An interactive insert/remove page for verifying transition revivals. - /// - /// The page inserts and removes two boxes together, so a revival is verified on both renderable kinds: - /// - The top, blue-gray box is a layer renderable. - /// - The bottom, green box is a view renderable, whose transitions additionally exercise the `backedView` model sync - /// (the view's `frame` on macOS and `alpha` on both platforms). - /// - /// The transitions run slowly so a removal can be interrupted mid-flight, and the transition button cycles 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, each box renderable's lifecycle events, and a continuous sample of each box layer's - /// model and presentation values, so a manual test session can be diagnosed from the console output. The boxes are - /// sampled independently, so a divergence between the layer box and the view box shows up as one box logging without - /// the other. final class TransitionRevivalView: ComposeView { - /// The transition to verify, cycled through by the page's transition button. private enum TransitionKind: CaseIterable { /// A fade, which retargets from the interrupted opacity on a revival. @@ -67,11 +51,25 @@ 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. 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. + /// 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 + /// A scale that grows in from zero and shrinks back to zero, running slowly so a removal can be interrupted + /// mid-flight. A revival continues from the interrupted scale. + case scale + + /// A scale with the default spring timing, for checking the at-speed look and spring revivals. + case scaleSpring + + /// The property a transition animates, for logging the relevant layer values. + enum AnimatedProperty { + + case opacity + case position + case scale + } + var title: String { switch self { case .opacity: @@ -80,21 +78,29 @@ extension Playground { return "slide (left ⇄ left)" case .crossSideSlide: return "slide (left → right)" + case .scale: + return "scale (slow)" + case .scaleSpring: + return "scale (spring)" } } /// The animated property, for logging the relevant layer values. - var animatesPosition: Bool { + var animatedProperty: AnimatedProperty { switch self { case .opacity: - return false + return .opacity case .slide, .crossSideSlide: - return true + return .position + case .scale, + .scaleSpring: + return .scale } } - var transition: RenderableTransition { + /// The transition to verify. The scale kinds pivot about `scaleAnchor`, the other kinds ignore it. + func transition(scaleAnchor: Layout.Alignment) -> RenderableTransition { switch self { case .opacity: return .opacity(timing: .spring(dampingRatio: 0.8, response: 3)) @@ -102,6 +108,10 @@ extension Playground { return .slide(from: .left, timing: .easeInEaseOut(duration: 3)) case .crossSideSlide: return .slide(from: .left, to: .right, timing: .easeInEaseOut(duration: 3)) + case .scale: + return .scale(anchor: scaleAnchor, timing: .easeInEaseOut(duration: 3)) + case .scaleSpring: + return .scale(anchor: scaleAnchor) } } @@ -116,6 +126,14 @@ extension Playground { private var isShowing = true private var transitionKind: TransitionKind = .opacity + private var scaleAnchor: Layout.Alignment = .center + + /// The page's preferred height, for the hosting content view's layout. + var preferredHeight: CGFloat { + let baseHeight: CGFloat = 260 + let anchorRowHeight: CGFloat = 36 + 12 + return transitionKind.animatedProperty == .scale ? baseHeight + anchorRowHeight : baseHeight + } private weak var layerBoxLayer: CALayer? private weak var viewBoxView: View? @@ -152,7 +170,7 @@ extension Playground { Playground.addBoxNameLabel("layer", to: layer, scale: Playground.displayScale(of: self)) } ) - .transition(transitionKind.transition) + .transition(transitionKind.transition(scaleAnchor: scaleAnchor)) .frame(Constants.boxSize) .id("layer-box") @@ -172,7 +190,7 @@ extension Playground { Playground.addBoxNameLabel("view", to: boxLayer, scale: Playground.displayScale(of: self)) } ) - .transition(transitionKind.transition) + .transition(transitionKind.transition(scaleAnchor: scaleAnchor)) .frame(Constants.boxSize) .id("view-box") } else { @@ -212,8 +230,26 @@ extension Playground { self.transitionKind = self.transitionKind.next self.log("TAP transition kind -> \(self.transitionKind.title)") self.refresh(animated: false) + + // the anchor button's visibility changed the page's preferred height, so the hosting content view must + // re-read it and re-layout + self.refreshEnclosingComposeView() } .frame(width: .flexible, height: 36) + + if transitionKind.animatedProperty == .scale { + Playground.button(title: "Scale anchor: \(scaleAnchor)", fontSize: 14) { [weak self] in + guard let self else { + return + } + let anchors = Layout.Alignment.allCases + let index = anchors.firstIndex(of: self.scaleAnchor)! // swiftlint:disable:this force_unwrapping + self.scaleAnchor = anchors[(index + 1) % anchors.count] + self.log("TAP scale anchor -> \(self.scaleAnchor)") + self.refresh(animated: false) + } + .frame(width: .flexible, height: 36) + } } .padding(12) } @@ -346,10 +382,15 @@ extension Playground { return "box view = nil" } let viewModel: String - if transitionKind.animatesPosition { + switch transitionKind.animatedProperty { + case .position: viewModel = "viewFrame = \(Debug.format(view.frame))" - } else { + case .opacity: viewModel = "viewAlpha = \(Debug.format(view.alpha))" + case .scale: + // a scale rides on the layer transform and syncs no view property, so the view's frame is the nearest + // view-level signal (on UIKit it is derived from the transform, on AppKit it holds still) + viewModel = "viewFrame = \(Debug.format(view.frame))" } return "\(viewModel), \(describeBox(layer: viewBoxLayer))" } @@ -361,19 +402,51 @@ extension Playground { let pointer = String(describing: Unmanaged.passUnretained(layer).toOpaque()) let model: String let presentation: String - if transitionKind.animatesPosition { + switch transitionKind.animatedProperty { + case .position: model = "position = \(Debug.format(layer.position))" presentation = "presentationPosition = \(layer.presentation().map { Debug.format($0.position) } ?? "nil")" - } else { + case .opacity: model = "opacity = \(Debug.format(layer.opacity))" presentation = "presentationOpacity = \(layer.presentation().map { Debug.format($0.opacity) } ?? "nil")" + case .scale: + // the translation is the center-pivot compensation, which should track (0.5 - anchor) * size * (1 - scale) + model = "scale = \(Debug.format(layer.uniformScale)), translation = \(Debug.format(layer.transformTranslation))" + presentation = "presentationScale = \(layer.presentation().map { Debug.format($0.uniformScale) } ?? "nil"), presentationTranslation = \(layer.presentation().map { Debug.format($0.transformTranslation) } ?? "nil")" } let inTree = layer.superlayer != nil ? "attached" : "DETACHED" return "layer = \(pointer) (\(inTree)), \(model), \(presentation), animations = \(Debug.describeAnimations(of: layer))" } + /// Refreshes the nearest enclosing compose view, so it re-reads this page's preferred height. + private func refreshEnclosingComposeView() { + var ancestor = superview + while let view = ancestor { + if let composeView = view as? ComposeView { + composeView.refresh(animated: false) + return + } + ancestor = view.superview + } + } + private func log(_ message: String) { print("[Revival] \(String(format: "%.3f", CACurrentMediaTime())) | \(message)") } } } + +private extension CALayer { + + /// The uniform scale of the layer's transform, read through the "transform.scale" key path (the average of the three + /// scale factors). + var uniformScale: CGFloat { + value(forKeyPath: "transform.scale") as? CGFloat ?? 1 + } + + /// The translation of the layer's transform, as a point for formatting. + var transformTranslation: CGPoint { + let translation = value(forKeyPath: "transform.translation") as? CGSize ?? .zero + return CGPoint(x: translation.width, y: translation.height) + } +} diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift index f332d7c..e3085f2 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift @@ -58,6 +58,8 @@ class ViewController: UIViewController { }() lazy var playgroundTextView = Playground.TextView() + + lazy var transitionRevivalView = Playground.TransitionRevivalView(frame: .zero) } private let state = ViewState() @@ -83,13 +85,14 @@ class ViewController: UIViewController { .padding(horizontal: Constants.padding) .frame(width: .flexible, height: 120) - ViewNode() + ViewNode(state.transitionRevivalView) + .flexibleSize() .underlay { LayerNode() .border(color: Color.gray, width: 1) } .padding(horizontal: Constants.padding) - .frame(width: .flexible, height: 260) + .frame(width: .flexible, height: state.transitionRevivalView.preferredHeight) ViewNode() .underlay { diff --git a/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift b/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift index 180f9ad..05f79d9 100644 --- a/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift +++ b/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift @@ -34,7 +34,14 @@ import ComposeUI class ViewController: NSViewController { - private lazy var contentView = ComposeView { contentView in + private class ViewState { + + lazy var transitionRevivalView = Playground.TransitionRevivalView(frame: .zero) + } + + private let state = ViewState() + + private lazy var contentView = ComposeView { [state] contentView in let isKey = (contentView.window?.isKeyWindow ?? true) VStack { @@ -78,13 +85,14 @@ class ViewController: NSViewController { Spacer(height: 16) - ViewNode() + ViewNode(state.transitionRevivalView) + .flexibleSize() .underlay { LayerNode() .border(color: Color.gray, width: 1) } .padding(horizontal: 16) - .frame(width: .flexible, height: 260) + .frame(width: .flexible, height: state.transitionRevivalView.preferredHeight) Spacer(height: 16) From 4b33025695bcfa84186c252c4e8e9cc98314eebd Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Sun, 6 Sep 2026 21:26:58 -0700 Subject: [PATCH 2/8] [transition] pin revival to the captured removal state in a config-drift test --- .../RenderableTransition+ScaleTests.swift | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift index 7324909..dd00ad6 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift @@ -896,6 +896,58 @@ class RenderableTransition_ScaleTests: XCTestCase { expect(contentView.test.removingRenderableMap.count) == 0 } + func test_composeViewIntegration_revivalWithDifferentConfig_continuesFromCapturedRemovalState() throws { + // given: a compose view showing content with a slow top-left-anchored scale transition + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + contentView.setContent { + ColorNode(.red) + .transition(.scale(anchor: .topLeft, timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + contentView.refresh(animated: false) + + // when: the content is removed with animation + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + // then: the removal writes its end state into the model transform: the end scale and the top-left pivot's + // compensation for the center-anchored layer + let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) + expect(layer.value(forKeyPath: "transform.scale") as? CGFloat) == 0 + expect(layer.value(forKeyPath: "transform.translation") as? CGSize) == CGSize(width: -50, height: -50) + + // when: revive the renderable with a different scale configuration, a 0.5 scale about the center + // a fresh insert for this configuration would start the scale offset at -0.5 and would add no translation animation + // at all (a center pivot on a center-anchored layer needs no compensation), so the assertions below can only pass + // when the insert anchors to the captured removal state instead of the fresh configuration + contentView.setContent { + ColorNode(.red) + .transition(.scale(from: 0.5, timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + contentView.refresh(animated: true) + + // then: the insert cancels the captured removal state on both channels, so the rendered transform is continuous + let scaleAnimations = layer.basicAnimations(forKeyPath: "transform.scale") + expect(scaleAnimations.count) == 2 + let insertScale = try unwrap(scaleAnimations.last) + expect(insertScale.fromValue as? CGFloat) == -1 + expect(insertScale.toValue as? CGFloat) == 0 + + let translationAnimations = layer.basicAnimations(forKeyPath: "transform.translation") + expect(translationAnimations.count) == 2 + let insertTranslation = try unwrap(translationAnimations.last) + expect(insertTranslation.fromValue as? CGSize) == CGSize(width: -50, height: -50) + expect(insertTranslation.toValue as? CGSize) == .zero + + // then: the model transform rests at identity + expect(CATransform3DIsIdentity(layer.transform)) == true + expect(contentView.test.removingRenderableMap.count) == 0 + } + func test_composeViewIntegration_revival_renderedScaleIsContinuous() throws { // given: a hosted compose view showing content with a slow scale transition let window = TestWindow() From 381a46ded07c79cd8bff387c7e1bb69c667e0106 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Sun, 6 Sep 2026 21:31:38 -0700 Subject: [PATCH 3/8] [transition] reuse canonical additive animate helpers in the scale removal --- .../RenderItem/RenderableTransition+Scale.swift | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift index 6ab5280..60e9557 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift @@ -149,12 +149,9 @@ public extension RenderableTransition { layer.animate( keyPath: "transform.scale", + to: from, timing: timing, - from: { ($0.value(forKeyPath: "transform.scale") as! CGFloat) - from }, // swiftlint:disable:this force_cast - to: { _ in CGFloat(0) }, - model: { _ in from }, updateAnimation: { - $0.isAdditive = true $0.delegate = AnimationDelegate(animationDidStop: { _, _ in completion() }) @@ -162,16 +159,7 @@ public extension RenderableTransition { ) if endTranslation != .zero { - layer.animate( - keyPath: "transform.translation", - timing: timing, - from: { ($0.value(forKeyPath: "transform.translation") as! CGSize) - endTranslation }, // swiftlint:disable:this force_cast - to: { _ in CGSize.zero }, - model: { _ in endTranslation }, - updateAnimation: { - $0.isAdditive = true - } - ) + layer.animate(keyPath: "transform.translation", to: endTranslation, timing: timing) } }, resetForReuse: { renderable in From 22cd2b5f08e2b2d390045a31e18ca0024c8f8f87 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Sun, 6 Sep 2026 22:04:20 -0700 Subject: [PATCH 4/8] [transition] share the transform restore helper, assert identity in scale inserts --- .../Animations/CALayer+Extensions.swift | 10 ++++ .../RenderableTransition+Scale.swift | 17 +----- .../ComposeUI/ComposeView/ComposeView.swift | 23 +------- .../RenderableTransition+ScaleTests.swift | 55 ++----------------- 4 files changed, 18 insertions(+), 87 deletions(-) diff --git a/ComposeUI/Sources/ComposeUI/Animations/CALayer+Extensions.swift b/ComposeUI/Sources/ComposeUI/Animations/CALayer+Extensions.swift index 6532f13..c2f9e41 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CALayer+Extensions.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CALayer+Extensions.swift @@ -58,6 +58,16 @@ extension CALayer { ) } + /// Restores the layer's model transform to identity. + func restoreIdentityTransformIfNeeded() { + guard !CATransform3DIsIdentity(transform) else { + return + } + disableActions(for: "transform") { + transform = CATransform3DIdentity + } + } + /// Moves the sublayer to the front. /// /// - Parameter sublayer: The sublayer to move to the front. diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift index 60e9557..2b05cc6 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift @@ -69,6 +69,7 @@ public extension RenderableTransition { takesOverKeyPaths: Constants.animatedKeyPaths, animate: { renderable, context, completion in let layer = renderable.layer + ComposeUI.assert(CATransform3DIsIdentity(layer.transform), "scale insert transition requires an identity model transform") guard timing.timing.duration > 0 || timing.delay > 0 else { if context.revivalTransform != nil { @@ -78,7 +79,6 @@ public extension RenderableTransition { layer.removeAnimations(forKeyPath: keyPath) } } - layer.restoreIdentityTransformIfNeeded() renderable.setFrame(context.targetFrame) completion() return @@ -99,7 +99,6 @@ public extension RenderableTransition { startTranslation = layer.pivotTranslation(towards: anchor.unitPoint, for: from, size: context.targetFrame.size) } - layer.restoreIdentityTransformIfNeeded() renderable.setFrame(context.targetFrame) layer.animate( @@ -225,18 +224,4 @@ private extension CALayer { height: (pivot.y - anchorPoint.y) * size.height * (1 - scale) ) } - - /// Restores the layer's model transform to identity so a following frame application is well-defined. - /// - /// The framework resets a revived renderable's transform before the render pass applies frames, so this is a no-op - /// in framework flows. A direct invocation can still carry the removal's model transform, which would corrupt the - /// frame application, so the transition restores the transform itself. - func restoreIdentityTransformIfNeeded() { - guard !CATransform3DIsIdentity(transform) else { - return - } - disableActions(for: "transform") { - transform = CATransform3DIdentity - } - } } diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index fc654f8..790f6b8 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift @@ -1126,7 +1126,7 @@ open class ComposeView: BaseScrollView { debug?.onEvent(.renderWillReuseRenderable(item: renderableItem, renderable: renderable)) #endif - renderable.layer.reset() + renderable.layer.restoreIdentityTransformIfNeeded() let updateType: RenderableUpdateType switch context.updateType { @@ -1223,7 +1223,7 @@ open class ComposeView: BaseScrollView { debug?.onEvent(.renderWillInsertRenderable(item: renderableItem, renderable: renderable)) #endif - renderable.layer.reset() + renderable.layer.restoreIdentityTransformIfNeeded() let frameBeforeWillInsert = renderable.frame renderableItem.willInsert?(renderable, RenderableInsertContext(oldFrame: frameBeforeWillInsert, newFrame: newFrame, contentView: self)) @@ -1432,22 +1432,3 @@ open class ComposeView: BaseScrollView { #endif } - -// MARK: - Helpers - -private extension CALayer { - - /// Common reset for the layer managed by `ComposeView`. - /// - /// To ensure the frame update is applied correctly, the transform is reset to identity. - func reset() { - // a reused renderable almost always already has an identity transform, so skip the work in that case. - // only renderables left with a non-identity transform (e.g. an interrupted transition) need a reset. - guard !CATransform3DIsIdentity(transform) else { - return - } - disableActions(for: "transform") { - transform = CATransform3DIdentity // setting frame requires an identity transform - } - } -} diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift index dd00ad6..d9ea0ad 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift @@ -136,46 +136,6 @@ class RenderableTransition_ScaleTests: XCTestCase { expect(CATransform3DIsIdentity(layer.transform)) == true } - func test_insertTransition_revival_directInvocation_restoresModelScale() throws { - // given: a layer mid-removal invoked directly: the model transform still carries the removal's scale, with a - // leftover additive scale animation attached - let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) - let targetFrame = Constants.targetFrame - let layer = TestLayer() - - let leftoverAnimation = CABasicAnimation(keyPath: "transform.scale") - leftoverAnimation.fromValue = CGFloat(1) - Constants.revivalScale - leftoverAnimation.toValue = CGFloat(0) - leftoverAnimation.duration = 10 - leftoverAnimation.isAdditive = true - layer.add(leftoverAnimation, forKey: "transform.scale") - - layer.setValue(Constants.revivalScale, forKeyPath: "transform.scale") - - // when: an insert transition animates with a revival transform - let transition = RenderableTransition.scale(timing: Constants.timing, options: .insert) - let revivalScale = Constants.revivalScale - try transition.insert.unwrap().animate( - renderable: .layer(layer), - context: RenderableTransition.InsertTransition.Context( - targetFrame: targetFrame, - revivalTransform: CATransform3DMakeScale(revivalScale, revivalScale, revivalScale), - contentView: contentView - ), - completion: {} - ) - - // then: the model scale is restored to identity before the frame applies, so the frame application is well-defined, - // and the additive offset continues from the removal's scale - expect(layer.frame) == targetFrame - expect(CATransform3DIsIdentity(layer.transform)) == true - expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 2 - - let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() - expect(animation.fromValue as? CGFloat) == revivalScale - 1 - expect(animation.toValue as? CGFloat) == 0 - } - func test_insertTransition_zeroDuration_appliesTargetAndCompletes() throws { // given: a layer renderable and a zero-duration scale-in transition let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) @@ -199,7 +159,7 @@ class RenderableTransition_ScaleTests: XCTestCase { } func test_insertTransition_zeroDurationRevival_clearsLeftoverAndSnapsToRest() throws { - // given: a layer mid-removal invoked directly: the model transform still carries the removal's scale, with a + // given: a layer mid-removal in the framework flow: the model transform is already reset to identity, with a // leftover additive scale animation attached let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) let targetFrame = Constants.targetFrame @@ -212,8 +172,6 @@ class RenderableTransition_ScaleTests: XCTestCase { leftoverAnimation.isAdditive = true layer.add(leftoverAnimation, forKey: "transform.scale") - layer.setValue(Constants.revivalScale, forKeyPath: "transform.scale") - // when: a zero-duration insert transition animates with a revival transform let transition = RenderableTransition.scale(timing: .linear(duration: 0), options: .insert) let revivalScale = Constants.revivalScale @@ -602,9 +560,9 @@ class RenderableTransition_ScaleTests: XCTestCase { expect(completionCallCount) == 1 } - func test_insertTransition_anchoredLayer_zeroDurationRevival_clearsResidueAndRestoresIdentity() throws { - // given: a layer mid-removal invoked directly: the model transform carries the removal's scale and translation, - // with leftover animations on both key paths + func test_insertTransition_anchoredLayer_zeroDurationRevival_clearsResidueAndSnapsToRest() throws { + // given: a layer mid-removal in the framework flow: the model transform is already reset to identity, with + // leftover animations on both key paths let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) let targetFrame = Constants.targetFrame let layer = TestLayer() @@ -625,9 +583,6 @@ class RenderableTransition_ScaleTests: XCTestCase { layer.add(leftoverTranslation, forKey: "transform.translation") let revivalScale = Constants.revivalScale - layer.setValue(revivalScale, forKeyPath: "transform.scale") - layer.setValue(CGSize(width: 12, height: 15), forKeyPath: "transform.translation") - var revivalTransform = CATransform3DMakeScale(revivalScale, revivalScale, revivalScale) revivalTransform.m41 = 12 revivalTransform.m42 = 15 @@ -645,7 +600,7 @@ class RenderableTransition_ScaleTests: XCTestCase { completion: { completionCallCount += 1 } ) - // then: the leftover animations on both key paths are cleared and the model transform snaps to identity at the + // then: the leftover animations on both key paths are cleared and the renderable rests at identity at the // target frame expect(layer.frame) == targetFrame expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 0 From db5d442994146c4708ed7918221e2d59af5c8aad Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Sun, 6 Sep 2026 22:14:31 -0700 Subject: [PATCH 5/8] [transition] pin the view-renderable vertical-anchor pivot in a rendered test The translation compensation exists for AppKit view-backing layers, which anchor at a corner, but the rendered-edge tests only covered center-anchored plain layers, and the view-renderable test only checked the symmetric center pivot, so a y-axis mistake specific to view-backing layers would have passed every existing test. The new hosted test removes a view renderable with a top-anchored scale and asserts the rendered top edge holds still, covering the corner-anchor and vertical-pivot combination on the real render server. --- .../RenderableTransition+ScaleTests.swift | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift index d9ea0ad..b96105c 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift @@ -770,6 +770,43 @@ class RenderableTransition_ScaleTests: XCTestCase { expect(abs(frameBefore.maxY - 100)).to(beApproximatelyEqual(to: 0, within: 0.5)) } + func test_composeViewIntegration_viewRenderable_topAnchor_renderedTopEdgeStaysFixed() throws { + // given: a hosted compose view showing a view renderable with a slow top-anchored scale transition. + // the view's backing layer anchors at a corner on AppKit and at the center on UIKit, and a vertical pivot is the + // combination a y-axis mistake in the compensation would flip, so this pins the pivot on the risky configuration + let window = TestWindow() + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.contentView().addSubview(contentView) + + contentView.setContent { + ViewNode() + .transition(.scale(anchor: .top, timing: .linear(duration: 10))) + .frame(width: 100, height: 100) + } + contentView.refresh(animated: false) + + // when: the content is removed with animation and the removal renders mid-flight + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) + expect(layer.presentation()).toEventuallyNot(beNil()) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + // then: the rendered frame stays anchored at the visual top while it shrinks: the top edge and the horizontal + // center hold still + let frameBefore = try unwrap(layer.presentation()).frame + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + let frameAfter = try unwrap(layer.presentation()).frame + + expect(frameAfter.height) < frameBefore.height + expect(abs(frameAfter.minY - frameBefore.minY)).to(beApproximatelyEqual(to: 0, within: 0.5)) + expect(abs(frameAfter.midX - frameBefore.midX)).to(beApproximatelyEqual(to: 0, within: 0.5)) + expect(abs(frameBefore.minY)).to(beApproximatelyEqual(to: 0, within: 0.5)) + } + // MARK: - ComposeView Integration func test_composeViewIntegration() throws { From 23746e4484081caf3307cb9e33fe3ab53da9f93d Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Sun, 6 Sep 2026 22:20:06 -0700 Subject: [PATCH 6/8] [transition] correct takeover contract doc and anchor corner comments The takesOverKeyPaths contract predates revivalTransform and only told custom transition authors about revivalPosition, omitting that the render pass also resets the transform before the insert transition runs. The test comments called the AppKit view-backing layer anchor the bottom left corner, which is the unflipped AppKit convention: in ComposeUI's flipped hierarchy the (0, 0) unit point is visually the top left, so the comments now name the unit point instead of a corner. --- .../ComposeNode/RenderItem/RenderableTransition.swift | 5 +++-- .../RenderItem/RenderableTransition+ScaleTests.swift | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift index 58dfcd0..1fcbd3c 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition.swift @@ -76,8 +76,9 @@ public struct RenderableTransition { /// 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. The insertion context's `revivalPosition` is - /// captured earlier, before the render pass applies the target frame. + /// properties land before this transition observes the state. The insertion context's `revivalPosition` and + /// `revivalTransform` are captured earlier, before the render pass applies the target frame and resets the + /// transform to identity. public let takesOverKeyPaths: Set private let animate: (Renderable, Context, @escaping () -> Void) -> Void diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift index b96105c..25b36e0 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift @@ -417,7 +417,7 @@ class RenderableTransition_ScaleTests: XCTestCase { // MARK: - Center Pivot Compensation func test_insertTransition_anchoredLayer_addsCenterPivotCompensation() throws { - // given: a layer anchored at the bottom left corner, like an AppKit view-backing layer, and a scale-in transition + // given: a layer anchored at the (0, 0) unit point, like an AppKit view-backing layer, and a scale-in transition let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) let targetFrame = Constants.targetFrame let layer = TestLayer() @@ -448,7 +448,7 @@ class RenderableTransition_ScaleTests: XCTestCase { } func test_removeTransition_anchoredLayer_addsCenterPivotCompensation() throws { - // given: a layer anchored at the bottom left corner and a scale-out transition + // given: a layer anchored at the (0, 0) unit point and a scale-out transition let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) let currentFrame = Constants.targetFrame let layer = TestLayer() @@ -536,7 +536,7 @@ class RenderableTransition_ScaleTests: XCTestCase { } func test_removeTransition_anchoredLayer_zeroDuration_appliesCompensatedEndState() throws { - // given: a layer anchored at the bottom left corner and a zero-duration scale-out transition removing to 0.5 + // given: a layer anchored at the (0, 0) unit point and a zero-duration scale-out transition removing to 0.5 let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) let currentFrame = Constants.targetFrame let layer = TestLayer() @@ -610,7 +610,7 @@ class RenderableTransition_ScaleTests: XCTestCase { } func test_removeTransition_anchoredLayer_resetForReuse_restoresIdentity() throws { - // given: a layer anchored at the bottom left corner with a scale remove transition in flight + // given: a layer anchored at the (0, 0) unit point with a scale remove transition in flight let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) let layer = TestLayer() layer.anchorPoint = .zero @@ -1041,7 +1041,7 @@ class RenderableTransition_ScaleTests: XCTestCase { let layer = try unwrap(contentView.test.removingRenderableMap.values.first?.renderable.layer) expect(layer.basicAnimations(forKeyPath: "transform.scale").count) == 1 #if canImport(AppKit) - // AppKit anchors view-backing layers at the bottom left corner, so the removal pairs the scale with a center-pivot + // AppKit anchors view-backing layers at the (0, 0) unit point, so the removal pairs the scale with a center-pivot // translation towards the visual center expect(layer.anchorPoint) == .zero expect(layer.basicAnimations(forKeyPath: "transform.translation").count) == 1 From 5d0fa4dd38efc7392bfa99ec088cff9884f46fbe Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Sun, 6 Sep 2026 22:25:49 -0700 Subject: [PATCH 7/8] [tests] extract the leftover animation setup into a helper Seven revival tests built the same six-line additive animation to simulate an in-flight removal's residue, differing only in key path and values. The shared shape (duration, additive, key) now lives in one helper so each test's given block reads as intent and a future change to the leftover shape is a single edit. --- .../RenderableTransition+ScaleTests.swift | 63 ++++++------------- 1 file changed, 19 insertions(+), 44 deletions(-) diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift index 25b36e0..2166b9e 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift @@ -100,12 +100,7 @@ class RenderableTransition_ScaleTests: XCTestCase { let targetFrame = Constants.targetFrame let layer = TestLayer() - let leftoverAnimation = CABasicAnimation(keyPath: "transform.scale") - leftoverAnimation.fromValue = CGFloat(1) - Constants.revivalScale - leftoverAnimation.toValue = CGFloat(0) - leftoverAnimation.duration = 10 - leftoverAnimation.isAdditive = true - layer.add(leftoverAnimation, forKey: "transform.scale") + addLeftoverAnimation(to: layer, keyPath: "transform.scale", from: CGFloat(1) - Constants.revivalScale, to: CGFloat(0)) // the framework applies the target frame as the model value before the transition runs layer.frame = targetFrame @@ -165,12 +160,7 @@ class RenderableTransition_ScaleTests: XCTestCase { let targetFrame = Constants.targetFrame let layer = TestLayer() - let leftoverAnimation = CABasicAnimation(keyPath: "transform.scale") - leftoverAnimation.fromValue = CGFloat(1) - Constants.revivalScale - leftoverAnimation.toValue = CGFloat(0) - leftoverAnimation.duration = 10 - leftoverAnimation.isAdditive = true - layer.add(leftoverAnimation, forKey: "transform.scale") + addLeftoverAnimation(to: layer, keyPath: "transform.scale", from: CGFloat(1) - Constants.revivalScale, to: CGFloat(0)) // when: a zero-duration insert transition animates with a revival transform let transition = RenderableTransition.scale(timing: .linear(duration: 0), options: .insert) @@ -202,12 +192,7 @@ class RenderableTransition_ScaleTests: XCTestCase { let targetFrame = Constants.targetFrame let layer = TestLayer() - let leftoverAnimation = CABasicAnimation(keyPath: "transform.scale") - leftoverAnimation.fromValue = CGFloat(1) - Constants.revivalScale - leftoverAnimation.toValue = CGFloat(0) - leftoverAnimation.duration = 10 - leftoverAnimation.isAdditive = true - layer.add(leftoverAnimation, forKey: "transform.scale") + addLeftoverAnimation(to: layer, keyPath: "transform.scale", from: CGFloat(1) - Constants.revivalScale, to: CGFloat(0)) layer.frame = targetFrame @@ -484,19 +469,8 @@ class RenderableTransition_ScaleTests: XCTestCase { let layer = TestLayer() layer.anchorPoint = .zero - let leftoverScale = CABasicAnimation(keyPath: "transform.scale") - leftoverScale.fromValue = CGFloat(1) - Constants.revivalScale - leftoverScale.toValue = CGFloat(0) - leftoverScale.duration = 10 - leftoverScale.isAdditive = true - layer.add(leftoverScale, forKey: "transform.scale") - - let leftoverTranslation = CABasicAnimation(keyPath: "transform.translation") - leftoverTranslation.fromValue = CGSize(width: -12, height: -15) - leftoverTranslation.toValue = CGSize.zero - leftoverTranslation.duration = 10 - leftoverTranslation.isAdditive = true - layer.add(leftoverTranslation, forKey: "transform.translation") + addLeftoverAnimation(to: layer, keyPath: "transform.scale", from: CGFloat(1) - Constants.revivalScale, to: CGFloat(0)) + addLeftoverAnimation(to: layer, keyPath: "transform.translation", from: CGSize(width: -12, height: -15), to: CGSize.zero) layer.frame = targetFrame @@ -568,19 +542,8 @@ class RenderableTransition_ScaleTests: XCTestCase { let layer = TestLayer() layer.anchorPoint = .zero - let leftoverScale = CABasicAnimation(keyPath: "transform.scale") - leftoverScale.fromValue = CGFloat(1) - Constants.revivalScale - leftoverScale.toValue = CGFloat(0) - leftoverScale.duration = 10 - leftoverScale.isAdditive = true - layer.add(leftoverScale, forKey: "transform.scale") - - let leftoverTranslation = CABasicAnimation(keyPath: "transform.translation") - leftoverTranslation.fromValue = CGSize(width: -12, height: -15) - leftoverTranslation.toValue = CGSize.zero - leftoverTranslation.duration = 10 - leftoverTranslation.isAdditive = true - layer.add(leftoverTranslation, forKey: "transform.translation") + addLeftoverAnimation(to: layer, keyPath: "transform.scale", from: CGFloat(1) - Constants.revivalScale, to: CGFloat(0)) + addLeftoverAnimation(to: layer, keyPath: "transform.translation", from: CGSize(width: -12, height: -15), to: CGSize.zero) let revivalScale = Constants.revivalScale var revivalTransform = CATransform3DMakeScale(revivalScale, revivalScale, revivalScale) @@ -1098,6 +1061,18 @@ class RenderableTransition_ScaleTests: XCTestCase { try assertCenterInvariant() } + // MARK: - Helpers + + /// Adds an additive animation to the layer, like the leftover an in-flight removal leaves behind. + private func addLeftoverAnimation(to layer: CALayer, keyPath: String, from fromValue: Any, to toValue: Any) { + let animation = CABasicAnimation(keyPath: keyPath) + animation.fromValue = fromValue + animation.toValue = toValue + animation.duration = 10 + animation.isAdditive = true + layer.add(animation, forKey: keyPath) + } + // MARK: - Constants private enum Constants { From 23a68406d97680542a4084906bebe38a48388dc3 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Mon, 7 Sep 2026 02:50:27 -0700 Subject: [PATCH 8/8] [transition] condense public transition docs to observable API contracts Per the AGENTS.md doc comment rule, the scale, slide, and opacity docs keep only what callers observe: insertion and removal semantics, pivot and revival continuity, and zero-duration behavior. Implementation rationale moves out: the opacity retarget-vs-stack reasoning now lives on the private retargetOpacity helper, and the additive mechanics live in the PR description and the takesOverKeyPaths contract doc. --- .../RenderableTransition+Opacity.swift | 13 +++++----- .../RenderableTransition+Scale.swift | 24 +++++-------------- .../RenderableTransition+Slide.swift | 21 ++++------------ 3 files changed, 18 insertions(+), 40 deletions(-) diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift index 7351485..e687801 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift @@ -34,12 +34,9 @@ public extension RenderableTransition { /// Creates an opacity transition. /// - /// The transition keeps a single opacity animation on the renderable: starting a transition while another one is in - /// flight replaces the in-flight animation with one that continues from the current visual opacity (and, for spring - /// timings, the current velocity). - /// Stacking is not an option for opacity because the render server clamps opacity per animation while compositing - /// additive animations, so opposing stacked animations do not compose (the screen diverges from the unclamped sum - /// that `presentation()` reports). + /// For insertion, the renderable fades from `from` to `to`. For removal, the renderable fades from its current + /// opacity back to `from`. Starting a transition while another one is in flight continues from the current visual + /// opacity. /// /// - Parameters: /// - from: The starting opacity value. @@ -131,6 +128,10 @@ private extension CALayer { /// with the current velocity, through the spring's initial velocity. Without an in-flight transition, the new /// animation starts from `freshStartValue`. /// + /// Retargeting replaces the in-flight animations instead of stacking on them, because the render server clamps + /// opacity per animation while compositing additive animations, so opposing stacked animations do not compose (the + /// screen diverges from the unclamped sum that `presentation()` reports). + /// /// The timing's delay schedules the new animation's begin time: the interrupted state is evaluated when the /// retargeting is dispatched, and the animation holds its start value until the delay elapses, so an interrupted /// in-flight animation freezes at its sampled value for the delay window. diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift index 2b05cc6..545950d 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift @@ -34,25 +34,13 @@ public extension RenderableTransition { /// Creates a scale transition. /// - /// For insertion, the renderable starts at `from` scale and scales to its natural size at `targetFrame`. - /// For removal, the renderable scales from its current scale down to `from`. + /// For insertion, the renderable starts at `from` scale and scales to its natural size at `targetFrame`. For removal, + /// the renderable scales from its current scale down to `from`. The scaling pivots about `anchor` for any layer + /// anchor point. /// - /// The scaling pivots about `anchor`, the point of the renderable that stays fixed while it scales: each - /// "transform.scale" animation is paired with a same-timing "transform.translation" compensation that keeps the - /// anchor fixed for any layer anchor point (AppKit anchors view-backing layers at a corner, unlike the center anchor - /// elsewhere). The compensation is skipped when it is zero, for a center anchor on a center-anchored layer. - /// - /// The scale is rendered by additive animations while the model transform rests at identity. The transition owns the - /// layer's transform and assumes no other writer touches it. - /// - /// Reviving a renderable while its scale-out is in flight continues the motion: the insertion's offset from the - /// removal's model scale cancels the model change, so the rendered scale doesn't jump, and the leftover exit offset - /// keeps decaying on top while both animations settle into the resting scale. An underdamped timing can overshoot, - /// which renders a momentarily mirrored scale when the composed scale crosses below zero. - /// - /// A zero-duration timing applies the end state 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 state is - /// scheduled as a snap that applies right after the delay window. + /// The transition owns the layer's transform and assumes no other writer touches it. Reviving a renderable while its + /// scale-out is in flight continues the motion from the removal's in-flight state. A zero-duration timing applies the + /// end state and completes immediately, or right after the delay window when the timing has a delay. /// /// - Parameters: /// - from: The scale to insert from and remove to. Values above 1 zoom down into place. Defaults to 0. diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift index a6bcfab..924e388 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift @@ -44,23 +44,12 @@ public extension RenderableTransition { /// Creates a slide transition. /// /// For insertion, the renderable starts outside the content view on the `from` side (with `overshoot` applied) and - /// slides into `targetFrame`. - /// For removal, the renderable slides from its current frame to outside the content view on the `to` side (or `from` - /// when `to` is nil). + /// slides into `targetFrame`. For removal, the renderable slides from its current frame to outside the content view + /// on the `to` side, or the `from` side when `to` is nil. /// - /// 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. - /// - /// 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. + /// Reviving a renderable while its slide-out is in flight continues the motion from wherever the removal left it, so + /// the `from` side only applies to fresh insertions. A zero-duration timing applies the end frame and completes + /// immediately, or right after the delay window when the timing has a delay. /// /// - Parameters: /// - from: The side of the slide transition to slide from.