diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca4de2..fdd4562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,25 @@ # CHANGELOG +## Unreleased + +### 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, + and the delay elapses in the animated layer's time space (a paused or speed-scaled layer scales pending delays with it). + 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). + ## [0.0.5](https://github.com/honghaoz/ComposeUI/releases/tag/0.0.5) (2026-08-08) ### Breaking Changes - `ViewNode` and `LayerNode` intrinsic size closures now receive only the proposed `CGSize`. Capture an external view or layer when its instance is needed for measurement. -- `ScrollViewType` now requires `clipsToBounds`; custom conformers must implement it. +- `ScrollViewType` now requires custom conformers to implement `clipsToBounds`. - `ComposeView` behavior enums gained new cases, and render debug events now use `ComposeNodeId` and updated event names. Update exhaustive switches and debug handlers as needed. - `RenderableTransition` contexts now expose `ComposeView`, and `CALayer.animate` value closures now receive the concrete layer type through `Self`. diff --git a/ComposeUI/Sources/ComposeUI/Animations/AnimationTiming.swift b/ComposeUI/Sources/ComposeUI/Animations/AnimationTiming.swift index 7e90576..e4c4e2e 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/AnimationTiming.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/AnimationTiming.swift @@ -148,7 +148,11 @@ public struct AnimationTiming: Hashable { /// The timing type. public let timing: Timing - /// The delay of the animation. + /// The delay before the animation begins, in seconds. + /// + /// The delay elapses in the animated layer's time space: a paused or speed-scaled layer (or ancestor) scales the + /// delay with it. The delay is not scaled by `speed`, which only scales the animation's own timeline. A zero-duration + /// timing with a delay applies as a snap right after the delay window. public let delay: TimeInterval /// The speed of the animation. diff --git a/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+AnimationTiming.swift b/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+AnimationTiming.swift index 51fc433..f77b92a 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+AnimationTiming.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+AnimationTiming.swift @@ -35,6 +35,9 @@ public extension CABasicAnimation { /// Make an animation based on the timing. /// + /// The timing's delay is not applied here: scheduling the begin time requires the target layer's time space, so + /// `CALayer.animate` sets it when adding the animation. + /// /// - Parameters: /// - timing: The timing of the animation. /// - Returns: The animation. @@ -61,7 +64,9 @@ public extension CABasicAnimation { } animation.speed = Float(timing.speed) - animation.fillMode = .both // avoid the final frame appears before the animation + // backwards fill holds the from value while a scheduled animation waits out its delay, and avoids the final frame + // appearing before the animation starts + animation.fillMode = .both return animation } diff --git a/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+Evaluate.swift b/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+Evaluate.swift index 2edb85d..b0de98f 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+Evaluate.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+Evaluate.swift @@ -47,9 +47,9 @@ extension CABasicAnimation { /// The supported animation shapes are the ones ComposeUI transitions produce: `timeOffset`, `repeatCount`, and /// `autoreverses` are not evaluated. /// - /// - Parameter time: The time in the animation's time space, compared against `beginTime`. An animation with an - /// unset (zero) `beginTime` hasn't been scheduled by Core Animation yet (it is resolved when the transaction - /// commits), so it evaluates at zero elapsed time. + /// - Parameter time: The time in the layer's time space, compared against `beginTime`. An animation with an unset + /// (zero) `beginTime` hasn't been scheduled by Core Animation yet (it is resolved when the transaction commits), + /// and an animation scheduled in the future hasn't started: both evaluate at zero elapsed time, yielding `fromValue`. /// - Returns: The scalar value at `time`. `nil` when `fromValue` or `toValue` is not a scalar number. func scalarValue(at time: TimeInterval) -> Double? { guard let from = (fromValue as? NSNumber)?.doubleValue, diff --git a/ComposeUI/Sources/ComposeUI/Animations/CALayer+Animations.swift b/ComposeUI/Sources/ComposeUI/Animations/CALayer+Animations.swift index f131cf8..ffa19ba 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CALayer+Animations.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CALayer+Animations.swift @@ -30,10 +30,18 @@ import QuartzCore +/// The duration of a scheduled snap: a zero-duration timing with a delay renders as an instant change after the delay +/// window. Core Animation substitutes its default duration for a zero duration, so the snap uses a sub-frame duration +/// instead. +private let scheduledSnapDuration: TimeInterval = 0.001 + public extension CALayer { /// Animate the layer's frame additively. /// + /// The timing's delay schedules the animations' begin time while the model frame updates immediately, see + /// `animate(key:keyPath:timing:from:to:model:updateAnimation:)`. + /// /// - Parameters: /// - to: The frame to animate to. /// - timing: The animation timing. @@ -131,6 +139,8 @@ public extension CALayer { /// Add an animation to the layer. /// + /// See `animate(key:keyPath:timing:from:to:model:updateAnimation:)` for the scheduling behavior of a delayed timing. + /// /// - Important: You must make sure the value type matches the key path type. Otherwise, a crash will occur. /// /// - Parameters: @@ -144,13 +154,14 @@ public extension CALayer { func animate(key: String? = nil, keyPath: String, timing: AnimationTiming, - from: @escaping (Self) -> T, - to: @escaping (Self) -> T, + from: (Self) -> T, + to: (Self) -> T, updateAnimation: ((CABasicAnimation) -> Void)? = nil) { - // Cast `self` to `Self` so the compiler resolves the called overload's `Self` to the dynamic type rather than `CALayer` + // cast `self` to `Self` so the compiler resolves the called overload's `Self` to the dynamic type rather than `CALayer` // otherwise, `(Self) -> T` closures fail to convert to `(CALayer) -> T`. - (self as! Self).animate( // swiftlint:disable:this force_cast + let layer = self as! Self // swiftlint:disable:this force_cast + layer.animate( // swiftlint:disable:this force_cast key: key, keyPath: keyPath, timing: timing, @@ -163,55 +174,71 @@ public extension CALayer { /// Add an animation to the layer. /// + /// The animation is added and the model value is set synchronously. The timing's delay schedules the animation's + /// begin time in the layer's time space, and the animation's fill mode holds the `from` value until the delay + /// elapses, so the layer keeps showing its pre-animation state during the delay window while the model value is + /// already set. A zero-duration timing applies the model value immediately when there is no delay. With a delay, + /// the change is scheduled as a snap that applies right after the delay window. + /// + /// A scheduled animation only survives on a layer that is in a committed layer tree: Core Animation drops animations + /// on detached layers when the enclosing transaction commits. + /// /// - Important: You must make sure the value type matches the key path type. Otherwise, a crash will occur. /// /// - Parameters: /// - key: The key to use for the animation. If `nil`, the key path will be used. /// - keyPath: The key path to animate. /// - timing: The animation timing. - /// - from: The value to animate from. - /// - to: The value to animate to. + /// - from: The value to animate from. Evaluated before the model value is set. A `nil` value on a scheduled + /// non-additive animation is resolved at dispatch, from the presentation value falling back to the model + /// value, because the fill mode can't hold an unresolved value during the delay window. + /// - to: The value to animate to. Evaluated before the model value is set. /// - model: The model value to set. If `nil`, the `to` value will be used. /// - updateAnimation: An optional closure to update the animation. @_spi(Private) func animate(key: String? = nil, keyPath: String, timing: AnimationTiming, - from: @escaping (Self) -> T, - to: @escaping (Self) -> T, + from: (Self) -> T, + to: (Self) -> T, model: ((Self) -> T)?, updateAnimation: ((CABasicAnimation) -> Void)? = nil) { - delay(timing.delay) { [weak self] in - guard let self = self as? Self else { - return // impossible - } + // cast `self` to `Self` so the closures typed over the extension's `Self` accept it. + let layer = self as! Self // swiftlint:disable:this force_cast - let model = model ?? to + guard timing.timing.duration > 0 || timing.delay > 0 else { + setKeyPathValue(keyPath, model?(layer) ?? to(layer)) + return + } - guard timing.timing.duration > 0 else { - self.setKeyPathValue(keyPath, model(self)) - return - } + let animation = CABasicAnimation.makeAnimation(timing) + if timing.timing.duration <= 0 { + animation.duration = scheduledSnapDuration + } + animation.keyPath = keyPath + animation.fromValue = from(layer) + let toValue = to(layer) + animation.toValue = toValue + if timing.delay > 0 { + animation.beginTime = currentTime + timing.delay + } - let animation = CABasicAnimation.makeAnimation(timing) - animation.keyPath = keyPath - animation.fromValue = from(self) - animation.toValue = to(self) + updateAnimation?(animation) - updateAnimation?(animation) + // a nil `T` boxes as `NSNull` when `T` is an optional type, which Core Animation also treats as unresolved + let isFromValueUnresolved = animation.fromValue == nil || animation.fromValue is NSNull + if timing.delay > 0, isFromValueUnresolved, !animation.isAdditive { + // a scheduled to-only animation can't backwards-fill an unresolved from value (the fill would show the target), + // so resolve it at dispatch the way Core Animation would at activation + animation.fromValue = presentation()?.value(forKeyPath: keyPath) ?? value(forKeyPath: keyPath) + } - let rawKey = key ?? keyPath - let key: String - if animation.isAdditive { - key = self.uniqueAnimationKey(key: rawKey) - } else { - key = rawKey - } - self.add(animation, forKey: key) + let rawKey = key ?? keyPath + let animationKey = animation.isAdditive ? uniqueAnimationKey(key: rawKey) : rawKey + add(animation, forKey: animationKey) - self.setKeyPathValue(keyPath, model(self)) - } + setKeyPathValue(keyPath, model?(layer) ?? toValue) } internal func setKeyPathValue(_ keyPath: String, _ value: Any) { @@ -341,6 +368,13 @@ public extension CALayer { return currentKey } + /// The current time in the layer's time space. + /// + /// This is the time that the layer's animation begin times are expressed in. + internal var currentTime: TimeInterval { + convertTime(CACurrentMediaTime(), from: nil) + } + /// The layer's basic animations animating the given key path. /// /// - Parameter keyPath: The animated key path. diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift index b5c2a91..fa3f949 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Opacity.swift @@ -56,7 +56,7 @@ public extension RenderableTransition { renderable.setFrame(context.targetFrame) renderable.layer.retargetOpacity( - freshStartValue: { _ in Float(from) }, + freshStartValue: Float(from), targetValue: Float(to), timing: timing, completion: completion @@ -66,14 +66,13 @@ public extension RenderableTransition { animatedKeyPaths: ["opacity"], animate: { renderable, _, completion in renderable.layer.retargetOpacity( - freshStartValue: { $0.opacity }, + freshStartValue: renderable.layer.opacity, targetValue: Float(from), timing: timing, completion: completion ) }, resetForReuse: { renderable in - renderable.layer.cancelPendingOpacityRetarget() renderable.layer.setKeyPathValue("opacity", Float(1)) } ) : nil @@ -85,12 +84,13 @@ private extension AnimationTiming { /// The timing for a retargeting animation. /// - /// The retargeting animation uses the same curve without the delay, because the retargeting itself absorbs the delay. /// For a spring timing, the interrupted velocity is carried into the spring's initial velocity, in Core Animation's /// convention (positive moves towards the target, in full from-to distances per second), so the spring's derived /// duration accounts for the carried velocity. /// - /// The velocity is dropped for a from-to distance below `RetargetConstants.velocityCarryMinimumDelta`. + /// The velocity is only carried by an immediate retargeting: a delayed one freezes the interrupted motion at rest + /// for the delay window, so its spring launches from rest. The velocity is also dropped for a from-to distance + /// below `RetargetConstants.velocityCarryMinimumDelta`. /// /// - Parameters: /// - velocity: The interrupted rate of change, in value units per second. `nil` when nothing was interrupted. @@ -100,7 +100,7 @@ private extension AnimationTiming { let retargetTiming: Timing switch timing { case .spring(let descriptor, let duration): - if let velocity, abs(delta) > RetargetConstants.velocityCarryMinimumDelta, speed > 0 { + if let velocity, delay == 0, abs(delta) > RetargetConstants.velocityCarryMinimumDelta, speed > 0 { let initialVelocity = CGFloat(-velocity) / (CGFloat(delta) * speed) let descriptor = SpringDescriptor( initialVelocity: initialVelocity, @@ -115,91 +115,70 @@ private extension AnimationTiming { case .timingFunction: retargetTiming = timing } - return AnimationTiming(timing: retargetTiming, delay: 0, speed: speed) + return AnimationTiming(timing: retargetTiming, delay: delay, speed: speed) } } private extension CALayer { - private static var pendingOpacityRetargetKey: UInt8 = 0 - - /// The timer of a delayed opacity retarget that hasn't started yet. - var pendingOpacityRetarget: DispatchSourceTimer? { - get { - objc_getAssociatedObject(self, &CALayer.pendingOpacityRetargetKey) as? DispatchSourceTimer - } - set { - objc_setAssociatedObject(self, &CALayer.pendingOpacityRetargetKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - /// Replaces any in-flight opacity animations with a single additive animation towards `targetValue`. /// - /// When an opacity transition is in flight (running animations, or a delayed retargeting still waiting to start), + /// When an opacity transition is in flight (running animations, or a scheduled one whose delay hasn't elapsed), /// the new animation continues from the opacity the layer currently shows. For a spring timing, it also continues /// with the current velocity, through the spring's initial velocity. Without an in-flight transition, the new - /// animation starts from `freshStartValue`, and a delayed one holds the model opacity at that start value for the - /// delay window. + /// animation starts from `freshStartValue`. + /// + /// 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. + /// + /// A zero-duration timing applies `targetValue` and completes immediately when there is no delay. With a delay, + /// the change is scheduled as a snap that applies right after the delay window. /// /// - Parameters: /// - freshStartValue: The opacity to start from when no opacity transition is in flight. /// - targetValue: The opacity to animate to. Also set as the model value. - /// - timing: The timing for the animation. The delay defers the retargeting itself, so the interrupted state is - /// evaluated when the animation actually starts. A retargeting that starts while an earlier one is still waiting - /// out its delay supersedes the earlier one. - /// - completion: The block called when the animation completes. - func retargetOpacity(freshStartValue: @escaping (CALayer) -> Float, + /// - timing: The timing for the animation. + /// - completion: The block called when the animation completes or is torn down before completing (removed by a + /// superseding retargeting, a reset, or the layer leaving the layer tree). + func retargetOpacity(freshStartValue: Float, targetValue: Float, timing: AnimationTiming, completion: @escaping () -> Void) { - // an opacity transition is in flight when animations are running, or when a delayed retarget is still waiting to - // start (it hasn't animated anything yet, so the model opacity is the visual state to continue from) - let isInFlight = pendingOpacityRetarget != nil || !basicAnimations(forKeyPath: "opacity").isEmpty - - // a pending delayed retarget is superseded: if it fired later, it would tear down this retarget's animation and - // complete this transition with stale values. - cancelPendingOpacityRetarget() + let interrupted = interruptedOpacityState() + removeAnimations(forKeyPath: "opacity") - if timing.delay > 0, !isInFlight { - // a delayed fresh transition shows the renderable at its model value before the animation starts, so hold it at - // the start value for the delay window. - setKeyPathValue("opacity", freshStartValue(self)) + guard timing.timing.duration > 0 || timing.delay > 0 else { + setKeyPathValue("opacity", targetValue) + completion() + return } - pendingOpacityRetarget = delay(timing.delay) { [weak self] in - guard let self else { - return + let start = interrupted?.value ?? freshStartValue + let delta = start - targetValue + + animate( + keyPath: "opacity", + timing: timing.retargeted(carryingVelocity: interrupted?.velocity, over: delta), + from: { _ in delta }, + to: { _ in 0 }, + model: { _ in targetValue }, + updateAnimation: { + $0.isAdditive = true + $0.delegate = AnimationDelegate(animationDidStop: { _, _ in + completion() + }) } - self.pendingOpacityRetarget = nil - - let interrupted = self.interruptedOpacityState() - self.removeAnimations(forKeyPath: "opacity") - - let start = interrupted?.value ?? (isInFlight ? self.opacity : freshStartValue(self)) - let delta = start - targetValue - - self.animate( - keyPath: "opacity", - timing: timing.retargeted(carryingVelocity: interrupted?.velocity, over: delta), - from: { _ in delta }, - to: { _ in 0 }, - model: { _ in targetValue }, - updateAnimation: { - $0.isAdditive = true - $0.delegate = AnimationDelegate(animationDidStop: { _, _ in - completion() - }) - } - ) - } + ) } /// Evaluates the in-flight opacity animations. /// /// The transition owns the renderable layer's opacity, so every opacity animation on the layer is treated as an /// in-flight transition. The animations compose in their key order, the same way Core Animation applies them: a - /// non-additive animation replaces the composed value, and an additive animation contributes on top of it. + /// non-additive animation replaces the composed value, and an additive animation contributes on top of it. A + /// scheduled animation whose delay hasn't elapsed contributes the start value its fill mode is holding. /// /// - Returns: The composed opacity and its rate of change in opacity per second, or `nil` when no opacity animation /// is in flight. The value is clamped to opacity's rendered [0, 1] range, and the rate is zero when it would push @@ -210,7 +189,7 @@ private extension CALayer { return nil } - let now = convertTime(CACurrentMediaTime(), from: nil) + let now = currentTime func composedValue(at time: TimeInterval) -> Double { var value = Double(opacity) @@ -238,12 +217,6 @@ private extension CALayer { } return (Float(clampedValue), velocity) } - - /// Cancels the pending delayed opacity retarget, if any. - func cancelPendingOpacityRetarget() { - pendingOpacityRetarget?.cancel() - pendingOpacityRetarget = nil - } } private enum RetargetConstants { diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift index 1e02a18..a4e7d3b 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift @@ -54,6 +54,9 @@ public extension RenderableTransition { /// transition that slides out to a different side than it slides in from doesn't take over an in-flight removal, and /// a revival snaps to the resting position before sliding in. /// + /// A zero-duration timing applies the end frame and completes immediately when there is no delay. With a delay, + /// the end frame is scheduled as a snap that applies right after the delay window. + /// /// - Parameters: /// - from: The side of the slide transition to slide from. /// - to: The side of the slide transition to slide to for removal. Defaults to `from` when nil. @@ -72,6 +75,12 @@ public extension RenderableTransition { let layer = renderable.layer let targetFrame = context.targetFrame + guard timing.timing.duration > 0 || timing.delay > 0 else { + renderable.setFrame(targetFrame) + completion() + return + } + let initialFrame: CGRect switch fromSide { case .top: @@ -117,6 +126,12 @@ public extension RenderableTransition { targetFrame = currentFrame.translate(dx: context.contentView.bounds().width - currentFrame.minX + overshoot) } + guard timing.timing.duration > 0 || timing.delay > 0 else { + renderable.setFrame(targetFrame) + completion() + return + } + layer.animate( keyPath: "position", timing: timing, diff --git a/ComposeUI/Sources/ComposeUI/ComposeNodes/ModifierNode.swift b/ComposeUI/Sources/ComposeUI/ComposeNodes/ModifierNode.swift index 5faeafc..a2ad21f 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNodes/ModifierNode.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNodes/ModifierNode.swift @@ -335,7 +335,7 @@ public extension ComposeNode { layer.animate( keyPath: "backgroundColor", timing: animationTiming, - from: { $0.presentation()?.backgroundColor }, + from: { $0.presentation()?.backgroundColor ?? $0.backgroundColor ?? Color.clear.cgColor }, to: { _ in color } ) } else { @@ -434,7 +434,7 @@ public extension ComposeNode { layer.animate( keyPath: "borderColor", timing: animationTiming, - from: { $0.presentation()?.borderColor }, + from: { $0.presentation()?.borderColor ?? $0.borderColor ?? Color.clear.cgColor }, to: { _ in color } ) layer.animate(keyPath: "borderWidth", to: width, timing: animationTiming) @@ -583,7 +583,7 @@ public extension ComposeNode { layer.animate( keyPath: "shadowColor", timing: animationTiming, - from: { $0.presentation()?.shadowColor }, + from: { $0.presentation()?.shadowColor ?? $0.shadowColor ?? Color.clear.cgColor }, to: { _ in color } ) layer.animate(keyPath: "shadowOpacity", to: opacity, timing: animationTiming) @@ -593,7 +593,7 @@ public extension ComposeNode { layer.animate( keyPath: "shadowPath", timing: animationTiming, - from: { $0.presentation()?.shadowPath }, + from: { $0.presentation()?.shadowPath ?? $0.shadowPath }, to: { _ in path } ) } else { diff --git a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+AnimationsTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+AnimationsTests.swift index c711047..35d348c 100644 --- a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+AnimationsTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+AnimationsTests.swift @@ -171,6 +171,122 @@ class CALayer_AnimationsTests: XCTestCase { expect(layer.position) == CGPoint(x: 200, y: 200) } + func test_animate_delayed_schedulesAnimation() throws { + let layer = CALayer() + layer.opacity = 0.2 + + layer.animate(keyPath: "opacity", to: Float(1), timing: .linear(duration: 1, delay: 0.5)) + + // the model value is set at dispatch, and the animation is scheduled in the future by the delay, holding the from + // delta so the layer keeps rendering the old value during the delay window + expect(layer.opacity) == 1 + let animation = try unwrap(layer.animation(forKey: "opacity") as? CABasicAnimation) + expect(try unwrap(animation.fromValue as? Float)).to(beApproximatelyEqual(to: -0.8, within: 1e-6)) + expect(animation.toValue as? Float) == 0 + expect(animation.fillMode) == .both + + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) + } + + func test_animate_zeroDuration_appliesModelImmediately() { + let layer = CALayer() + layer.opacity = 0.2 + + layer.animate(keyPath: "opacity", to: Float(1), timing: .linear(duration: 0)) + + // a zero-duration timing without a delay applies the model value immediately + expect(layer.opacity) == 1 + expect(layer.animationKeys()) == nil + } + + func test_animate_delayed_zeroDuration_schedulesSnap() throws { + let layer = CALayer() + layer.opacity = 0.2 + + layer.animate(keyPath: "opacity", to: Float(1), timing: .linear(duration: 0, delay: 0.5)) + + // a zero-duration timing with a delay is a scheduled snap: the animation holds the old value for the delay window, + // then applies the model value as an instant change + expect(layer.opacity) == 1 + let animation = try unwrap(layer.animation(forKey: "opacity") as? CABasicAnimation) + expect(try unwrap(animation.fromValue as? Float)).to(beApproximatelyEqual(to: -0.8, within: 1e-6)) + expect(animation.duration).to(beApproximatelyEqual(to: 0.001, within: 1e-6)) + + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) + } + + func test_animate_delayed_beginTime_usesLayerTimeSpace() throws { + let layer = CALayer() + layer.speed = 2 + layer.opacity = 0.2 + + layer.animate(keyPath: "opacity", to: Float(1), timing: .linear(duration: 1, delay: 0.5)) + + // the delay is expressed in the layer's time space, which runs at twice the media time for this layer, so the begin + // time is the layer's current time plus the delay (far from the media time plus the delay) + let animation = try unwrap(layer.animation(forKey: "opacity") as? CABasicAnimation) + let layerNow = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - layerNow).to(beApproximatelyEqual(to: 0.5, within: 0.1)) + expect(abs(animation.beginTime - (CACurrentMediaTime() + 0.5))).toNot(beApproximatelyEqual(to: 0, within: 1)) + } + + func test_animate_delayed_nilFromValue_resolvesAtDispatch() throws { + let red = CGColor(red: 1, green: 0, blue: 0, alpha: 1) + let green = CGColor(red: 0, green: 1, blue: 0, alpha: 1) + + let layer = CALayer() + layer.backgroundColor = green + + // an unhosted layer has no presentation, so the from closure resolves to nil + layer.animate( + keyPath: "backgroundColor", + timing: .linear(duration: 1, delay: 0.5), + from: { $0.presentation()?.backgroundColor }, + to: { _ in red } + ) + + // the nil from value is resolved at dispatch from the model value, so the scheduled animation's fill can hold + // the old value during the delay window instead of showing the target + let animation = try unwrap(layer.animation(forKey: "backgroundColor") as? CABasicAnimation) + expect(try unwrap(animation.fromValue) as! CGColor) == green // swiftlint:disable:this force_cast + expect(layer.backgroundColor) == red + } + + func test_animate_delayed_holdsFromValueDuringDelayWindow() throws { + let testWindow = TestWindow() + + let layer = CALayer() + testWindow.layer.addSublayer(layer) + layer.frame = CGRect(x: 0, y: 0, width: 50, height: 50) + layer.opacity = 0.2 + CATransaction.flush() + + var isCompleted = false + layer.animate( + keyPath: "opacity", + to: Float(1), + timing: .linear(duration: 0.2, delay: 0.5), + updateAnimation: { + $0.delegate = AnimationDelegate(animationDidStop: { _, _ in + isCompleted = true + }) + } + ) + + // during the delay window, the model is at the target while the presentation holds the old value + expect(layer.presentation()).toEventuallyNot(beNil()) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2)) + expect(layer.opacity) == 1 + expect(try unwrap(layer.presentation()).opacity).to(beApproximatelyEqual(to: 0.2, within: 0.05)) + expect(isCompleted) == false + + // the animation completes after the delay and the duration, landing at the target + expect(isCompleted).toEventually(beTrue(), timeout: 2) + expect(try unwrap(layer.presentation()).opacity).to(beApproximatelyEqual(to: 1, within: 0.05)) + } + func test_animationKey() { // with implicit key do { diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+OpacityTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+OpacityTests.swift index cf1209e..a7a5698 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+OpacityTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+OpacityTests.swift @@ -294,13 +294,11 @@ class RenderableTransition_OpacityTests: XCTestCase { completion: {} ) - // releasing the layer during the delay window is a no-op when the delay fires - weak var weakLayer = layer + // releasing the layer during the delay window releases it with its scheduled animation once the pending + // transaction commits, without the animation ever playing + weak let weakLayer = layer layer = nil - expect(weakLayer) == nil - - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.15)) - weakLayer = nil + expect(weakLayer).toEventually(beNil()) } func test_retarget_springTiming_matchesVelocity() throws { @@ -336,6 +334,32 @@ class RenderableTransition_OpacityTests: XCTestCase { expect(spring.duration).to(beApproximatelyEqual(to: springWithCarriedVelocity.perceptualDuration(), within: 1e-6)) } + func test_retarget_springTiming_delayed_startsFromRest() throws { + let layer = CALayer() + layer.opacity = 1 + + // an insertion halfway through a 10s linear fade: value 0.5, rising at +0.1 per second + addInFlightAdditiveAnimation(to: layer, from: -1, progress: 0.5) + + let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .spring(delay: 0.5)) + try unwrap(transition.remove).animate( + renderable: .layer(layer), + context: RenderableTransition.RemoveTransition.Context(contentView: nil), + completion: {} + ) + + // a delayed retarget freezes the interrupted motion at its sampled value for the delay window, so the scheduled + // spring launches from rest instead of with the stale sampled velocity + let animations = layer.basicAnimations(forKeyPath: "opacity") + expect(animations.count) == 1 + let spring = try unwrap(animations.first as? CASpringAnimation) + expect(try unwrap(spring.fromValue as? Float)).to(beApproximatelyEqual(to: 0.5, within: 0.01)) + expect(spring.initialVelocity) == 0 + + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(spring.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) + } + func test_retarget_springTiming_speedScalesVelocity() throws { let layer = CALayer() layer.opacity = 1 @@ -408,100 +432,113 @@ class RenderableTransition_OpacityTests: XCTestCase { expect(spring.initialVelocity) == 0 } - func test_delayedFreshInsert_hidesLayerDuringDelay() { + func test_delayedFreshInsert_schedulesHeldAnimation() throws { let layer = CALayer() - layer.opacity = 1 + layer.opacity = 0.3 // junk model value, a fresh insertion starts from `from` - let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 1, delay: 0.1)) + let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 1, delay: 0.5)) transition.insert?.animate( renderable: .layer(layer), context: RenderableTransition.InsertTransition.Context(targetFrame: CGRect(x: 0, y: 0, width: 10, height: 10), contentView: nil), completion: {} ) - // during the delay window, the layer is hidden at the start value with no animation yet - expect(layer.opacity) == 0 - expect(layer.basicAnimations(forKeyPath: "opacity").count) == 0 - - // after the delay, the model is at the target. The animation itself is not asserted because Core Animation drops - // animations on layers outside of a layer tree when a transaction commits, and waiting for the delay pumps the run - // loop. The non-delayed tests cover the added animation. - expect(layer.opacity).toEventually(beEqual(to: 1)) + // the model is at the target immediately, and the scheduled animation holds the start value's delta for the delay + // window (the fill-mode hold itself is pinned by the hosted animate tests) + expect(layer.opacity) == 1 + let animations = layer.basicAnimations(forKeyPath: "opacity") + expect(animations.count) == 1 + let animation = try unwrap(animations.first) + expect(try unwrap(animation.fromValue as? Float)) == -1 + expect(animation.toValue as? Float) == 0 + expect(animation.fillMode) == .both + + // the animation is scheduled in the future by the delay, and evaluates to its held start delta until then + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) + expect(try unwrap(animation.scalarValue(at: now))).to(beApproximatelyEqual(to: -1, within: 1e-6)) } - func test_delayedInsert_withInFlightAnimation_keepsCurrentModelDuringDelay() { + func test_delayedInsert_withInFlightAnimation_freezesAtSampledValue() throws { let layer = CALayer() layer.opacity = 0 - // an in-flight removal keeps playing during the delay window, so the model is not touched + // an in-flight removal, halfway through: rendered opacity is 0.5 addInFlightAdditiveAnimation(to: layer, from: 1, progress: 0.5) - let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 1, delay: 0.1)) + let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 1, delay: 0.5)) transition.insert?.animate( renderable: .layer(layer), context: RenderableTransition.InsertTransition.Context(targetFrame: CGRect(x: 0, y: 0, width: 10, height: 10), contentView: nil), completion: {} ) - expect(layer.opacity) == 0 - expect(layer.basicAnimations(forKeyPath: "opacity").count) == 1 + // the in-flight removal is sampled at dispatch and replaced: the scheduled animation holds the sampled value (0.5) + // for the delay window, then fades to the target + expect(layer.opacity) == 1 + let animations = layer.basicAnimations(forKeyPath: "opacity") + expect(animations.count) == 1 + let animation = try unwrap(animations.first) + expect(try unwrap(animation.fromValue as? Float)).to(beApproximatelyEqual(to: -0.5, within: 0.01)) - // after the delay, the model is at the target. The animation itself is not asserted because Core Animation drops - // animations on layers outside of a layer tree when a transaction commits, and waiting for the delay pumps the run - // loop. The non-delayed tests cover the added animation. - expect(layer.opacity).toEventually(beEqual(to: 1)) + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) } - func test_delayedRetarget_supersededByNewRetarget_doesNotFire() throws { + func test_delayedRetarget_scheduledAnimationIsSuperseded() throws { let layer = CALayer() layer.opacity = 0 - // an in-flight removal, halfway through + // an in-flight removal, halfway through: rendered opacity is 0.5 addInFlightAdditiveAnimation(to: layer, from: 1, progress: 0.5) - // a delayed insert schedules its retarget for after the delay window + // a delayed insert replaces it with a scheduled animation holding the sampled value (0.5), model at 1 var insertCompletionCallCount = 0 - let insertTransition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 1, delay: 0.1)) + let insertTransition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 1, delay: 0.5)) insertTransition.insert?.animate( renderable: .layer(layer), context: RenderableTransition.InsertTransition.Context(targetFrame: CGRect(x: 0, y: 0, width: 10, height: 10), contentView: nil), completion: { insertCompletionCallCount += 1 } ) - // a remove interrupts during the delay window, superseding the pending insert retarget + // a remove interrupts during the insert's delay window: it samples the scheduled animation's held value + // (model 1 + held delta -0.5 = 0.5) and replaces it with its own animation towards 0 let removeTransition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 5)) try unwrap(removeTransition.remove).animate( renderable: .layer(layer), context: RenderableTransition.RemoveTransition.Context(contentView: nil), completion: {} ) - expect(layer.opacity) == 0 - expect(layer.basicAnimations(forKeyPath: "opacity").count) == 1 - // past the delay window, the superseded insert retarget must not fire: it would tear down the remove's - // animation, complete the removal with stale values, and set the model to its own target (1). - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.25)) expect(layer.opacity) == 0 + let animations = layer.basicAnimations(forKeyPath: "opacity") + expect(animations.count) == 1 + expect(try unwrap(animations.first?.fromValue as? Float)).to(beApproximatelyEqual(to: 0.5, within: 0.01)) + + // the superseded insert's animation was removed without finishing, so its completion reports as stopped (Core + // Animation delivers the callback on a later run loop turn) expect(insertCompletionCallCount) == 0 + expect(insertCompletionCallCount).toEventually(beEqual(to: 1)) } - func test_delayedRetarget_supersededPendingRetarget_continuesFromCurrentOpacity() throws { + func test_delayedRetarget_scheduledRemove_supersededByInsert_continuesFromHeldValue() throws { let layer = CALayer() layer.opacity = 1 - // a delayed remove schedules its retarget: nothing is animating yet, the layer still shows its model opacity + // a delayed remove: the model moves to 0 at dispatch, and the scheduled animation holds the old value (1) for the + // delay window let removeTransition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 5, delay: 0.5)) try unwrap(removeTransition.remove).animate( renderable: .layer(layer), context: RenderableTransition.RemoveTransition.Context(contentView: nil), completion: {} ) - expect(layer.opacity) == 1 - expect(layer.basicAnimations(forKeyPath: "opacity").count) == 0 + expect(layer.opacity) == 0 + expect(layer.basicAnimations(forKeyPath: "opacity").count) == 1 - // an insert supersedes the pending remove before it fires: the layer is still fully visible, so the insert - // continues from the current opacity (a no-op fade from 1 to 1) instead of restarting from its fresh start - // value (a snap to 0 followed by a fade-in) + // an insert supersedes the scheduled remove before its delay elapses: the layer still shows the held value (1), so + // the insert continues from it (a no-op fade from 1 to 1) instead of restarting from its fresh start value (a snap + // to 0 followed by a fade-in) let insertTransition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 5)) try unwrap(insertTransition.insert).animate( renderable: .layer(layer), @@ -515,11 +552,11 @@ class RenderableTransition_OpacityTests: XCTestCase { expect(try unwrap(animations.first?.fromValue as? Float)) == 0 } - func test_delayedInsert_withPendingRetarget_keepsCurrentModelDuringDelay() throws { + func test_delayedInsert_overScheduledRemove_continuesFromHeldValue() throws { let layer = CALayer() layer.opacity = 1 - // a delayed remove schedules its retarget: nothing is animating yet + // a delayed remove: the scheduled animation holds the old value (1) for the delay window let removeTransition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 5, delay: 0.5)) try unwrap(removeTransition.remove).animate( renderable: .layer(layer), @@ -527,46 +564,102 @@ class RenderableTransition_OpacityTests: XCTestCase { completion: {} ) - // a delayed insert supersedes the pending remove: the transition is in flight (pending), so the insert must not - // hold the layer at its fresh start value (0), the layer keeps showing its current opacity for the delay window - let insertTransition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 5, delay: 0.1)) + // a delayed insert supersedes the scheduled remove: it samples the held value (1) at dispatch and schedules its own + // animation from it, so the layer keeps rendering 1 through both delay windows + let insertTransition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 5, delay: 0.5)) try unwrap(insertTransition.insert).animate( renderable: .layer(layer), context: RenderableTransition.InsertTransition.Context(targetFrame: CGRect(x: 0, y: 0, width: 10, height: 10), contentView: nil), completion: {} ) - expect(layer.opacity) == 1 - // past both delay windows, the cancelled remove never fired and the insert continued from the current opacity - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.7)) expect(layer.opacity) == 1 + let animations = layer.basicAnimations(forKeyPath: "opacity") + expect(animations.count) == 1 + let animation = try unwrap(animations.first) + expect(try unwrap(animation.fromValue as? Float)) == 0 + + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) } - func test_delayedRetarget_cancelledByResetForReuse_doesNotFire() throws { + func test_delayedRetarget_scheduledAnimationIsRemovedByReset() throws { let layer = CALayer() layer.opacity = 1 - // a delayed remove schedules its retarget for after the delay window + // a delayed remove: the model moves to 0 at dispatch, and the scheduled animation holds the old value var removeCompletionCallCount = 0 - let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 1, delay: 0.1)) + let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 1, delay: 0.5)) let removeTransition = try unwrap(transition.remove) removeTransition.animate( renderable: .layer(layer), context: RenderableTransition.RemoveTransition.Context(contentView: nil), completion: { removeCompletionCallCount += 1 } ) + expect(layer.opacity) == 0 + expect(layer.basicAnimations(forKeyPath: "opacity").count) == 1 - // the renderable is reset during the delay window (e.g. recycled to the pool, or revived without a - // taking-over insert transition), cancelling the pending retarget + // the renderable is reset during the delay window (e.g. recycled to the pool, or revived without a taking-over + // insert transition): the scheduled animation is removed with the rest of the residue removeTransition.resetForReuse(renderable: .layer(layer)) - expect(layer.opacity) == 1 - // past the delay window, the cancelled remove retarget must not fire: it would fade the reset renderable - // towards its own target (0). - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.25)) expect(layer.opacity) == 1 expect(layer.basicAnimations(forKeyPath: "opacity").count) == 0 + + // the torn-down animation reports as stopped, so the completion fires on a later run loop turn. the framework + // cancels the transition's completion before resetting, so the late call is inert there expect(removeCompletionCallCount) == 0 + expect(removeCompletionCallCount).toEventually(beEqual(to: 1)) + } + + func test_zeroDurationTransition_appliesTargetAndCompletes() throws { + let layer = CALayer() + layer.opacity = 1 + + // an in-flight animation is torn down by the zero-duration retarget + addInFlightAdditiveAnimation(to: layer, from: 1, progress: 0.5) + + var removeCompletionCallCount = 0 + let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 0)) + try unwrap(transition.remove).animate( + renderable: .layer(layer), + context: RenderableTransition.RemoveTransition.Context(contentView: nil), + completion: { removeCompletionCallCount += 1 } + ) + + // the target value applies and the transition completes immediately, with no animation left behind + expect(layer.opacity) == 0 + expect(layer.basicAnimations(forKeyPath: "opacity").count) == 0 + expect(removeCompletionCallCount) == 1 + } + + func test_zeroDurationTransition_delayed_schedulesSnap() throws { + let layer = CALayer() + layer.opacity = 0 + + // an in-flight removal, halfway through: rendered opacity is 0.5 + addInFlightAdditiveAnimation(to: layer, from: 1, progress: 0.5) + + var insertCompletionCallCount = 0 + let transition = RenderableTransition.opacity(from: 0, to: 1, timing: .linear(duration: 0, delay: 0.5)) + transition.insert?.animate( + renderable: .layer(layer), + context: RenderableTransition.InsertTransition.Context(targetFrame: CGRect(x: 0, y: 0, width: 10, height: 10), contentView: nil), + completion: { insertCompletionCallCount += 1 } + ) + + // a zero-duration timing with a delay is a scheduled snap: the sampled value (0.5) holds for the delay window, + // then snaps to the target, completing through the animation + expect(layer.opacity) == 1 + let animations = layer.basicAnimations(forKeyPath: "opacity") + expect(animations.count) == 1 + let animation = try unwrap(animations.first) + expect(try unwrap(animation.fromValue as? Float)).to(beApproximatelyEqual(to: -0.5, within: 0.01)) + expect(animation.duration).to(beApproximatelyEqual(to: 0.001, within: 1e-6)) + expect(insertCompletionCallCount) == 0 + + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + expect(animation.beginTime - now).to(beApproximatelyEqual(to: 0.5, within: 0.1)) } /// Adds an in-flight additive opacity animation with a known progress to `layer`. diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+SlideTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+SlideTests.swift index 16b066b..05b5260 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+SlideTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNode/RenderItem/RenderableTransition+SlideTests.swift @@ -352,6 +352,102 @@ class RenderableTransition_SlideTests: XCTestCase { expect(layer.frame) == expectedTargetFrame } + func test_insertTransition_zeroDuration_appliesTargetAndCompletes() throws { + let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) + let layer = TestLayer() + let renderable = Renderable.layer(layer) + let transition = RenderableTransition.slide( + from: .top, + overshoot: Constants.overshoot, + timing: .linear(duration: 0), + options: .insert + ) + + var completionCallCount = 0 + try transition.insert.unwrap().animate( + renderable: renderable, + context: RenderableTransition.InsertTransition.Context(targetFrame: Constants.targetFrame, contentView: contentView), + completion: { completionCallCount += 1 } + ) + + // the target frame applies and the transition completes immediately, with no animation added + expect(layer.frame) == Constants.targetFrame + expect(layer.animationKeys()) == nil + expect(completionCallCount) == 1 + } + + func test_removeTransition_zeroDuration_appliesTargetAndCompletes() throws { + 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.slide( + from: .top, + overshoot: Constants.overshoot, + timing: .linear(duration: 0), + options: .remove + ) + + var completionCallCount = 0 + try transition.remove.unwrap().animate( + renderable: renderable, + context: RenderableTransition.RemoveTransition.Context(contentView: contentView), + completion: { completionCallCount += 1 } + ) + + // the off-screen end frame applies and the transition completes immediately, with no animation added + let expectedTargetFrame = CGRect( + x: currentFrame.origin.x, + y: -currentFrame.height - Constants.overshoot, + width: currentFrame.width, + height: currentFrame.height + ) + expect(layer.frame) == expectedTargetFrame + expect(layer.animationKeys()) == nil + expect(completionCallCount) == 1 + } + + func test_removeTransition_delayedZeroDuration_schedulesSnap() throws { + 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.slide( + from: .top, + overshoot: Constants.overshoot, + timing: .linear(duration: 0, delay: 0.5), + options: .remove + ) + + var completionCallCount = 0 + try transition.remove.unwrap().animate( + renderable: renderable, + context: RenderableTransition.RemoveTransition.Context(contentView: contentView), + completion: { completionCallCount += 1 } + ) + + // a zero-duration timing with a delay is a scheduled snap: the renderable holds its current frame for the delay + // window, then snaps off-screen, completing through the animation + let expectedTargetFrame = CGRect( + x: currentFrame.origin.x, + y: -currentFrame.height - Constants.overshoot, + width: currentFrame.width, + height: currentFrame.height + ) + expect(layer.frame) == expectedTargetFrame + + let animation = try (layer.addedAnimation as? CABasicAnimation).unwrap() + expect(animation.keyPath) == "position" + expect(animation.fromValue as? CGPoint) == layer.position(from: currentFrame) - layer.position(from: expectedTargetFrame) + 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_with_toSide() throws { let contentView = ComposeView(frame: CGRect(origin: .zero, size: Constants.contentSize)) let currentFrame = Constants.targetFrame diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+TransitionTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+TransitionTests.swift index ce5d0ba..b42f7e2 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+TransitionTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+TransitionTests.swift @@ -84,6 +84,33 @@ class ComposeView_TransitionTests: XCTestCase { expect(contentView.test.removingRenderableMap.count) == 0 } + func test_delayedTransition_removalCompletesThroughScheduledAnimation() { + let window = TestWindow() + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.contentView().addSubview(contentView) + + contentView.setContent { + ColorNode(.red) + .transition(.opacity(timing: .linear(duration: 0.1, delay: 0.3))) + .frame(width: 100, height: 100) + } + contentView.refresh(animated: true) + + contentView.setContent { + Empty() + } + contentView.refresh(animated: true) + + // the removal is in flight while the scheduled animation waits out its delay: it must not complete during the + // delay window + expect(contentView.test.removingRenderableMap.count) == 1 + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + expect(contentView.test.removingRenderableMap.count) == 1 + + // the scheduled animation's completion finishes the removal after the delay and the duration + expect(contentView.test.removingRenderableMap.count).toEventually(beEqual(to: 0), timeout: 2) + } + func test_reinsertRemovingRenderable() { let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS.xcodeproj/project.pbxproj b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS.xcodeproj/project.pbxproj index 3484afd..c28f7fc 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS.xcodeproj/project.pbxproj +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ E5ED82052D87B2AF00B3FE3A /* Playground+AnimatingComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5ED82032D87B2AF00B3FE3A /* Playground+AnimatingComposeView.swift */; }; E5ED82062D87B2AF00B3FE3A /* Playground+TransitionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5ED82042D87B2AF00B3FE3A /* Playground+TransitionView.swift */; }; FAD29AA7E71C49C681B130B7 /* Playground+TransitionRevivalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFAF329A87694966B1616473 /* Playground+TransitionRevivalView.swift */; }; + A7C41E92B35D4F8A9C02D611 /* Playground+AnimateLabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F52A81C46E5D9B8D13E722 /* Playground+AnimateLabView.swift */; }; + A7C41E92B35D4F8A9C02D622 /* Playground+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F52A81C46E5D9B8D13E733 /* Playground+Debug.swift */; }; E5F25D7F2D9A5BEF00183721 /* Playground+ShadowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F25D7E2D9A5BEF00183721 /* Playground+ShadowView.swift */; }; /* End PBXBuildFile section */ @@ -51,6 +53,8 @@ E5ED82032D87B2AF00B3FE3A /* Playground+AnimatingComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+AnimatingComposeView.swift"; sourceTree = ""; }; E5ED82042D87B2AF00B3FE3A /* Playground+TransitionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+TransitionView.swift"; sourceTree = ""; }; EFAF329A87694966B1616473 /* Playground+TransitionRevivalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+TransitionRevivalView.swift"; sourceTree = ""; }; + B3F52A81C46E5D9B8D13E722 /* Playground+AnimateLabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+AnimateLabView.swift"; sourceTree = ""; }; + B3F52A81C46E5D9B8D13E733 /* Playground+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+Debug.swift"; sourceTree = ""; }; E5F25D7E2D9A5BEF00183721 /* Playground+ShadowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+ShadowView.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -106,6 +110,8 @@ E5ED82032D87B2AF00B3FE3A /* Playground+AnimatingComposeView.swift */, E5ED82042D87B2AF00B3FE3A /* Playground+TransitionView.swift */, EFAF329A87694966B1616473 /* Playground+TransitionRevivalView.swift */, + B3F52A81C46E5D9B8D13E722 /* Playground+AnimateLabView.swift */, + B3F52A81C46E5D9B8D13E733 /* Playground+Debug.swift */, E5D636CD2D7CEA270080A152 /* Playground+FrameView.swift */, 021EAA032E2F6C190094431C /* Playground+LayersView.swift */, 021EAA062E8000010094431C /* Playground+ZOrderView.swift */, @@ -207,6 +213,8 @@ E5ED82052D87B2AF00B3FE3A /* Playground+AnimatingComposeView.swift in Sources */, E5ED82062D87B2AF00B3FE3A /* Playground+TransitionView.swift in Sources */, FAD29AA7E71C49C681B130B7 /* Playground+TransitionRevivalView.swift in Sources */, + A7C41E92B35D4F8A9C02D611 /* Playground+AnimateLabView.swift in Sources */, + A7C41E92B35D4F8A9C02D622 /* Playground+Debug.swift in Sources */, E5D636D42D7CECAB0080A152 /* Colors.swift in Sources */, E58E92452CCF40FD0076FEFB /* AppDelegate.swift in Sources */, E58E92472CCF40FD0076FEFB /* SceneDelegate.swift in Sources */, diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+AnimateLabView.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+AnimateLabView.swift new file mode 100644 index 0000000..2437c4d --- /dev/null +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+AnimateLabView.swift @@ -0,0 +1,336 @@ +// +// Playground+AnimateLabView.swift +// ComposéUI +// +// Created by Honghao Zhang on 8/27/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. +// + +#if canImport(AppKit) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +@_spi(Private) import ComposeUI + +extension Playground { + + /// An interactive page for exercising the `CALayer` animate APIs directly. + /// + /// The box is a plain sublayer outside of the render pass's management, animated only by the animate APIs. The toggle + /// buttons drive a single animation each (immediate or with a 1s delay), and the scenario buttons run scripted + /// sequences with fixed internal timings, so a session on one build can be compared with a session on another, + /// visually and through the logged samples. + /// + /// Things to observe across builds: + /// - A delayed animation shows the old value during the delay window, then animates. + /// - When the model value changes relative to the visible change (the sample logs both). + /// - How a delayed animation composes with an in-flight one, and the final resting values. + final class AnimateLabView: ComposeView { + + private enum Constants { + static let boxSize: CGFloat = 48 + static let boxMargin: CGFloat = 20 + static let duration: TimeInterval = 2.5 + static let delay: TimeInterval = 1 + static let fadedOpacity: Float = 0.15 + static let cornerRadiusNormal: CGFloat = 6 + static let cornerRadiusRounded: CGFloat = 24 + } + + /// The stage layer hosting the box. The box is positioned in the stage's coordinates once the stage has a size, and + /// is otherwise fully owned by the animate calls. + private let stageLayer = CALayer() + + private let boxLayer = CALayer() + private var isBoxPositioned = false + + private var isMovedRight = false + private var isFaded = false + private var isRounded = false + + private var samplingTimer: Timer? + private var lastSampleLine: String? + + private typealias Debug = Playground.Debug + + /// The time base for the logs: reset on every tap, so sample timestamps are relative to the last action and sessions + /// from different builds can be compared line by line. + private var referenceTime: CFTimeInterval = CACurrentMediaTime() + + /// Identifies the scenario run owning the scheduled steps, so a newer run cancels the older run's steps. + private var scenarioToken = UUID() + + @ComposeContentBuilder + override var content: ComposeContent { + VStack(spacing: 10) { + LayerNode( + make: { [weak self] _ in self?.stageLayer ?? CALayer() }, + update: { [weak self] _, context in + self?.positionBoxIfNeeded(stageSize: context.newFrame.size) + } + ) + .underlay { + LayerNode() + .border(color: Color.gray, width: 1) + } + .frame(width: .flexible, height: 100) + + HStack(spacing: 10) { + Playground.button(title: "Move ⇄", fontSize: 11) { [weak self] in + self?.tap("Move") { self?.move(delayed: false) } + } + Playground.button(title: "Fade ⇄", fontSize: 11) { [weak self] in + self?.tap("Fade") { self?.fade(delayed: false) } + } + Playground.button(title: "Corner ⇄", fontSize: 11) { [weak self] in + self?.tap("Corner") { self?.corner(delayed: false) } + } + } + .frame(width: .flexible, height: 32) + + HStack(spacing: 10) { + Playground.button(title: "Move ⇄ +1s", fontSize: 11) { [weak self] in + self?.tap("Move delayed") { self?.move(delayed: true) } + } + Playground.button(title: "Fade ⇄ +1s", fontSize: 11) { [weak self] in + self?.tap("Fade delayed") { self?.fade(delayed: true) } + } + Playground.button(title: "Corner ⇄ +1s", fontSize: 11) { [weak self] in + self?.tap("Corner delayed") { self?.corner(delayed: true) } + } + } + .frame(width: .flexible, height: 32) + + HStack(spacing: 10) { + Playground.button(title: "S1: fresh delayed move", fontSize: 11) { [weak self] in + self?.runScenario("S1 fresh delayed move", steps: [ + (0, "move delayed", { self?.move(delayed: true) }), + ]) + } + Playground.button(title: "S2: interrupt in-flight", fontSize: 11) { [weak self] in + self?.runScenario("S2 delayed move during in-flight move", steps: [ + (0, "move", { self?.move(delayed: false) }), + (0.6, "move back delayed", { self?.move(delayed: true) }), + ]) + } + } + .frame(width: .flexible, height: 32) + + HStack(spacing: 10) { + Playground.button(title: "S3: two delayed moves", fontSize: 11) { [weak self] in + self?.runScenario("S3 two overlapping delayed moves", steps: [ + (0, "move delayed", { self?.move(delayed: true) }), + (0.4, "move back delayed", { self?.move(delayed: true) }), + ]) + } + Playground.button(title: "S4: stacked delayed fades", fontSize: 11) { [weak self] in + self?.runScenario("S4 delayed fade during in-flight fade", steps: [ + (0, "fade", { self?.fade(delayed: false) }), + (0.6, "fade back delayed", { self?.fade(delayed: true) }), + ]) + } + Playground.button(title: "Reset", fontSize: 11) { [weak self] in + self?.tap("Reset") { self?.reset() } + } + } + .frame(width: .flexible, height: 32) + } + .padding(12) + } + + override init(frame: CGRect) { + super.init(frame: frame) + clippingBehavior = .always + } + + // MARK: - Actions + + private static func homeFrame(in stageBounds: CGRect) -> CGRect { + CGRect( + x: Constants.boxMargin, + y: (stageBounds.height - Constants.boxSize) / 2, + width: Constants.boxSize, + height: Constants.boxSize + ) + } + + /// Positions the box at its home frame once the stage has a size. + private func positionBoxIfNeeded(stageSize: CGSize) { + guard !isBoxPositioned, stageSize.width > 0 else { + return + } + isBoxPositioned = true + + stageLayer.masksToBounds = true + stageLayer.addSublayer(boxLayer) + + CATransaction.begin() + CATransaction.setDisableActions(true) + boxLayer.frame = Self.homeFrame(in: CGRect(origin: .zero, size: stageSize)) + boxLayer.backgroundColor = Colors.blueGray.cgColor + boxLayer.cornerRadius = Constants.cornerRadiusNormal + CATransaction.commit() + } + + private func timing(delayed: Bool) -> AnimationTiming { + .easeInEaseOut(duration: Constants.duration, delay: delayed ? Constants.delay : 0) + } + + private func move(delayed: Bool) { + isMovedRight.toggle() + var targetFrame = Self.homeFrame(in: stageLayer.bounds) + if isMovedRight { + targetFrame.origin.x = stageLayer.bounds.width - Constants.boxSize - Constants.boxMargin + } + log("DISPATCH animateFrame(to: \(Debug.format(targetFrame.origin)), delay: \(delayed ? Constants.delay : 0))") + boxLayer.animateFrame(to: targetFrame, timing: timing(delayed: delayed)) + } + + private func fade(delayed: Bool) { + isFaded.toggle() + let targetOpacity: Float = isFaded ? Constants.fadedOpacity : 1 + log("DISPATCH animate(opacity, to: \(Debug.format(targetOpacity)), delay: \(delayed ? Constants.delay : 0))") + boxLayer.animate(keyPath: "opacity", to: targetOpacity, timing: timing(delayed: delayed)) + } + + private func corner(delayed: Bool) { + isRounded.toggle() + let targetRadius = isRounded ? Constants.cornerRadiusRounded : Constants.cornerRadiusNormal + log("DISPATCH animate(cornerRadius, to: \(Debug.format(targetRadius)), delay: \(delayed ? Constants.delay : 0))") + boxLayer.animate(keyPath: "cornerRadius", to: targetRadius, timing: timing(delayed: delayed)) + } + + /// Restores the box to its home state with no animations, so scenario runs start from the same state. + private func reset() { + scenarioToken = UUID() + boxLayer.removeAllAnimations() + CATransaction.begin() + CATransaction.setDisableActions(true) + boxLayer.frame = Self.homeFrame(in: stageLayer.bounds) + boxLayer.opacity = 1 + boxLayer.cornerRadius = Constants.cornerRadiusNormal + CATransaction.commit() + isMovedRight = false + isFaded = false + isRounded = false + log("RESET") + } + + // MARK: - Scenarios + + /// Resets the box, then runs the steps at their fixed offsets, logging each one. + /// + /// Starting a new scenario (or tapping any other button) cancels the previous scenario's remaining steps. + private func runScenario(_ name: String, steps: [(offset: TimeInterval, name: String, action: () -> Void)]) { + referenceTime = CACurrentMediaTime() + log("SCENARIO \(name)") + reset() + + let token = UUID() + scenarioToken = token + for step in steps { + DispatchQueue.main.asyncAfter(deadline: .now() + step.offset) { [weak self] in + guard let self, self.scenarioToken == token else { + return + } + self.log("STEP \(step.name)") + step.action() + } + } + } + + /// Logs a tap and runs its action, cancelling any scenario in progress. + private func tap(_ name: String, action: () -> Void) { + referenceTime = CACurrentMediaTime() + scenarioToken = UUID() + log("TAP \(name)") + action() + } + + // MARK: - Sampling + + #if canImport(UIKit) + override func didMoveToWindow() { + super.didMoveToWindow() + updateSampling() + } + #endif + + #if canImport(AppKit) + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateSampling() + } + #endif + + deinit { + samplingTimer?.invalidate() + } + + /// Runs the sampling timer while the view is in a window. + private func updateSampling() { + guard window != nil else { + samplingTimer?.invalidate() + samplingTimer = nil + return + } + guard samplingTimer == nil else { + return + } + let timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in + self?.sampleBoxState() + } + RunLoop.main.add(timer, forMode: .common) + samplingTimer = timer + } + + /// Logs the box layer's state when it changed since the last sample. + private func sampleBoxState() { + let line = describeBox() + guard line != lastSampleLine else { + return + } + lastSampleLine = line + log("SAMPLE \(line)") + } + + private func describeBox() -> String { + let layer = boxLayer + let model = "position = \(Debug.format(layer.position)), opacity = \(Debug.format(layer.opacity)), corner = \(Debug.format(layer.cornerRadius))" + let presentation = layer.presentation().map { + "presentation: position = \(Debug.format($0.position)), opacity = \(Debug.format($0.opacity)), corner = \(Debug.format($0.cornerRadius))" + } ?? "presentation: nil" + return "\(model), \(presentation), animations = \(Debug.describeAnimations(of: layer))" + } + + private func log(_ message: String) { + print("[AnimateLab] \(String(format: "+%.3f", CACurrentMediaTime() - referenceTime)) | \(message)") + } + } +} diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+Debug.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+Debug.swift new file mode 100644 index 0000000..fcbf13a --- /dev/null +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+Debug.swift @@ -0,0 +1,135 @@ +// +// Playground+Debug.swift +// ComposéUI +// +// Created by Honghao Zhang on 8/27/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. +// + +#if canImport(AppKit) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +import ComposeUI + +extension Playground { + + /// Formatting helpers for logging layer and animation state in playground pages. + enum Debug { + + /// Describes the layer's attached animations, with each animation's endpoints and elapsed time. + static func describeAnimations(of layer: CALayer) -> String { + let keys = layer.animationKeys() ?? [] + guard !keys.isEmpty else { + return "[]" + } + let now = layer.convertTime(CACurrentMediaTime(), from: nil) + let descriptions = keys.map { key -> String in + guard let animation = layer.animation(forKey: key) as? CABasicAnimation else { + return "\(key): \(type(of: layer.animation(forKey: key) as Any))" + } + let from = describeValue(animation.fromValue) + let to = describeValue(animation.toValue) + let elapsed = now - animation.beginTime + return "\(key)(\(animation.keyPath ?? "?")): \(from) -> \(to)\(animation.isAdditive ? " additive" : ""), elapsed = \(format(elapsed))/\(format(animation.duration))s" + } + return "[\(descriptions.joined(separator: " | "))]" + } + + /// Describes an animation endpoint value. + static func describeValue(_ value: Any?) -> String { + switch value { + case let number as NSNumber: + return format(number.doubleValue) + case let point as NSValue: + #if canImport(UIKit) + return format(point.cgPointValue) + #else + return format(point.pointValue) + #endif + case .none: + return "nil" + case .some(let other): + return String(describing: other) + } + } + + static func format(_ value: Double) -> String { + String(format: "%.3f", value) + } + + static func format(_ value: Float) -> String { + String(format: "%.3f", value) + } + + static func format(_ value: CGFloat) -> String { + String(format: "%.3f", value) + } + + static func format(_ point: CGPoint) -> String { + String(format: "(%.1f, %.1f)", point.x, point.y) + } + } + + /// Makes a standard playground action button. + /// + /// - Parameters: + /// - title: The button title. + /// - fontSize: The title's font size. `nil` uses the label's default font. + /// - onTap: The tap handler. + static func button(title: String, fontSize: CGFloat? = nil, onTap: @escaping () -> Void) -> ComposeNode { + ButtonNode( + content: { state in + let backgroundColor: Color + switch state { + case .normal, + .hovered: + backgroundColor = Colors.blueGray + case .pressed, + .selected: + backgroundColor = Colors.darkBlueGray + case .disabled: + backgroundColor = Colors.lightBlueGray + } + var label = Label(title) + .textColor(.white) + .selectable(false) + if let fontSize { + label = label.font(.systemFont(ofSize: fontSize)) + } + ColorNode(backgroundColor) + .cornerRadius(6) + .overlay { + label + } + }, + onTap: onTap + ) + } +} diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift index b3a2575..95dfe96 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift @@ -115,6 +115,8 @@ extension Playground { private var samplingTimer: Timer? private var lastSampleLine: String? + private typealias Debug = Playground.Debug + /// Whether the box layer can be tracked, which requires the content view's DEBUG-only debug events. private var isSamplingSupported: Bool { #if DEBUG @@ -141,7 +143,7 @@ extension Playground { .frame(width: .flexible, height: .flexible) HStack(spacing: 12) { - button(title: isShowing ? "Remove (animated)" : "Insert (animated)") { [weak self] in + Playground.button(title: isShowing ? "Remove (animated)" : "Insert (animated)") { [weak self] in guard let self else { return } @@ -151,7 +153,7 @@ extension Playground { self.logBoxState("after refresh(animated: true)") } - button(title: isShowing ? "Remove (instant)" : "Insert (instant)") { [weak self] in + Playground.button(title: isShowing ? "Remove (instant)" : "Insert (instant)") { [weak self] in guard let self else { return } @@ -163,7 +165,7 @@ extension Playground { } .frame(width: .flexible, height: 36) - button(title: "Transition: \(transitionKind.title)") { [weak self] in + Playground.button(title: "Transition: \(transitionKind.title)") { [weak self] in guard let self else { return } @@ -279,95 +281,18 @@ extension Playground { let model: String let presentation: String if transitionKind.animatesPosition { - model = "position = \(format(layer.position))" - presentation = "presentationPosition = \(layer.presentation().map { format($0.position) } ?? "nil")" + model = "position = \(Debug.format(layer.position))" + presentation = "presentationPosition = \(layer.presentation().map { Debug.format($0.position) } ?? "nil")" } else { - model = "opacity = \(format(layer.opacity))" - presentation = "presentationOpacity = \(layer.presentation().map { format($0.opacity) } ?? "nil")" + model = "opacity = \(Debug.format(layer.opacity))" + presentation = "presentationOpacity = \(layer.presentation().map { Debug.format($0.opacity) } ?? "nil")" } let inTree = layer.superlayer != nil ? "attached" : "DETACHED" - return "layer = \(pointer) (\(inTree)), \(model), \(presentation), animations = \(describeAnimations(of: layer))" - } - - private func describeAnimations(of layer: CALayer) -> String { - let keys = layer.animationKeys() ?? [] - guard !keys.isEmpty else { - return "[]" - } - let now = layer.convertTime(CACurrentMediaTime(), from: nil) - let descriptions = keys.map { key -> String in - guard let animation = layer.animation(forKey: key) as? CABasicAnimation else { - return "\(key): \(type(of: layer.animation(forKey: key) as Any))" - } - let from = describeValue(animation.fromValue) - let to = describeValue(animation.toValue) - let elapsed = now - animation.beginTime - return "\(key)(\(animation.keyPath ?? "?")): \(from) -> \(to)\(animation.isAdditive ? " additive" : ""), elapsed = \(format(elapsed))/\(format(animation.duration))s" - } - return "[\(descriptions.joined(separator: " | "))]" - } - - private func describeValue(_ value: Any?) -> String { - switch value { - case let number as NSNumber: - return format(number.doubleValue) - case let point as NSValue: - #if canImport(UIKit) - return format(point.cgPointValue) - #else - return format(point.pointValue) - #endif - case .none: - return "nil" - case .some(let other): - return String(describing: other) - } - } - - private func format(_ value: Double) -> String { - String(format: "%.3f", value) - } - - private func format(_ value: Float) -> String { - String(format: "%.3f", value) - } - - private func format(_ value: CGFloat) -> String { - String(format: "%.3f", value) - } - - private func format(_ point: CGPoint) -> String { - String(format: "(%.1f, %.1f)", point.x, point.y) + return "layer = \(pointer) (\(inTree)), \(model), \(presentation), animations = \(Debug.describeAnimations(of: layer))" } private func log(_ message: String) { print("[Revival] \(String(format: "%.3f", CACurrentMediaTime())) | \(message)") } - - private func button(title: String, onTap: @escaping () -> Void) -> ComposeNode { - ButtonNode( - content: { state in - let backgroundColor: Color - switch state { - case .normal, - .hovered: - backgroundColor = Colors.blueGray - case .pressed, - .selected: - backgroundColor = Colors.darkBlueGray - case .disabled: - backgroundColor = Colors.lightBlueGray - } - ColorNode(backgroundColor) - .cornerRadius(6) - .overlay { - Label(title) - .textColor(.white) - .selectable(false) - } - }, - onTap: onTap - ) - } } } diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift index 468ccef..3beea83 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift @@ -91,6 +91,14 @@ class ViewController: UIViewController { .padding(horizontal: Constants.padding) .frame(width: .flexible, height: 260) + ViewNode() + .underlay { + LayerNode() + .border(color: Color.gray, width: 1) + } + .padding(horizontal: Constants.padding) + .frame(width: .flexible, height: 300) + ViewNode() .underlay { LayerNode() diff --git a/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS.xcodeproj/project.pbxproj b/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS.xcodeproj/project.pbxproj index e49417e..de06949 100644 --- a/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS.xcodeproj/project.pbxproj +++ b/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ E5EA9B302D06CD2900570DF1 /* ComposeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E5EA9B2F2D06CD2900570DF1 /* ComposeUI */; }; E5ED82002D8785AD00B3FE3A /* Playground+TransitionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5ED81FF2D8785AD00B3FE3A /* Playground+TransitionView.swift */; }; D16BF3001FE4495099A8F9B3 /* Playground+TransitionRevivalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCF14AA829B44068B356550 /* Playground+TransitionRevivalView.swift */; }; + C9D63B70D57F6EAC7E24F833 /* Playground+AnimateLabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E74C6FE6807FBD6F35A944 /* Playground+AnimateLabView.swift */; }; + C9D63B70D57F6EAC7E24F844 /* Playground+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E74C6FE6807FBD6F35A955 /* Playground+Debug.swift */; }; E5ED82022D87B02D00B3FE3A /* Playground+AnimatingComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5ED82012D87B02D00B3FE3A /* Playground+AnimatingComposeView.swift */; }; E5F25D7D2D9A503000183721 /* Playground+ShadowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F25D7C2D9A503000183721 /* Playground+ShadowView.swift */; }; E5F901F82D8DF4CC00BFE2EE /* Playground+LabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F901F72D8DF4CC00BFE2EE /* Playground+LabelView.swift */; }; @@ -50,6 +52,8 @@ E5D636D92D7D016D0080A152 /* Playground+FrameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+FrameView.swift"; sourceTree = ""; }; E5ED81FF2D8785AD00B3FE3A /* Playground+TransitionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+TransitionView.swift"; sourceTree = ""; }; 9CCF14AA829B44068B356550 /* Playground+TransitionRevivalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+TransitionRevivalView.swift"; sourceTree = ""; }; + D5E74C6FE6807FBD6F35A944 /* Playground+AnimateLabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+AnimateLabView.swift"; sourceTree = ""; }; + D5E74C6FE6807FBD6F35A955 /* Playground+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+Debug.swift"; sourceTree = ""; }; E5ED82012D87B02D00B3FE3A /* Playground+AnimatingComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+AnimatingComposeView.swift"; sourceTree = ""; }; E5F25D7C2D9A503000183721 /* Playground+ShadowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+ShadowView.swift"; sourceTree = ""; }; E5F901F72D8DF4CC00BFE2EE /* Playground+LabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Playground+LabelView.swift"; sourceTree = ""; }; @@ -118,6 +122,8 @@ E58BA09B2DCC82FC00333061 /* Playground+Button.swift */, E5ED81FF2D8785AD00B3FE3A /* Playground+TransitionView.swift */, 9CCF14AA829B44068B356550 /* Playground+TransitionRevivalView.swift */, + D5E74C6FE6807FBD6F35A944 /* Playground+AnimateLabView.swift */, + D5E74C6FE6807FBD6F35A955 /* Playground+Debug.swift */, E504D9682D8B2BFF00FC20F8 /* Playground+SwiftUIView.swift */, E5F901F72D8DF4CC00BFE2EE /* Playground+LabelView.swift */, E5C5BB2F2D94706700F79668 /* Playground+TextView.swift */, @@ -212,6 +218,8 @@ E5F25D7D2D9A503000183721 /* Playground+ShadowView.swift in Sources */, E5ED82002D8785AD00B3FE3A /* Playground+TransitionView.swift in Sources */, D16BF3001FE4495099A8F9B3 /* Playground+TransitionRevivalView.swift in Sources */, + C9D63B70D57F6EAC7E24F833 /* Playground+AnimateLabView.swift in Sources */, + C9D63B70D57F6EAC7E24F844 /* Playground+Debug.swift in Sources */, E5D0347B2D7E2CFE004AB25D /* RotationView.swift in Sources */, 639549D6DDD6494D9517996F /* AdditiveOpacityDemoWindow.swift in Sources */, E59D80912CA8DFD3006A6467 /* ViewController.swift in Sources */, diff --git a/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift b/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift index 98e6407..62ba306 100644 --- a/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift +++ b/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift @@ -88,6 +88,16 @@ class ViewController: NSViewController { Spacer(height: 16) + ViewNode() + .underlay { + LayerNode() + .border(color: Color.gray, width: 1) + } + .padding(horizontal: 16) + .frame(width: .flexible, height: 300) + + Spacer(height: 16) + ViewNode() .underlay { LayerNode()