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/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+Opacity.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift index fa3f949..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. @@ -52,16 +49,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 @@ -128,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 new file mode 100644 index 0000000..545950d --- /dev/null +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Scale.swift @@ -0,0 +1,215 @@ +// +// 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` for any layer + /// anchor point. + /// + /// 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. + /// - 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 + 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 { + // 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) + } + } + 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) + } + + 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", + to: from, + timing: timing, + updateAnimation: { + $0.delegate = AnimationDelegate(animationDidStop: { _, _ in + completion() + }) + } + ) + + if endTranslation != .zero { + layer.animate(keyPath: "transform.translation", to: endTranslation, timing: timing) + } + }, + 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) + ) + } +} diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift index a3f7db6..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. @@ -75,58 +64,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..1fcbd3c 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 } } @@ -70,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/Sources/ComposeUI/ComposeView/ComposeView.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index f7fdf9e..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 { @@ -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) } @@ -1220,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)) @@ -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 { @@ -1429,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 new file mode 100644 index 0000000..2166b9e --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+ScaleTests.swift @@ -0,0 +1,1102 @@ +// +// 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() + + 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 + + // 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_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 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 + let layer = TestLayer() + + 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) + 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() + + addLeftoverAnimation(to: layer, keyPath: "transform.scale", from: CGFloat(1) - Constants.revivalScale, to: CGFloat(0)) + + 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 (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() + 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 (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() + 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 + + 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 + + // 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 (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() + 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_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() + layer.anchorPoint = .zero + + 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) + 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 renderable rests at 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 (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 + 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)) + } + + 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 { + // 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_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() + 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 (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 + 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: - 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 { + + 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)