diff --git a/AGENTS.md b/AGENTS.md index 5785c66b..dd9034c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Before reporting a change complete, verify in order: 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`. +5. User-facing behavior changes since the last release have an entry under `Unreleased` in `CHANGELOG.md`. # Boundaries @@ -137,6 +137,9 @@ Hard-won rules from past corrections, grouped by theme. - Converting an existing closure to a closure type that involves a generic parameter, for example a block field of a type nested in `RenderItem`, allocates a reabstraction thunk, since generic closures take their arguments indirectly. In a hot path, write the closure literal where the generic-typed value is built. A closure that already exists, such as a caller's callback, allocates the same whether it is converted or wrapped in a literal, so store it in a field whose type doesn't involve the generic parameter. Count allocations (for example with `malloc_zone_statistics`) instead of inferring them from the code. - Swift lays out stored properties in declaration order, and malloc rounds each allocation up to a multiple of 16 bytes, so a byte of padding in a type stored in a heap node can cost 16 bytes per node. An enum of closures has no spare bits and adds a tag byte after its payload, so declare it before smaller fields, which then pack after the tag. Check the layout with `MemoryLayout`. - A generic function runs specialized only for callers in its own module. A caller in another module runs it unspecialized, with its arguments passed indirectly and its protocol requirements dispatched through witness tables, so a public generic API can be slower for apps than the non-generic API it replaces, even when the framework's own calls get faster. Measure a public generic API from a caller outside the module, and make it `@inlinable` or add concrete overloads when it's on a hot path. +- When the code being replaced shares a cost across calls, such as a cache that serves every later call in a pass, compare the replacement per pass, not per call. A per-call benchmark charges the shared cost to every call and overstates the saving. +- In a hot path, keep Objective-C values out of Swift bridging and `Any` casts. Bridging an `NSArray` of strings to `[String]`, as `CALayer.animationKeys()` does, or an `is`/`as?` cast of an `Any` holding an object, can cost more than the work around it, so read the `NSArray` as it is and tell objects apart by their Core Foundation type IDs. +- Check what an interop workaround does to object lifetimes, not only to speed. `NSObject.perform(_:)` autoreleases its receiver, so the object outlives its last owner until the enclosing autorelease pool drains, which a benchmark misses when the pool drains outside the measured code. Wrap such a call in `autoreleasepool` and cover the release with a test. ## CI @@ -147,7 +150,7 @@ Hard-won rules from past corrections, grouped by theme. ## Core Animation - Additive animations compose on screen only for properties the render server doesn't clamp between animations. It clamps opacities (`opacity`, `shadowOpacity`) to [0, 1] after applying each animation, so opposing additive animations of an opacity don't compose even though `presentation()` reports the unclamped sum: animate opacities non-additively or with a single replacing animation. It clamps `cornerRadius` at 0 the same way, which matters only when the running sum dips below 0. Other bounded properties compose as a sum where verified (`shadowRadius`, `borderWidth`, `CAShapeLayer`'s `strokeStart` and `strokeEnd`). Verify any other property with a `CARenderer` probe (render the layer tree into a Metal texture and read the pixels) before relying on either behavior, since only the compositor's output tells. -- Core Animation evaluates all presentation layers of a transaction at one time, the time of the transaction's first presentation read of any layer, until the outermost transaction commits in a run loop turn or a `CATransaction.flush()`. Model changes don't refresh it. So two presentation reads in one transaction always agree, but a presentation read and a clock read aren't for the same time, even next to each other. Verified on macOS and in the iOS simulator. +- Core Animation evaluates all presentation layers of a transaction at one time, the time of the transaction's first presentation read of a layer with animations, until the outermost transaction commits in a run loop turn or a `CATransaction.flush()`. A read of a layer without animations doesn't fix the time, even when its sublayers animate, and model changes don't refresh it. So two presentation reads in one transaction always agree, but a presentation read and a clock read aren't for the same time, even next to each other. Verified on macOS and in the iOS simulator. - Core Animation solves every timing function's curve numerically in single precision, to within 1e-5 of the change, even `CAMediaTimingFunction(name: .linear)`, while a nil timing function paces linearly and exactly, up to single-precision rounding. Compare a presentation value exactly with a computed one only for an animation without a timing function, and allow 1e-5 of the change otherwise. Verified on macOS and in the iOS simulator. ## Testing @@ -166,9 +169,12 @@ Hard-won rules from past corrections, grouped by theme. - Match content to the section's altitude: overview sections get a couple of high-level sentences, mechanics and specifics go in the section that owns them. - A CHANGELOG entry is for a change users would notice or need to act on. Leave out internal details, such as an edge-case fix that makes one more code path follow a rule the API already documents. +- Judge CHANGELOG entries against the last release, not the previous commit. Users only see released behavior, so a change to behavior that's still unreleased gets no entry of its own: update the entry that introduced the behavior when what it describes changes, and add nothing when the change only corrects it. Otherwise entries pile up for intermediate states no user saw. ## Design decisions - Question the premise before designing around it: when an existing behavior drives a decision, first check whether that behavior is intentional (documented, tested for its own sake, or explained in history) or incidental. Incidental behavior is a candidate to change, not a constraint to satisfy. - Surface rejected alternatives: when you consider an option and drop it for scope, cost, or risk, state it in one line with the reason. A silently dropped alternative takes the decision away from the reviewer. - Treat API surface as a variable, not a constraint: when a fix adds compensating logic (invalidation, special cases, extra cache keys) only so that a public type or option stays safe to misuse, first find who can reach that misuse. If only callers outside the module can, propose removing or narrowing the API next to the compensating fix, with the cost of each, before implementing either. Compatibility kept by default is the option that hides its cost. +- Enumerate a system's inputs before replacing its evaluation: when computed logic replaces what a system evaluates, list every kind of input the system honors, decide for each whether the logic models it or falls back, and test each decision. An input the logic silently misreads gives a wrong result where the replaced path gave a right one, and nothing flags it, such as a Core Animation group that a computed presentation value doesn't see. +- Check whether an input applies before requiring it to be supported: an input that has no effect, such as an animation that hasn't begun, shouldn't force a fallback or a failure only because the logic can't evaluate it. diff --git a/ComposeUI/Sources/ComposeUI/Animations/AdditiveValue.swift b/ComposeUI/Sources/ComposeUI/Animations/AdditiveValue.swift new file mode 100644 index 00000000..0478078f --- /dev/null +++ b/ComposeUI/Sources/ComposeUI/Animations/AdditiveValue.swift @@ -0,0 +1,117 @@ +// +// AdditiveValue.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 CoreGraphics +import Foundation + +/// A value of a kind that animates additively, a number, `CGSize` or `CGPoint`, as two components. +/// +/// A number uses the first component and leaves the second at zero, so the component-wise math is the same for every kind. +struct AdditiveValue { + + private enum Kind { + case number + case size + case point + } + + private let kind: Kind + + /// The components: the number and zero, width and height, or x and y. + private let components: SIMD2 + + /// Creates the value from a number, `CGSize` or `CGPoint`, as Core Animation boxes them. + /// + /// - Returns: `nil` for a value of another kind. + init?(_ value: Any) { + if let size = value as? CGSize { + kind = .size + components = SIMD2(size.width, size.height) + } else if let point = value as? CGPoint { + kind = .point + components = SIMD2(point.x, point.y) + } else if let number = value as? NSNumber { + kind = .number + components = SIMD2(number.doubleValue, 0) + } else { + return nil + } + } + + private init(kind: Kind, components: SIMD2) { + self.kind = kind + self.components = components + } + + /// The value as Core Animation boxes it. + var value: Any { + switch kind { + case .number: + return components.x + case .size: + return CGSize(width: components.x, height: components.y) + case .point: + return CGPoint(x: components.x, y: components.y) + } + } + + /// The zero of the value's kind. + var zero: AdditiveValue { + AdditiveValue(kind: kind, components: .zero) + } + + /// Whether every component is zero. + var isZero: Bool { + components == .zero + } + + func isSameKind(as other: AdditiveValue) -> Bool { + kind == other.kind + } + + /// The value with every component multiplied by `factor`. + func scaled(by factor: Double) -> AdditiveValue { + AdditiveValue(kind: kind, components: components * factor) + } + + /// The component-wise sum of two values of the same kind. + static func + (lhs: AdditiveValue, rhs: AdditiveValue) -> AdditiveValue { + AdditiveValue(kind: lhs.kind, components: lhs.components + rhs.components) + } + + static func += (lhs: inout AdditiveValue, rhs: AdditiveValue) { + lhs = lhs + rhs + } + + /// The component-wise difference of two values of the same kind. + static func - (lhs: AdditiveValue, rhs: AdditiveValue) -> AdditiveValue { + AdditiveValue(kind: lhs.kind, components: lhs.components - rhs.components) + } +} diff --git a/ComposeUI/Sources/ComposeUI/Animations/AnimationInterpolation.swift b/ComposeUI/Sources/ComposeUI/Animations/AnimationInterpolation.swift new file mode 100644 index 00000000..5eb31932 --- /dev/null +++ b/ComposeUI/Sources/ComposeUI/Animations/AnimationInterpolation.swift @@ -0,0 +1,103 @@ +// +// AnimationInterpolation.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 + +/// Interpolates animation values the way Core Animation does: numbers, sizes and points component by component, +/// colors component by component in extended sRGB with straight alpha, see `ExtendedSRGB`, and paths with the same +/// segments point by point, see `PathPoints`. +/// +/// It gives the value for a progress, which `AnimationCurve` gives for a time. +enum AnimationInterpolation { + + /// The value a progress of the way from one animation value to another. + /// + /// - Parameters: + /// - from: The from value, as Core Animation keeps it: an `NSNumber`, an `NSValue` of a size or a point, a color or + /// a path. + /// - to: The to value. + /// - progress: The progress: 0 at the from value, 1 at the to value, and past them for a spring's overshoot. + /// - Returns: The value, the from or to value itself at a progress of 0 or 1. `nil` for values that aren't numbers, + /// sizes, points, colors or paths of the same kind, paths with other segments, or colors that don't convert to + /// extended sRGB. + static func value(from: AnyObject, to: AnyObject, progress: Double) -> Any? { + switch progress { + case 0: + return from + case 1: + return to + default: + break + } + + switch (CFGetTypeID(from), CFGetTypeID(to)) { + case (CGColor.typeID, CGColor.typeID): + return color(from: unsafeDowncast(from, to: CGColor.self), to: unsafeDowncast(to, to: CGColor.self), progress: progress) + case (CGPath.typeID, CGPath.typeID): + return path(from: unsafeDowncast(from, to: CGPath.self), to: unsafeDowncast(to, to: CGPath.self), progress: progress) + default: + guard let from = AdditiveValue(from), let to = AdditiveValue(to), from.isSameKind(as: to) else { + return nil + } + return (from + (to - from).scaled(by: progress)).value + } + } + + /// The color a progress of the way from one color to another, interpolated component by component in extended sRGB, + /// with straight alpha, as Core Animation interpolates colors of any color space. + /// + /// - Parameters: + /// - from: The from color. + /// - to: The to color. + /// - progress: The progress. + /// - Returns: The color, in extended sRGB, or `nil` when a color doesn't convert to extended sRGB, such as a pattern. + static func color(from: CGColor, to: CGColor, progress: Double) -> CGColor? { + guard let from = ExtendedSRGB.components(of: from), let to = ExtendedSRGB.components(of: to) else { + return nil + } + return ExtendedSRGB.color(components: from + (to - from) * progress) + } + + /// The path a progress of the way from one path to another, interpolated point by point. + /// + /// - Parameters: + /// - from: The from path. + /// - to: The to path. + /// - progress: The progress. + /// - Returns: The path, or `nil` when the paths have other segments or points that aren't finite. + static func path(from: CGPath, to: CGPath, progress: Double) -> CGPath? { + let from = PathPoints(from) + let to = PathPoints(to) + guard from.hasSameSegments(as: to), from.isFinite, to.isFinite else { + return nil + } + return from.adding(to.subtracting(from), multipliedBy: CGFloat(progress)).path + } +} diff --git a/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+AnimationTiming.swift b/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+AnimationTiming.swift index 839e56c4..04ad7827 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+AnimationTiming.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+AnimationTiming.swift @@ -41,6 +41,8 @@ public extension CABasicAnimation { /// A timing with a zero duration makes an animation shorter than a frame at the normal speed, which lands as an /// instant change after the delay, as a zero duration has no timeline for the timing's speed to scale. /// + /// The animation's fill mode is `.both`, so an animation scheduled to begin later holds its from value until it begins. + /// /// - Parameters: /// - timing: The timing of the animation. /// - Returns: The animation. diff --git a/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+Evaluate.swift b/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+Evaluate.swift index 714861e6..653748e2 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+Evaluate.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CABasicAnimation+Evaluate.swift @@ -58,35 +58,44 @@ extension CABasicAnimation { return from + (to - from) * progress(forElapsedTime: elapsed) } - /// The time the animation has run at the given time, in its own timeline: the time since `beginTime`, scaled by - /// `speed`. + /// The animation's progress from `fromValue` (0) to `toValue` (1) after the given elapsed time. /// - /// An animation with an unset (zero) `beginTime` hasn't been scheduled by Core Animation yet (it is resolved when the - /// transaction commits), so it has run for zero seconds. An animation scheduled in the future has run for a negative - /// time, even without a backwards fill, when it doesn't show its from value until it begins. + /// - Parameter elapsed: The elapsed time in the animation's timeline, in seconds, see `elapsedTime(at:)`. + /// - Returns: The progress, 1 for a zero-duration animation. + func progress(forElapsedTime elapsed: TimeInterval) -> Double { + AnimationCurve(self).progress(forElapsedTime: elapsed) + } +} + +extension CAAnimation { + + /// The time the animation has run at the given time, in its own timeline, see `timeSinceBegin(at:)`. /// /// - Parameter time: The time in the layer's time space, compared against `beginTime`. - /// - Returns: The elapsed time, or `nil` for an animation with a `timeOffset`, a `repeatCount`, a `repeatDuration`, - /// or `autoreverses`, which `progress(forElapsedTime:)` doesn't evaluate. + /// - Returns: The elapsed time, or `nil` for an animation whose timing `CABasicAnimation.progress(forElapsedTime:)` + /// doesn't evaluate, see `hasEvaluableTiming`. func elapsedTime(at time: TimeInterval) -> TimeInterval? { - guard timeOffset == 0, - repeatCount == 0, - repeatDuration == 0, - !autoreverses - else { - return nil - } - // the fill mode isn't checked, so a rate of change sampled across the begin time still evaluates the animation on - // both sides of it - return beginTime == 0 ? 0 : (time - beginTime) * TimeInterval(speed) + hasEvaluableTiming ? timeSinceBegin(at: time) : nil } - /// The animation's progress from `fromValue` (0) to `toValue` (1) after the given elapsed time. + /// Whether `CABasicAnimation.progress(forElapsedTime:)` evaluates the animation's timing: it has no `timeOffset`, + /// `repeatCount`, `repeatDuration` or `autoreverses`. + var hasEvaluableTiming: Bool { + timeOffset == 0 && repeatCount == 0 && repeatDuration == 0 && !autoreverses + } + + /// The time since the animation's `beginTime` at the given time, scaled by its `speed`, whatever its timing. /// - /// - Parameter elapsed: The elapsed time in the animation's timeline, in seconds, see `elapsedTime(at:)`. - /// - Returns: The progress, 1 for a zero-duration animation. - func progress(forElapsedTime elapsed: TimeInterval) -> Double { - AnimationCurve(self).progress(forElapsedTime: elapsed) + /// An animation with an unset (zero) `beginTime` hasn't been scheduled by Core Animation yet (it is resolved when the + /// transaction commits), so it has run for zero seconds. An animation scheduled in the future at a positive speed has + /// run for a negative time, even without a backwards fill, when it doesn't show its from value until it begins. + /// + /// - Parameter time: The time in the layer's time space, compared against `beginTime`. + /// - Returns: The time since the animation began, negative before it begins. + func timeSinceBegin(at time: TimeInterval) -> TimeInterval { + // the fill mode isn't checked, so a rate of change sampled across the begin time still evaluates the animation on + // both sides of it + beginTime == 0 ? 0 : (time - beginTime) * TimeInterval(speed) } } diff --git a/ComposeUI/Sources/ComposeUI/Animations/CALayer+Animations.swift b/ComposeUI/Sources/ComposeUI/Animations/CALayer+Animations.swift index 148f620e..fb6eb3a1 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CALayer+Animations.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CALayer+Animations.swift @@ -181,10 +181,10 @@ public extension CALayer { /// /// The animation is added and the model value is set synchronously. The animation begins at the current time of the /// animation clock plus the timing's delay, in the layer's time space, see `AnimationClock` and - /// `CAAnimation.beginTime(at:delay:)`. 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. + /// `CAAnimation.beginTime(at:delay:)`. The animation's backwards fill, see `CABasicAnimation.makeAnimation(_:)`, 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. /// /// The 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. The begin time is in the layer's time space when the @@ -198,8 +198,9 @@ public extension CALayer { /// - keyPath: The key path to animate. /// - timing: The animation timing. /// - 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. + /// non-additive animation is resolved at dispatch, from the value the layer shows, see `shownValue(forKeyPath:)`, + /// falling back to the model value, because the backwards fill 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. @@ -234,7 +235,7 @@ public extension CALayer { 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) + animation.fromValue = shownValue(forKeyPath: keyPath) ?? value(forKeyPath: keyPath) } let rawKey = key ?? keyPath @@ -244,85 +245,6 @@ public extension CALayer { setKeyPathValue(keyPath, model?(layer) ?? toValue) } - // a read through KVC boxes the value in an `NSValue` or `NSNumber` and casts it back, which costs a sizable share of - // setting up an animation, so the typed `animate(keyPath:to:timing:updateAnimation:)` overloads read the current value - // with the functions below, which read the properties the framework animates directly - - /// Get the layer's model value at a key path whose value is a `CGPoint`. - /// - /// `position` is read directly, other key paths through KVC. - /// - /// - Important: The key path's value must be a `CGPoint`. Otherwise, a crash will occur. - /// - /// - Parameter keyPath: The key path to read. - /// - Returns: The value at the key path. - internal func pointValue(forKeyPath keyPath: String) -> CGPoint { - switch keyPath { - case "position": - return position - default: - return value(forKeyPath: keyPath) as! CGPoint // swiftlint:disable:this force_cast - } - } - - /// Get the layer's model value at a key path whose value is a `CGSize`. - /// - /// `bounds.size` and `shadowOffset` are read directly, other key paths through KVC. - /// - /// - Important: The key path's value must be a `CGSize`. Otherwise, a crash will occur. - /// - /// - Parameter keyPath: The key path to read. - /// - Returns: The value at the key path. - internal func sizeValue(forKeyPath keyPath: String) -> CGSize { - switch keyPath { - case "bounds.size": - return bounds.size - case "shadowOffset": - return shadowOffset - default: - return value(forKeyPath: keyPath) as! CGSize // swiftlint:disable:this force_cast - } - } - - /// Get the layer's model value at a key path whose value is a floating-point number. - /// - /// `opacity`, `shadowOpacity`, `borderWidth`, `cornerRadius`, and `shadowRadius` are read directly when `T` is the - /// property's type. Other key paths and types are read through KVC. - /// - /// - Important: The key path's value must be a number that casts to `T`. Otherwise, a crash will occur. - /// - /// - Parameter keyPath: The key path to read. - /// - Returns: The value at the key path. - internal func floatingPointValue(forKeyPath keyPath: String) -> T { - // a direct read is only taken for the property's own type, as other types rely on the conversion of KVC's boxed - // number, for example reading the `Float` opacity as a `CGFloat` - switch keyPath { - case "opacity": - if let value = opacity as? T { - return value - } - case "shadowOpacity": - if let value = shadowOpacity as? T { - return value - } - case "borderWidth": - if let value = borderWidth as? T { - return value - } - case "cornerRadius": - if let value = cornerRadius as? T { - return value - } - case "shadowRadius": - if let value = shadowRadius as? T { - return value - } - default: - break - } - return value(forKeyPath: keyPath) as! T // swiftlint:disable:this force_cast - } - /// Get a unique animation key. /// /// This is useful when you want to add multiple animations, such as additive animations, with the same name to a layer. @@ -357,12 +279,10 @@ public extension CALayer { /// - Parameter keyPath: The animated key path. /// - Returns: The basic animations animating `keyPath`, in the layer's animation key order. internal func basicAnimations(forKeyPath keyPath: String) -> [CABasicAnimation] { - (animationKeys() ?? []).compactMap { key in - guard let animation = animation(forKey: key) as? CABasicAnimation, animation.keyPath == keyPath else { - return nil - } - return animation + guard let animations = animationSequence(forKeyPath: keyPath) else { + return [] } + return animations.compactMap { $0.animation.directAnimation as? CABasicAnimation } } /// The layer's property animations animating the given key path, such as basic and keyframe animations. @@ -370,20 +290,162 @@ public extension CALayer { /// - Parameter keyPath: The animated key path. /// - Returns: The property animations animating `keyPath`, in the layer's animation key order. internal func propertyAnimations(forKeyPath keyPath: String) -> [CAPropertyAnimation] { - (animationKeys() ?? []).compactMap { key in - guard let animation = animation(forKey: key) as? CAPropertyAnimation, animation.keyPath == keyPath else { - return nil - } - return animation + guard let animations = animationSequence(forKeyPath: keyPath) else { + return [] } + return animations.compactMap(\.animation.directAnimation) } - /// Removes the layer's animations animating the given key path, leaving other animations alone. + /// Removes the layer's property animations animating the given key path, leaving other animations alone. + /// + /// An animation that changes the key path through another key path is left alone too, see `KeyPathAnimation.indirect`, + /// since it's there for the other key path. /// /// - Parameter keyPath: The animated key path. internal func removeAnimations(forKeyPath keyPath: String) { - for key in animationKeys() ?? [] where (animation(forKey: key) as? CAPropertyAnimation)?.keyPath == keyPath { + guard let animations = animationSequence(forKeyPath: keyPath) else { + return + } + // the sequence walks a copy of the keys, so removing an animation doesn't skip the next one + for (key, animation) in animations where animation.directAnimation != nil { removeAnimation(forKey: key) } } + + /// The layer's animations that change what the given key path shows, with their keys, looked up as they're iterated: + /// its property animations of the key path, and the ones that change it through another key path, see + /// `KeyPathAnimation`. + /// + /// The first animation is found when the sequence is created, so a caller can skip the work the animations need, such + /// as reading the layer's current time, when no animation changes the key path. + /// + /// An animation added without a key isn't included, since `animationKeys()` doesn't list it, and without a key it + /// can't be looked up. + /// + /// - Parameter keyPath: The animated key path. + /// - Returns: The animations in the layer's animation key order, or `nil` when no animation changes the key path. + internal func animationSequence(forKeyPath keyPath: String) -> KeyPathAnimationSequence? { + // `animationKeys()` bridges Core Animation's array of keys to a Swift array of strings, which costs more than + // looking up the animations, so the array is read as it is. `perform(_:)` autoreleases the layer, so a local pool + // releases it right away instead of keeping a layer its owners let go alive until the run loop's pool drains. + let keys = autoreleasepool { + perform(#selector(CALayer.animationKeys))?.takeUnretainedValue() as? NSArray + } + guard let keys else { + return nil + } + return KeyPathAnimationSequence(layer: self, keyPath: keyPath, keys: keys) + } +} + +/// A layer's animation that changes what a key path shows, see `CALayer.animationSequence(forKeyPath:)`. +enum KeyPathAnimation { + + /// A property animation of the key path. + case direct(CAPropertyAnimation) + + /// An animation that changes the key path through another key path: a property animation of a component of the key + /// path, such as `position.x` of `position`, or of a key path the key path is a component of, such as `bounds` of + /// `bounds.size`, or a group with an animation of the key path or of such a key path, at any depth. + case indirect(CAAnimation) + + /// The animation. + var animation: CAAnimation { + switch self { + case .direct(let animation): + return animation + case .indirect(let animation): + return animation + } + } + + /// The property animation of the key path, or `nil` for an animation that changes it through another key path. + var directAnimation: CAPropertyAnimation? { + switch self { + case .direct(let animation): + return animation + case .indirect: + return nil + } + } +} + +/// A layer's animations that change what a key path shows, with their keys, looked up as they're iterated, see +/// `CALayer.animationSequence(forKeyPath:)`. +struct KeyPathAnimationSequence: Sequence, IteratorProtocol { + + private let layer: CALayer + private let keyPath: String + private let keys: NSArray + private let keyCount: Int + private var index = 0 + + /// The first animation, found when the sequence is created, which `next()` returns first. + private var first: (key: String, animation: KeyPathAnimation)? + + /// Creates the sequence, or returns `nil` when no animation changes the key path. + fileprivate init?(layer: CALayer, keyPath: String, keys: NSArray) { + self.layer = layer + self.keyPath = keyPath + self.keys = keys + keyCount = keys.count + guard let first = lookUpNext() else { + return nil + } + self.first = first + } + + mutating func next() -> (key: String, animation: KeyPathAnimation)? { + if let first { + self.first = nil + return first + } + return lookUpNext() + } + + /// Looks up the next animation that changes the key path, after the keys looked up so far. + private mutating func lookUpNext() -> (key: String, animation: KeyPathAnimation)? { + while index < keyCount { + // Core Animation's keys are strings + let key = unsafeDowncast(keys.object(at: index) as AnyObject, to: NSString.self) as String + index += 1 + let animation = layer.animation(forKey: key) + if let propertyAnimation = animation as? CAPropertyAnimation, let animatedKeyPath = propertyAnimation.keyPath { + if animatedKeyPath == keyPath { + return (key, .direct(propertyAnimation)) + } + if Self.isKeyPath(animatedKeyPath, aComponentOf: keyPath) || Self.isKeyPath(keyPath, aComponentOf: animatedKeyPath) { + return (key, .indirect(propertyAnimation)) + } + } else if let group = animation as? CAAnimationGroup, Self.group(group, changes: keyPath) { + return (key, .indirect(group)) + } + } + return nil + } + + /// Whether a group changes what a key path shows, with an animation of the key path, of one of its components, or of + /// a key path it's a component of, directly or in a group of its own. + private static func group(_ group: CAAnimationGroup, changes keyPath: String) -> Bool { + group.animations?.contains { animation in + if let propertyAnimation = animation as? CAPropertyAnimation { + guard let animatedKeyPath = propertyAnimation.keyPath else { + return false + } + return animatedKeyPath == keyPath || isKeyPath(animatedKeyPath, aComponentOf: keyPath) || isKeyPath(keyPath, aComponentOf: animatedKeyPath) + } + return (animation as? CAAnimationGroup).map { Self.group($0, changes: keyPath) } ?? false + } ?? false + } + + /// Whether a key path is a component of another, such as `position.x` of `position`: it starts with the other key + /// path and a dot. + /// + /// The bytes are compared instead of splitting the key path into components by character, since splitting by + /// character segments grapheme clusters, which costs more than the lookup the check is for. + private static func isKeyPath(_ keyPath: String, aComponentOf otherKeyPath: String) -> Bool { + let bytes = keyPath.utf8 + let otherBytes = otherKeyPath.utf8 + return bytes.count > otherBytes.count && bytes.starts(with: otherBytes) && bytes.dropFirst(otherBytes.count).first == UInt8(ascii: ".") + } } diff --git a/ComposeUI/Sources/ComposeUI/Animations/CALayer+KeyPathValue.swift b/ComposeUI/Sources/ComposeUI/Animations/CALayer+KeyPathValue.swift index 5b74d223..8bd49fd4 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CALayer+KeyPathValue.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CALayer+KeyPathValue.swift @@ -119,7 +119,8 @@ extension CALayer { } // A write through KVC resolves the key path and boxes a number or a structure in an `NSNumber` or `NSValue`, which - // costs a sizable share of setting up an animation, so the properties the framework animates are set directly. + // costs a sizable share of setting up an animation, so the properties the framework animates are set directly. The + // switches below list these properties in the order `CALayer` declares them, followed by a shape layer's path. /// Set the layer's model value at a key path. /// @@ -130,61 +131,61 @@ extension CALayer { // a number or a structure is only set directly when it has the property's own type, as other types rely on KVC's // conversion of the boxed value, for example setting a `CGFloat` as the `Float` opacity switch keyPath { - case "position": - if let value = value as? CGPoint { - position = value - return - } case "bounds.size": if let value = value as? CGSize { bounds.size = value return } - case "shadowOffset": - if let value = value as? CGSize { - shadowOffset = value - return - } - case "opacity": - if let value = value as? Float { - opacity = value - return - } - case "shadowOpacity": - if let value = value as? Float { - shadowOpacity = value + case "position": + if let value = value as? CGPoint { + position = value return } - case "borderWidth": - if let value = value as? CGFloat { - borderWidth = value - return + case "backgroundColor": + if let color = Self.color(from: value, forKeyPath: keyPath) { + backgroundColor = color } + return case "cornerRadius": if let value = value as? CGFloat { cornerRadius = value return } - case "shadowRadius": + case "borderWidth": if let value = value as? CGFloat { - shadowRadius = value + borderWidth = value return } - case "backgroundColor": - if let color = Self.color(from: value, forKeyPath: keyPath) { - backgroundColor = color - } - return case "borderColor": if let color = Self.color(from: value, forKeyPath: keyPath) { borderColor = color } return + case "opacity": + if let value = value as? Float { + opacity = value + return + } case "shadowColor": if let color = Self.color(from: value, forKeyPath: keyPath) { shadowColor = color } return + case "shadowOpacity": + if let value = value as? Float { + shadowOpacity = value + return + } + case "shadowOffset": + if let value = value as? CGSize { + shadowOffset = value + return + } + case "shadowRadius": + if let value = value as? CGFloat { + shadowRadius = value + return + } case "shadowPath": if let path = Self.path(from: value, forKeyPath: keyPath) { shadowPath = path @@ -216,44 +217,44 @@ extension CALayer { /// - Returns: Whether the model value is the value. func hasModelValue(_ value: Any, forKeyPath keyPath: String) -> Bool { switch keyPath { + case "bounds.size": + if let value = value as? CGSize { + return bounds.size == value + } case "position": if let value = value as? CGPoint { return position == value } - case "bounds.size": - if let value = value as? CGSize { - return bounds.size == value + case "backgroundColor": + return Self.isObject(backgroundColor, equalTo: value) + case "cornerRadius": + if let value = value as? CGFloat { + return cornerRadius == value } - case "shadowOffset": - if let value = value as? CGSize { - return shadowOffset == value + case "borderWidth": + if let value = value as? CGFloat { + return borderWidth == value } + case "borderColor": + return Self.isObject(borderColor, equalTo: value) case "opacity": if let value = value as? Float { return opacity == value } + case "shadowColor": + return Self.isObject(shadowColor, equalTo: value) case "shadowOpacity": if let value = value as? Float { return shadowOpacity == value } - case "borderWidth": - if let value = value as? CGFloat { - return borderWidth == value - } - case "cornerRadius": - if let value = value as? CGFloat { - return cornerRadius == value + case "shadowOffset": + if let value = value as? CGSize { + return shadowOffset == value } case "shadowRadius": if let value = value as? CGFloat { return shadowRadius == value } - case "backgroundColor": - return Self.isObject(backgroundColor, equalTo: value) - case "borderColor": - return Self.isObject(borderColor, equalTo: value) - case "shadowColor": - return Self.isObject(shadowColor, equalTo: value) case "shadowPath": return Self.isObject(shadowPath, equalTo: value) case "path": @@ -266,6 +267,128 @@ extension CALayer { return Self.isObject(self.value(forKeyPath: keyPath) as AnyObject?, equalTo: value) } + /// The layer's model value at a key path. + /// + /// The properties the framework animates are read directly, in their own types, like `setModelValue(_:forKeyPath:)` + /// sets them. Other key paths are read through KVC. + /// + /// - Parameter keyPath: The key path. + /// - Returns: The model value, `nil` for a key path without a value, such as a layer without a background color. + func modelValue(forKeyPath keyPath: String) -> Any? { + switch keyPath { + case "bounds.size": + return bounds.size + case "position": + return position + case "backgroundColor": + return backgroundColor + case "cornerRadius": + return cornerRadius + case "borderWidth": + return borderWidth + case "borderColor": + return borderColor + case "opacity": + return opacity + case "shadowColor": + return shadowColor + case "shadowOpacity": + return shadowOpacity + case "shadowOffset": + return shadowOffset + case "shadowRadius": + return shadowRadius + case "shadowPath": + return shadowPath + case "path": + if let shapeLayer = self as? CAShapeLayer { + return shapeLayer.path + } + default: + break + } + return value(forKeyPath: keyPath) + } + + // A read through KVC boxes the value in an `NSValue` or `NSNumber` and casts it back, which costs a sizable share of + // setting up an animation, so the typed `animate(keyPath:to:timing:updateAnimation:)` overloads read the current value + // with the functions below, which read the properties the framework animates directly. + + /// Get the layer's model value at a key path whose value is a `CGPoint`. + /// + /// `position` is read directly, other key paths through KVC. + /// + /// - Important: The key path's value must be a `CGPoint`. Otherwise, a crash will occur. + /// + /// - Parameter keyPath: The key path to read. + /// - Returns: The value at the key path. + func pointValue(forKeyPath keyPath: String) -> CGPoint { + switch keyPath { + case "position": + return position + default: + return value(forKeyPath: keyPath) as! CGPoint // swiftlint:disable:this force_cast + } + } + + /// Get the layer's model value at a key path whose value is a `CGSize`. + /// + /// `bounds.size` and `shadowOffset` are read directly, other key paths through KVC. + /// + /// - Important: The key path's value must be a `CGSize`. Otherwise, a crash will occur. + /// + /// - Parameter keyPath: The key path to read. + /// - Returns: The value at the key path. + func sizeValue(forKeyPath keyPath: String) -> CGSize { + switch keyPath { + case "bounds.size": + return bounds.size + case "shadowOffset": + return shadowOffset + default: + return value(forKeyPath: keyPath) as! CGSize // swiftlint:disable:this force_cast + } + } + + /// Get the layer's model value at a key path whose value is a floating-point number. + /// + /// `cornerRadius`, `borderWidth`, `opacity`, `shadowOpacity`, and `shadowRadius` are read directly when `T` is the + /// property's type. Other key paths and types are read through KVC. + /// + /// - Important: The key path's value must be a number that casts to `T`. Otherwise, a crash will occur. + /// + /// - Parameter keyPath: The key path to read. + /// - Returns: The value at the key path. + func floatingPointValue(forKeyPath keyPath: String) -> T { + // a direct read is only taken for the property's own type, as other types rely on the conversion of KVC's boxed + // number, for example reading the `Float` opacity as a `CGFloat` + switch keyPath { + case "cornerRadius": + if let value = cornerRadius as? T { + return value + } + case "borderWidth": + if let value = borderWidth as? T { + return value + } + case "opacity": + if let value = opacity as? T { + return value + } + case "shadowOpacity": + if let value = shadowOpacity as? T { + return value + } + case "shadowRadius": + if let value = shadowRadius as? T { + return value + } + default: + break + } + return value(forKeyPath: keyPath) as! T // swiftlint:disable:this force_cast + } + /// Whether a model value, as an object, is the given value, compared like KVC compares its boxed values. /// /// A value of another type, such as a platform color at a `CGColor` key path, isn't equal instead of asserting, since diff --git a/ComposeUI/Sources/ComposeUI/Animations/CALayer+Retarget.swift b/ComposeUI/Sources/ComposeUI/Animations/CALayer+Retarget.swift index 5cf97acb..77a46467 100644 --- a/ComposeUI/Sources/ComposeUI/Animations/CALayer+Retarget.swift +++ b/ComposeUI/Sources/ComposeUI/Animations/CALayer+Retarget.swift @@ -46,10 +46,14 @@ public extension CALayer { /// `opacity` and `shadowOpacity` are replaced too, since the render server clamps them after each animation and /// stacked animations wouldn't compose on screen. /// - /// A folded or replaced animation is removed, so its delegate is told it stopped before finishing. + /// A folded or replaced animation is removed, so its delegate is told it stopped before finishing. An animation that + /// changes the key path through another key path, such as a group or an animation of `position.x` for `position`, is + /// neither folded nor replaced, since it's there for the other key path, and keeps running. /// /// - Important: The value's type must match the key path's, or Core Animation crashes. /// - Important: Animations kept with `isRemovedOnCompletion` off aren't supported. + /// - Important: Animations added without a key aren't retargeted or accounted for, since Core Animation doesn't list + /// them. /// /// - Parameters: /// - keyPath: The key path to set. @@ -65,7 +69,7 @@ public extension CALayer { setKeyPathValue(keyPath, value) return } - let (inFlightAnimations, endedKeptKeys, now) = animations + let (inFlightAnimations, indirectAnimations, endedKeptKeys, now) = animations // a kept animation that has ended would cover the new value for key in endedKeptKeys { @@ -107,51 +111,59 @@ public extension CALayer { updateAnimation: { $0.isAdditive = true } ) } else { - // remove the in-flight animations and replace them with a new one from the current value to the new value + // replace the in-flight animations with a new one from the value they show to the new value. + // an animation that changes the key path through another key path keeps running and is part of the start value, + // which only then needs the animations in one array + let startValue = indirectAnimations.isEmpty + ? shownValue(forKeyPath: keyPath, animations: inFlightAnimations.lazy.map { KeyPathAnimation.direct($0.animation) }, at: now) + : shownValue(forKeyPath: keyPath, animations: indirectAnimations + inFlightAnimations.map { KeyPathAnimation.direct($0.animation) }, at: now) for inFlightAnimation in inFlightAnimations { removeAnimation(forKey: inFlightAnimation.key) } animate( keyPath: keyPath, timing: timing, - from: { $0.presentation()?.value(forKeyPath: keyPath) }, + from: { _ in startValue }, to: { _ -> Any? in value } ) } } - /// The layer's property animations of the given key path that haven't ended at `now`, the keys of the kept ones that - /// have ended, and `now`, the layer's current time. + /// The layer's property animations of the given key path that haven't ended at `now`, its animations that change the + /// key path through another key path, the keys of the kept property animations that have ended, and `now`, the + /// layer's current time. /// - /// Returns `nil` when no animation animates the key path, without reading the current time, since reading it converts + /// An animation that changes the key path through another key path, see `KeyPathAnimation.indirect`, isn't one of the + /// in-flight animations, since it's there for the other key path, so the retarget neither folds nor removes it. + /// + /// Returns `nil` when no animation changes the key path, without reading the current time, since reading it converts /// the time through the layer tree, and a layer usually has nothing animating. - private func inFlightAnimations(forKeyPath keyPath: String) -> (animations: [InFlightAnimation], endedKeptKeys: [String], now: TimeInterval)? { - var now: TimeInterval? + private func inFlightAnimations(forKeyPath keyPath: String) -> (animations: [InFlightAnimation], indirectAnimations: [KeyPathAnimation], endedKeptKeys: [String], now: TimeInterval)? { + guard let animations = animationSequence(forKeyPath: keyPath) else { + return nil + } + + let now = currentTime var inFlightAnimations: [InFlightAnimation] = [] + var indirectAnimations: [KeyPathAnimation] = [] var endedKeptKeys: [String] = [] - for key in animationKeys() ?? [] { - guard let animation = animation(forKey: key) as? CAPropertyAnimation, animation.keyPath == keyPath else { + for (key, keyPathAnimation) in animations { + guard let animation = keyPathAnimation.directAnimation else { + indirectAnimations.append(keyPathAnimation) continue } - ComposeUI.assert( animation.isRemovedOnCompletion, "animation \"\(key)\" of \"\(keyPath)\" is kept with isRemovedOnCompletion off, which isn't supported" ) - let time = now ?? currentTime - now = time - if let remainingTime = animation.remainingTime(at: time) { + if let remainingTime = animation.remainingTime(at: now) { inFlightAnimations.append(InFlightAnimation(key: key, animation: animation, remainingTime: remainingTime)) } else if !animation.isRemovedOnCompletion { endedKeptKeys.append(key) } } - - guard let now else { - return nil - } - return (inFlightAnimations, endedKeptKeys, now) + return (inFlightAnimations, indirectAnimations, endedKeptKeys, now) } /// The in-flight additive animations to fold into one glide to the new value, and the offset of the shown value from @@ -244,89 +256,3 @@ private extension CABasicAnimation { return (from, to) } } - -/// A value of a kind that animates additively, a number, `CGSize` or `CGPoint`, as two components. -/// -/// A number uses the first component and leaves the second at zero, so the component-wise math is the same for every -/// kind. -private struct AdditiveValue { - - private enum Kind { - case number - case size - case point - } - - private let kind: Kind - - /// The components: the number and zero, width and height, or x and y. - private let components: SIMD2 - - /// Creates the value from a number, `CGSize` or `CGPoint`, as Core Animation boxes them. - /// - /// - Returns: `nil` for a value of another kind. - init?(_ value: Any) { - if let size = value as? CGSize { - kind = .size - components = SIMD2(size.width, size.height) - } else if let point = value as? CGPoint { - kind = .point - components = SIMD2(point.x, point.y) - } else if let number = value as? NSNumber { - kind = .number - components = SIMD2(number.doubleValue, 0) - } else { - return nil - } - } - - private init(kind: Kind, components: SIMD2) { - self.kind = kind - self.components = components - } - - /// The value as Core Animation boxes it. - var value: Any { - switch kind { - case .number: - return components.x - case .size: - return CGSize(width: components.x, height: components.y) - case .point: - return CGPoint(x: components.x, y: components.y) - } - } - - /// The zero of the value's kind. - var zero: AdditiveValue { - AdditiveValue(kind: kind, components: .zero) - } - - /// Whether every component is zero. - var isZero: Bool { - components == .zero - } - - func isSameKind(as other: AdditiveValue) -> Bool { - kind == other.kind - } - - /// The value with every component multiplied by `factor`. - func scaled(by factor: Double) -> AdditiveValue { - AdditiveValue(kind: kind, components: components * factor) - } - - /// The component-wise sum of two values of the same kind. - static func + (lhs: AdditiveValue, rhs: AdditiveValue) -> AdditiveValue { - AdditiveValue(kind: lhs.kind, components: lhs.components + rhs.components) - } - - static func += (lhs: inout AdditiveValue, rhs: AdditiveValue) { - lhs = lhs + rhs - } - - /// The component-wise difference of two values of the same kind. - static func - (lhs: AdditiveValue, rhs: AdditiveValue) -> AdditiveValue { - AdditiveValue(kind: lhs.kind, components: lhs.components - rhs.components) - } -} diff --git a/ComposeUI/Sources/ComposeUI/Animations/CALayer+ShownValue.swift b/ComposeUI/Sources/ComposeUI/Animations/CALayer+ShownValue.swift new file mode 100644 index 00000000..aa2291fe --- /dev/null +++ b/ComposeUI/Sources/ComposeUI/Animations/CALayer+ShownValue.swift @@ -0,0 +1,272 @@ +// +// CALayer+ShownValue.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 + +extension CALayer { + + /// The value a key path shows at the animation clock's current time, which an animation that begins now starts from. + /// + /// The value is computed from the model value and the key path's animations, see + /// `shownValue(forKeyPath:animations:at:)`, instead of read from `presentation()`: Core Animation evaluates the + /// presentation layers of a transaction at the time of its first presentation read of a layer with animations, which + /// can be up to a run loop turn away from the clock's time in either direction, and a layer has no presentation layer + /// before it is committed. + /// + /// - Important: An animation added without a key isn't included, since Core Animation doesn't list it, see + /// `animationSequence(forKeyPath:)`. + /// + /// - Parameter keyPath: The key path. + /// - Returns: The value. + func shownValue(forKeyPath keyPath: String) -> Any? { + // a key path usually has nothing animating, so its model value is returned without reading the clock, which + // converts the time through every layer up the tree + guard let animations = animationSequence(forKeyPath: keyPath) else { + return modelValue(forKeyPath: keyPath) + } + return shownValue(forKeyPath: keyPath, animations: animations.lazy.map(\.animation), at: currentTime) + } + + /// The color a color key path shows, see `shownValue(forKeyPath:)`. + /// + /// - Parameter keyPath: The key path. + /// - Returns: The color, or `nil` without one. + func shownColor(forKeyPath keyPath: String) -> CGColor? { + Self.object(shownValue(forKeyPath: keyPath), withTypeID: CGColor.typeID).map { unsafeDowncast($0, to: CGColor.self) } + } + + /// The path a path key path shows, see `shownValue(forKeyPath:)`. + /// + /// - Parameter keyPath: The key path. + /// - Returns: The path, or `nil` without one. + func shownPath(forKeyPath keyPath: String) -> CGPath? { + Self.object(shownValue(forKeyPath: keyPath), withTypeID: CGPath.typeID).map { unsafeDowncast($0, to: CGPath.self) } + } + + /// The opacity `opacity` or `shadowOpacity` shows, see `shownValue(forKeyPath:)`. + /// + /// - Parameter keyPath: The key path. + /// - Returns: The opacity, or `nil` for another key path. + func shownOpacity(forKeyPath keyPath: String) -> Float? { + shownValue(forKeyPath: keyPath) as? Float + } + + /// The value a key path shows at a time, given its animations. + /// + /// The animations that show at the time are evaluated and composed the way Core Animation composes them: an additive + /// animation's value adds to the value below it, and another animation's value replaces it. An animation that doesn't + /// show, one that hasn't begun without a backwards fill or has ended, has no effect, whatever its values. The + /// properties the render server clamps after each animation are clamped the same way: `opacity` and `shadowOpacity` to + /// [0, 1], and `cornerRadius` at 0, see `animate(keyPath:to:timing:updateAnimation:)`. The values interpolate as Core + /// Animation interpolates them, see `AnimationInterpolation`. + /// + /// - Parameters: + /// - keyPath: The key path. + /// - animations: The layer's animations that change the key path, in the order Core Animation applies them, see + /// `animationSequence(forKeyPath:)`. + /// - time: The time in the layer's time space, see `currentTime`. + /// - Returns: The value. When an animation can't be evaluated, it's the presentation value instead: one that has begun + /// or fills backwards, and has a time offset, repeats or autoreverses, see `hasEvaluableTiming`, or a negative speed, + /// or one that shows and changes the key path through another key path, see `KeyPathAnimation.indirect`, isn't a + /// basic animation from a value to a value, has values that don't interpolate, see `AnimationInterpolation`, or has + /// a value function, which only applies to a transform. + func shownValue(forKeyPath keyPath: String, animations: some Sequence, at time: TimeInterval) -> Any? { + switch keyPath { + case "opacity": + if let opacity = composedNumber(model: Double(opacity), clampedTo: 0 ... 1, animations: animations, at: time) { + return Float(opacity) + } + case "shadowOpacity": + if let opacity = composedNumber(model: Double(shadowOpacity), clampedTo: 0 ... 1, animations: animations, at: time) { + return Float(opacity) + } + case "cornerRadius": + if let radius = composedNumber(model: Double(cornerRadius), clampedTo: 0 ... .infinity, animations: animations, at: time) { + return CGFloat(radius) + } + default: + switch composedValue(model: modelValue(forKeyPath: keyPath), animations: animations, at: time) { + case .value(let value): + return value + case .unevaluable: + break + } + } + // an animation that can't be evaluated, so Core Animation's own evaluation, which is for another time + return presentation()?.value(forKeyPath: keyPath) + } + + /// The number the animations show over the model value, clamped after each animation. + /// + /// - Returns: The number, or `nil` when an animation can't be evaluated. + private func composedNumber(model: Double, clampedTo range: ClosedRange, animations: some Sequence, at time: TimeInterval) -> Double? { + var value = model + for keyPathAnimation in animations { + switch keyPathAnimation.effect(at: time) { + case .noEffect: + continue + case .unevaluable: + return nil + case .shows(let animation, let progress): + guard let from = (animation.fromValue as? NSNumber)?.doubleValue, let to = (animation.toValue as? NSNumber)?.doubleValue else { + return nil + } + let animatedValue = from + (to - from) * progress + value = min(max(animation.isAdditive ? value + animatedValue : animatedValue, range.lowerBound), range.upperBound) + } + } + return value + } + + /// The value the animations show over the model value. + private func composedValue(model: Any?, animations: some Sequence, at time: TimeInterval) -> ComposedValue { + var value = model + for keyPathAnimation in animations { + switch keyPathAnimation.effect(at: time) { + case .noEffect: + continue + case .unevaluable: + return .unevaluable + case .shows(let animation, let progress): + // a value function, which only applies to a transform, shows its output instead of the interpolated value. the + // values are read as objects, as Core Animation keeps them, and told apart by their type IDs, since casting an + // `Any` costs far more + guard animation.valueFunction == nil, + let from = animation.fromValue.map({ $0 as AnyObject }), CFGetTypeID(from) != CFNullGetTypeID(), + let to = animation.toValue.map({ $0 as AnyObject }), CFGetTypeID(to) != CFNullGetTypeID(), + let animatedValue = AnimationInterpolation.value(from: from, to: to, progress: progress) + else { + return .unevaluable + } + if animation.isAdditive { + guard let base = value.flatMap({ AdditiveValue($0) }), let addend = AdditiveValue(animatedValue), base.isSameKind(as: addend) else { + return .unevaluable + } + value = (base + addend).value + } else { + value = animatedValue + } + } + } + return .value(value) + } + + /// The value as an object of a Core Foundation type, or `nil` for a value of another type or no value. + private static func object(_ value: Any?, withTypeID typeID: CFTypeID) -> AnyObject? { + guard let object = value.map({ $0 as AnyObject }), CFGetTypeID(object) == typeID else { + return nil + } + return object + } +} + +private extension CAAnimation { + + /// Whether the animation begins after a time, whatever its speed, which only sets how it runs once it begins. An + /// unset (zero) `beginTime` becomes the time of the commit, so the animation has begun. + func begins(after time: TimeInterval) -> Bool { + beginTime != 0 && time < beginTime + } + + /// Whether the animation shows its start before it begins. + var fillsBackwards: Bool { + fillMode == .backwards || fillMode == .both + } + + /// Whether the animation shows at a time, given its elapsed time then: before it begins only with a backwards fill, + /// and once it has ended only when it's kept with a forwards fill, as Core Animation removes it on completion otherwise. + /// + /// - Parameters: + /// - elapsed: The animation's elapsed time at the time, see `timeSinceBegin(at:)`. + /// - time: The time in the layer's time space. + /// - Returns: Whether the animation shows. + func shows(atElapsedTime elapsed: TimeInterval, time: TimeInterval) -> Bool { + if elapsed < 0 { + return fillsBackwards + } + guard remainingTime(at: time) == nil else { + return true + } + return !isRemovedOnCompletion && (fillMode == .forwards || fillMode == .both) + } +} + +private extension KeyPathAnimation { + + /// How the animation affects the value its key path shows at a time. + /// + /// Whether the animation has begun is decided first, since one that hasn't begun shows nothing without a backwards + /// fill, whatever its timing or kind. Whether it has ended depends on its timing, so that's decided once its timing is + /// known to be evaluated. + /// + /// - Parameter time: The time in the layer's time space, see `CALayer.currentTime`. + /// - Returns: The effect. + func effect(at time: TimeInterval) -> AnimationEffect { + // an animation that hasn't begun shows only with a backwards fill, whatever its timing or speed + if animation.begins(after: time), !animation.fillsBackwards { + return .noEffect + } + // a negative speed runs the animation backwards from its end once it begins, which isn't evaluated + guard animation.hasEvaluableTiming, animation.speed >= 0 else { + return .unevaluable + } + let elapsed = animation.timeSinceBegin(at: time) + guard animation.shows(atElapsedTime: elapsed, time: time) else { + return .noEffect + } + guard let basicAnimation = directAnimation as? CABasicAnimation, basicAnimation.byValue == nil else { + return .unevaluable + } + return .shows(basicAnimation, progress: basicAnimation.progress(forElapsedTime: elapsed)) + } +} + +/// How an animation affects the value a key path shows at a time. +private enum AnimationEffect { + + /// The animation doesn't show at the time. + case noEffect + + /// The animation may show, but its timing or kind isn't evaluated, see `CALayer.shownValue(forKeyPath:animations:at:)`. + case unevaluable + + /// The animation shows its values at a progress, from its from value (0) to its to value (1). + case shows(CABasicAnimation, progress: Double) +} + +/// A key path's composed value, or that an animation of it can't be evaluated. +private enum ComposedValue { + + /// The value, `nil` for a key path without a value, such as a layer without a background color. + case value(Any?) + + /// An animation of the key path can't be evaluated. + case unevaluable +} diff --git a/ComposeUI/Sources/ComposeUI/Animations/ExtendedSRGB.swift b/ComposeUI/Sources/ComposeUI/Animations/ExtendedSRGB.swift new file mode 100644 index 00000000..4aa6c70c --- /dev/null +++ b/ComposeUI/Sources/ComposeUI/Animations/ExtendedSRGB.swift @@ -0,0 +1,104 @@ +// +// ExtendedSRGB.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 CoreGraphics +import Foundation + +/// Colors in extended sRGB, the color space Core Animation interpolates colors of any color space in. +enum ExtendedSRGB { + + /// The extended sRGB color space. + static let colorSpace = CGColorSpace(name: CGColorSpace.extendedSRGB)! // swiftlint:disable:this force_unwrapping + + private static let sRGB = CGColorSpace(name: CGColorSpace.sRGB)! // swiftlint:disable:this force_unwrapping + + /// The extended sRGB components of the colors converted last, oldest first, kept on the main thread. + private static var conversions: [(color: CGColor, components: SIMD4)] = [] + + /// The components of a color in extended sRGB: red, green, blue and alpha. + /// + /// The components of an sRGB color are its extended sRGB components. A color of another color space is converted + /// through ColorSync, which costs microseconds and dozens of allocations, so the main thread keeps the conversions of + /// the colors converted last, as a render pass converts the same few colors for many layers, such as a theme's gray. + /// + /// - Parameter color: The color. + /// - Returns: The components, or `nil` for a color that doesn't convert, such as a pattern. + static func components(of color: CGColor) -> SIMD4? { + // the named color spaces are shared instances, so an identity check tells an sRGB color without comparing names + if let space = color.colorSpace, space === sRGB || space === colorSpace { + return rgbaComponents(of: color) + } + + guard Thread.isMainThread else { + return convertedComponents(of: color) + } + if let conversion = conversions.last(where: { $0.color === color || CFEqual($0.color, color) }) { + return conversion.components + } + guard let components = convertedComponents(of: color) else { + return nil + } + if conversions.count == Constants.conversionCount { + conversions.removeFirst() + } + conversions.append((color, components)) + return components + } + + /// The color of extended sRGB components. + /// + /// - Parameter components: The components: red, green, blue and alpha. + /// - Returns: The color. + static func color(components: SIMD4) -> CGColor? { + let components = (CGFloat(components.x), CGFloat(components.y), CGFloat(components.z), CGFloat(components.w)) + return withUnsafePointer(to: components) { + $0.withMemoryRebound(to: CGFloat.self, capacity: 4) { + CGColor(colorSpace: colorSpace, components: $0) + } + } + } + + private static func convertedComponents(of color: CGColor) -> SIMD4? { + color.converted(to: colorSpace, intent: .defaultIntent, options: nil).flatMap { rgbaComponents(of: $0) } + } + + /// The components of a color of an RGB color space. + private static func rgbaComponents(of color: CGColor) -> SIMD4? { + color.components.map { SIMD4(Double($0[0]), Double($0[1]), Double($0[2]), Double($0[3])) } + } + + // MARK: - Constants + + private enum Constants { + + /// How many converted colors the main thread keeps, more than a render pass usually converts. + static let conversionCount = 8 + } +} diff --git a/ComposeUI/Sources/ComposeUI/Components/DropShadowLayer.swift b/ComposeUI/Sources/ComposeUI/Components/DropShadowLayer.swift index 97033e8c..c11ece49 100644 --- a/ComposeUI/Sources/ComposeUI/Components/DropShadowLayer.swift +++ b/ComposeUI/Sources/ComposeUI/Components/DropShadowLayer.swift @@ -96,8 +96,7 @@ open class DropShadowLayer: CALayer { override public init(layer: Any) { guard let layer = layer as? DropShadowLayer else { - // swiftlint:disable:next fatal_error - fatalError("expect the `layer` to be the same type during an animation.") + fatalError("expect the `layer` to be the same type during an animation.") // swiftlint:disable:this fatal_error } super.init(layer: layer) } @@ -132,7 +131,7 @@ open class DropShadowLayer: CALayer { animate( keyPath: "shadowColor", timing: animationTiming, - from: { $0.presentation()?.shadowColor }, + from: { $0.shownColor(forKeyPath: "shadowColor") }, to: { _ in color } ) } @@ -143,7 +142,7 @@ open class DropShadowLayer: CALayer { animate( keyPath: "shadowOpacity", timing: animationTiming, - from: { $0.presentation()?.shadowOpacity }, + from: { $0.shownOpacity(forKeyPath: "shadowOpacity") }, to: { _ in opacity } ) } diff --git a/ComposeUI/Sources/ComposeUI/Components/InnerShadowLayer.swift b/ComposeUI/Sources/ComposeUI/Components/InnerShadowLayer.swift index 8b07c6bc..7f20558b 100644 --- a/ComposeUI/Sources/ComposeUI/Components/InnerShadowLayer.swift +++ b/ComposeUI/Sources/ComposeUI/Components/InnerShadowLayer.swift @@ -103,8 +103,7 @@ open class InnerShadowLayer: CALayer { override public init(layer: Any) { guard let layer = layer as? InnerShadowLayer else { - // swiftlint:disable:next fatal_error - fatalError("expect the `layer` to be the same type during an animation.") + fatalError("expect the `layer` to be the same type during an animation.") // swiftlint:disable:this fatal_error } #if DEBUG @@ -180,7 +179,7 @@ open class InnerShadowLayer: CALayer { animate( keyPath: "shadowColor", timing: animationTiming, - from: { $0.presentation()?.shadowColor }, + from: { $0.shownColor(forKeyPath: "shadowColor") }, to: { _ in color } ) } @@ -191,7 +190,7 @@ open class InnerShadowLayer: CALayer { animate( keyPath: "shadowOpacity", timing: animationTiming, - from: { $0.presentation()?.shadowOpacity }, + from: { $0.shownOpacity(forKeyPath: "shadowOpacity") }, to: { _ in opacity } ) } diff --git a/ComposeUI/Sources/ComposeUI/ComposeNodes/ColorNode.swift b/ComposeUI/Sources/ComposeUI/ComposeNodes/ColorNode.swift index d9e898fe..169fb68e 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNodes/ColorNode.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNodes/ColorNode.swift @@ -108,7 +108,7 @@ public struct ColorNode: ComposeNode { layer.animate( keyPath: "backgroundColor", timing: animationTiming, - from: { $0.presentation()?.backgroundColor }, + from: { $0.shownColor(forKeyPath: "backgroundColor") }, to: { _ in color } ) } diff --git a/ComposeUI/Sources/ComposeUI/ComposeNodes/ModifierNode.swift b/ComposeUI/Sources/ComposeUI/ComposeNodes/ModifierNode.swift index 45f3c659..d40ebf26 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNodes/ModifierNode.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNodes/ModifierNode.swift @@ -355,7 +355,7 @@ public extension ComposeNode { layer.animate( keyPath: "backgroundColor", timing: animationTiming, - from: { $0.presentation()?.backgroundColor ?? $0.backgroundColor ?? Color.clear.cgColor }, + from: { $0.shownColor(forKeyPath: "backgroundColor") ?? $0.backgroundColor ?? Color.clear.cgColor }, to: { _ in color } ) } @@ -468,7 +468,7 @@ public extension ComposeNode { layer.animate( keyPath: "borderColor", timing: animationTiming, - from: { $0.presentation()?.borderColor ?? $0.borderColor ?? Color.clear.cgColor }, + from: { $0.shownColor(forKeyPath: "borderColor") ?? $0.borderColor ?? Color.clear.cgColor }, to: { _ in color } ) } @@ -635,7 +635,7 @@ public extension ComposeNode { layer.animate( keyPath: "shadowColor", timing: animationTiming, - from: { $0.presentation()?.shadowColor ?? $0.shadowColor ?? Color.clear.cgColor }, + from: { $0.shownColor(forKeyPath: "shadowColor") ?? $0.shadowColor ?? Color.clear.cgColor }, to: { _ in color } ) } @@ -644,7 +644,7 @@ public extension ComposeNode { layer.animate( keyPath: "shadowOpacity", timing: animationTiming, - from: { $0.presentation()?.shadowOpacity }, + from: { $0.shownOpacity(forKeyPath: "shadowOpacity") }, to: { _ in opacity } ) } @@ -659,7 +659,7 @@ public extension ComposeNode { layer.animate( keyPath: "shadowPath", timing: animationTiming, - from: { $0.presentation()?.shadowPath ?? $0.shadowPath }, + from: { $0.shownPath(forKeyPath: "shadowPath") ?? $0.shadowPath }, to: { _ in path } ) } diff --git a/ComposeUI/Sources/ComposeUI/ComposeNodes/_ComposeNode_Template_Layer.swift b/ComposeUI/Sources/ComposeUI/ComposeNodes/_ComposeNode_Template_Layer.swift index d721f890..f5b42d09 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNodes/_ComposeNode_Template_Layer.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNodes/_ComposeNode_Template_Layer.swift @@ -116,8 +116,7 @@ override init(layer: Any) { guard let layer = layer as? Self else { - // swiftlint:disable:next fatal_error - fatalError("expect the `layer` to be the same type during an animation.") + fatalError("expect the `layer` to be the same type during an animation.") // swiftlint:disable:this fatal_error } super.init(layer: layer) } diff --git a/ComposeUI/Sources/ComposeUI/_Deprecated/CrossPlatform/Label/NSLabel.swift b/ComposeUI/Sources/ComposeUI/_Deprecated/CrossPlatform/Label/NSLabel.swift index 8ca766a6..1aac5b6c 100644 --- a/ComposeUI/Sources/ComposeUI/_Deprecated/CrossPlatform/Label/NSLabel.swift +++ b/ComposeUI/Sources/ComposeUI/_Deprecated/CrossPlatform/Label/NSLabel.swift @@ -89,8 +89,7 @@ // // @available(*, unavailable) // public required init?(coder: NSCoder) { -// // swiftlint:disable:next fatal_error -// fatalError("init(coder:) is unavailable") +// fatalError("init(coder:) is unavailable") // swiftlint:disable:this fatal_error // } // // private func commonInit() { diff --git a/ComposeUI/Tests/ComposeUITests/Animations/AdditivePath/CALayer+AdditivePathTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/AdditivePath/CALayer+AdditivePathTests.swift index a3bfa02d..4145eb0e 100644 --- a/ComposeUI/Tests/ComposeUITests/Animations/AdditivePath/CALayer+AdditivePathTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Animations/AdditivePath/CALayer+AdditivePathTests.swift @@ -50,8 +50,8 @@ class CALayer_AdditivePathTests: XCTestCase { // then: a basic animation goes from the rect to the inset rect with the timing, beginning at 1000, and the model has // the inset rect let animation = try (layer.animation(forKey: "path") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(animation.fromValue))) == PathPoints(rect()) - expect(try path(animation.toValue)) == rect(inset: 10) + expect(try PathPoints(pathValue(animation.fromValue))) == PathPoints(rect()) + expect(try pathValue(animation.toValue)) == rect(inset: 10) expect(animation.duration) == 2 expect(animation.timingFunction) == CAMediaTimingFunction(name: .easeIn) expect(animation.fillMode) == .both @@ -118,7 +118,7 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the path animates with keyframes of both changes added up, spread evenly from the path shown to the new // path, until the longer change lands. the changes begin at 1000, and so do the keyframes let animation = try (layer.animation(forKey: "path") as? CAKeyframeAnimation).unwrap() - let values = try paths(of: animation) + let values = try pathValues(of: animation) expect(values.count) == 121 expect(animation.keyTimes) == nil expect(animation.calculationMode) == .linear @@ -146,7 +146,7 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the path is the rounded rect of the width the two changes add up to: 125 wide now, as the first change is a // quarter done, 200 wide half a second later, and 300 wide when the first change lands. the keyframes begin now let animation = try (layer.animation(forKey: "path") as? CAKeyframeAnimation).unwrap() - let values = try paths(of: animation) + let values = try pathValues(of: animation) expect(animation.beginTime) == 1000 expect(animation.duration) == 1.5 expect(try values.first.unwrap().maxPointDistance(to: roundedRect(width: 125))) < 0.01 @@ -179,7 +179,7 @@ class CALayer_AdditivePathTests: XCTestCase { // a begin time of zero, so the keyframes start from the width shown, 175, and last as long as the new change, longer // than the half second the first change has left let animation = try (layer.animation(forKey: "path") as? CAKeyframeAnimation).unwrap() - let values = try paths(of: animation) + let values = try pathValues(of: animation) expect(shownWidth).to(beApproximatelyEqual(to: 175, within: 1e-6)) expect(try values.first.unwrap().maxPointDistance(to: roundedRect(width: 175))) < 0.01 expect(animation.duration) == 1 @@ -222,7 +222,7 @@ class CALayer_AdditivePathTests: XCTestCase { let elapsedTime = animation.beginTime - committedBeginTime expect(elapsedTime) > 0 let shownInset = 10 * elapsedTime / 2 - expect(try paths(of: animation).first.unwrap().maxPointDistance(to: rect(inset: shownInset))) < 1e-3 + expect(try pathValues(of: animation).first.unwrap().maxPointDistance(to: rect(inset: shownInset))) < 1e-3 } func test_animatePath_changesInFlight_showTheirSum() throws { @@ -423,8 +423,8 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the new path shows at once, and the change in flight keeps adding to it let animation = try (layer.animation(forKey: "path") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(animation.fromValue))) == PathPoints(rect(width: 120, inset: 10)) - expect(try path(animation.toValue)) == rect(width: 120, inset: 20) + expect(try PathPoints(pathValue(animation.fromValue))) == PathPoints(rect(width: 120, inset: 10)) + expect(try pathValue(animation.toValue)) == rect(width: 120, inset: 20) expect(animation.duration) == 2 } @@ -439,7 +439,7 @@ class CALayer_AdditivePathTests: XCTestCase { let animation = try (layer.animation(forKey: "path") as? CABasicAnimation).unwrap() expect(animation.beginTime).to(beApproximatelyEqual(to: layer.currentTime + 0.5, within: 0.05)) expect(animation.fillMode) == .both - expect(try PathPoints(path(animation.fromValue))) == PathPoints(rect()) + expect(try PathPoints(pathValue(animation.fromValue))) == PathPoints(rect()) } func test_animatePath_spring() throws { @@ -469,7 +469,7 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the shadow path animates from the rect let animation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(animation.fromValue))) == PathPoints(rect()) + expect(try PathPoints(pathValue(animation.fromValue))) == PathPoints(rect()) expect(layer.shadowPath) == rect(inset: 10) } @@ -486,7 +486,7 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the changes went with the replaced animation, so the path animates from the model path alone let animation = try (layer.animation(forKey: "path") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(animation.fromValue))) == PathPoints(rect(inset: 10)) + expect(try PathPoints(pathValue(animation.fromValue))) == PathPoints(rect(inset: 10)) expect(animation.duration) == 1 // when: the layer's animations are removed, and the path animates again @@ -495,7 +495,7 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the changes went with the animations too let newAnimation = try (layer.animation(forKey: "path") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(newAnimation.fromValue))) == PathPoints(rect(inset: 20)) + expect(try PathPoints(pathValue(newAnimation.fromValue))) == PathPoints(rect(inset: 20)) } func test_animatePath_keyPathWithoutPath_asserts() { @@ -552,8 +552,8 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the change keeps adding to the new path, so the path shown widens by 20 at once and still loses its inset // over the change's time, from the change's begin time let animation = try (layer.animation(forKey: "path") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(animation.fromValue))) == PathPoints(rect(width: 120)) - expect(try path(animation.toValue)) == rect(width: 120, inset: 10) + expect(try PathPoints(pathValue(animation.fromValue))) == PathPoints(rect(width: 120)) + expect(try pathValue(animation.toValue)) == rect(width: 120, inset: 10) expect(animation.duration) == 2 expect(animation.beginTime) == 1000 expect(layer.path) == rect(width: 120, inset: 10) @@ -588,7 +588,7 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the change in flight is left alone let animation = try (layer.animation(forKey: "path") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(animation.fromValue))) == PathPoints(rect()) + expect(try PathPoints(pathValue(animation.fromValue))) == PathPoints(rect()) } func test_setPath_otherSegments_setsThePathAtOnce() { @@ -674,10 +674,10 @@ class CALayer_AdditivePathTests: XCTestCase { // then: the paths animate from the paths set before, and are read and set without KVC let shadowPathAnimation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(shadowPathAnimation.fromValue))) == PathPoints(rect(inset: 10)) + expect(try PathPoints(pathValue(shadowPathAnimation.fromValue))) == PathPoints(rect(inset: 10)) expect(layer.shadowPath) == rect(inset: 20) let pathAnimation = try (shapeLayer.animation(forKey: "path") as? CABasicAnimation).unwrap() - expect(try PathPoints(path(pathAnimation.fromValue))) == PathPoints(rect(inset: 10)) + expect(try PathPoints(pathValue(pathAnimation.fromValue))) == PathPoints(rect(inset: 10)) expect(shapeLayer.path) == rect(inset: 20) expect(layer.kvcReadCount) == 0 expect(layer.kvcWriteCount) == 0 @@ -694,7 +694,7 @@ class CALayer_AdditivePathTests: XCTestCase { layer.setPath(keyPath: "path", to: rect(inset: 10)) // then: the paths are read and set through KVC, as a value for the key - expect(try path(layer.value(forKey: "path"))) == rect(inset: 10) + expect(try pathValue(layer.value(forKey: "path"))) == rect(inset: 10) expect(layer.kvcReadCount) == 2 expect(layer.kvcWriteCount) == 2 } @@ -719,15 +719,10 @@ class CALayer_AdditivePathTests: XCTestCase { CGPath(roundedRect: CGRect(x: 0, y: 0, width: width, height: 50), cornerWidth: 10, cornerHeight: 10, transform: nil) } - /// The paths of a keyframe animation. - private func paths(of animation: CAKeyframeAnimation) throws -> [CGPath] { - try animation.values.unwrap().map { try path($0) } - } - /// The points a keyframe animation of paths shows at a time from its begin, interpolated the way Core Animation /// interpolates linear keyframes: at the key times, or spread evenly without them. private func interpolatedPoints(of animation: CAKeyframeAnimation, at time: TimeInterval) throws -> PathPoints { - let values = try paths(of: animation).map { PathPoints($0) } + let values = try pathValues(of: animation).map { PathPoints($0) } let times = animation.keyTimes?.map { $0.doubleValue * animation.duration } ?? values.indices.map { animation.duration * TimeInterval($0) / TimeInterval(values.count - 1) } let index = try times.lastIndex { $0 <= time }.unwrap() @@ -737,10 +732,4 @@ class CALayer_AdditivePathTests: XCTestCase { let fraction = (time - times[index]) / (times[index + 1] - times[index]) return values[index].adding(values[index + 1].subtracting(values[index]), multipliedBy: fraction) } - - /// A path given as an animation value. - private func path(_ value: Any?) throws -> CGPath { - // a Core Foundation type can't be checked at runtime, so the cast is forced - try (value.unwrap() as! CGPath) // swiftlint:disable:this force_cast - } } diff --git a/ComposeUI/Tests/ComposeUITests/Animations/AdditiveValueTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/AdditiveValueTests.swift new file mode 100644 index 00000000..f6d62f19 --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/Animations/AdditiveValueTests.swift @@ -0,0 +1,87 @@ +// +// AdditiveValueTests.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 CoreGraphics + +import ChouTiTest + +@testable import ComposeUI + +class AdditiveValueTests: XCTestCase { + + func test_init_kinds() throws { + // then: numbers, sizes and points, as Swift values or boxed as Core Animation keeps them, make values of their kind + expect(try unwrap(AdditiveValue(2.5)).value as? Double) == 2.5 + expect(try unwrap(AdditiveValue(Float(0.5))).value as? Double) == 0.5 + expect(try unwrap(AdditiveValue(NSNumber(value: 3))).value as? Double) == 3 + expect(try unwrap(AdditiveValue(CGSize(width: 1, height: 2))).value as? CGSize) == CGSize(width: 1, height: 2) + expect(try unwrap(AdditiveValue(CGSize(width: 1, height: 2) as AnyObject)).value as? CGSize) == CGSize(width: 1, height: 2) + expect(try unwrap(AdditiveValue(CGPoint(x: 3, y: 4))).value as? CGPoint) == CGPoint(x: 3, y: 4) + expect(try unwrap(AdditiveValue(CGPoint(x: 3, y: 4) as AnyObject)).value as? CGPoint) == CGPoint(x: 3, y: 4) + + // then: other values don't + expect(AdditiveValue("a")) == nil + expect(AdditiveValue(CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1))) == nil + } + + func test_kinds_areToldApart() throws { + // given: a number, a size and a point + let number = try unwrap(AdditiveValue(1.0)) + let size = try unwrap(AdditiveValue(CGSize(width: 1, height: 0))) + let point = try unwrap(AdditiveValue(CGPoint(x: 1, y: 0))) + + // then: only values of one kind are the same kind + expect(try number.isSameKind(as: unwrap(AdditiveValue(2.0)))) == true + expect(number.isSameKind(as: size)) == false + expect(size.isSameKind(as: point)) == false + expect(point.isSameKind(as: number)) == false + } + + func test_arithmetic() throws { + // given: two sizes + let lhs = try unwrap(AdditiveValue(CGSize(width: 1, height: 2))) + let rhs = try unwrap(AdditiveValue(CGSize(width: 3, height: 5))) + + // then: they add, subtract and scale component by component, and have a zero of their kind + expect((lhs + rhs).value as? CGSize) == CGSize(width: 4, height: 7) + expect((rhs - lhs).value as? CGSize) == CGSize(width: 2, height: 3) + expect(lhs.scaled(by: 2).value as? CGSize) == CGSize(width: 2, height: 4) + expect(lhs.zero.value as? CGSize) == CGSize.zero + expect(lhs.zero.isZero) == true + expect(lhs.isZero) == false + + // when: adding in place + var sum = lhs + sum += rhs + + // then: it's the sum + expect(sum.value as? CGSize) == CGSize(width: 4, height: 7) + } +} diff --git a/ComposeUI/Tests/ComposeUITests/Animations/AnimationInterpolationTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/AnimationInterpolationTests.swift new file mode 100644 index 00000000..6e762160 --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/Animations/AnimationInterpolationTests.swift @@ -0,0 +1,221 @@ +// +// AnimationInterpolationTests.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 AnimationInterpolationTests: XCTestCase { + + private let red = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + private let blue = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + + // MARK: - Value + + func test_value_atTheEnds_isTheValueItself() throws { + // given: values that interpolate, and values that don't + let pairs: [(from: AnyObject, to: AnyObject)] = [ + (red, blue), + (NSNumber(value: 1), NSNumber(value: 2)), + (NSString(string: "a"), NSString(string: "b")), + ] + + for (from, to) in pairs { + // then: at a progress of 0 and 1 it's the from or to value itself + expect(AnimationInterpolation.value(from: from, to: to, progress: 0) as AnyObject) === from + expect(AnimationInterpolation.value(from: from, to: to, progress: 1) as AnyObject) === to + } + } + + func test_value_numbersSizesAndPoints_interpolateComponentByComponent() { + // then: numbers, sizes and points, as Core Animation boxes them, interpolate component by component, past the to + // value for an overshoot + expect(AnimationInterpolation.value(from: NSNumber(value: 2), to: NSNumber(value: 10), progress: 0.25) as? Double) == 4 + expect(AnimationInterpolation.value(from: NSNumber(value: 2), to: NSNumber(value: 10), progress: 1.5) as? Double) == 14 + expect(AnimationInterpolation.value(from: CGSize(width: 0, height: 8) as AnyObject, to: CGSize(width: 8, height: 0) as AnyObject, progress: 0.25) as? CGSize) == CGSize(width: 2, height: 6) + expect(AnimationInterpolation.value(from: CGPoint(x: 10, y: 20) as AnyObject, to: CGPoint(x: 50, y: -20) as AnyObject, progress: 0.25) as? CGPoint) == CGPoint(x: 20, y: 10) + } + + func test_value_valuesThatDontInterpolate_isNil() throws { + // then: values of other kinds, or of mismatched kinds, don't interpolate + expect(AnimationInterpolation.value(from: NSString(string: "a"), to: NSString(string: "b"), progress: 0.5)) == nil + expect(AnimationInterpolation.value(from: NSNumber(value: 1), to: CGSize.zero as AnyObject, progress: 0.5)) == nil + expect(AnimationInterpolation.value(from: red, to: NSNumber(value: 1), progress: 0.5)) == nil + } + + func test_value_colorsAndPaths_interpolateAsColorsAndPaths() throws { + // given: colors and paths + let square = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) + let rect = CGPath(rect: CGRect(x: 10, y: 20, width: 20, height: 50), transform: nil) + + // then: they interpolate as colors and paths + try expectExtendedSRGBComponents(of: colorValue(AnimationInterpolation.value(from: red, to: blue, progress: 0.25)), toBe: [0.75, 0, 0.25, 1]) + expect(try PathPoints(pathValue(AnimationInterpolation.value(from: square, to: rect, progress: 0.25)))) == PathPoints(CGPath(rect: CGRect(x: 2.5, y: 5, width: 80, height: 87.5), transform: nil)) + } + + // MARK: - Color + + func test_color_interpolatesTheExtendedSRGBComponents() throws { + // given: colors of several color spaces, with alpha + let pairs = try [ + (red, CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 0.5)), + (CGColor(gray: 0.2, alpha: 1), CGColor(colorSpace: unwrap(CGColorSpace(name: CGColorSpace.displayP3)), components: [0, 1, 0, 1]).unwrap()), + ] + + for (from, to) in pairs { + // when: interpolating a quarter of the way + let color = try AnimationInterpolation.color(from: from, to: to, progress: 0.25).unwrap() + + // then: the color is in extended sRGB, a quarter of the way component by component, with straight alpha + expect(color.colorSpace?.name) == CGColorSpace.extendedSRGB + try expectExtendedSRGBComponents(of: color, toBe: interpolatedExtendedSRGBComponents(from: from, to: to, progress: 0.25)) + } + } + + func test_color_pattern_isNil() throws { + // given: a pattern color, which has no extended sRGB components + var callbacks = CGPatternCallbacks(version: 0, drawPattern: { _, _ in }, releaseInfo: nil) + let pattern = try CGPattern(info: nil, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), matrix: .identity, xStep: 1, yStep: 1, tiling: .noDistortion, isColored: true, callbacks: &callbacks).unwrap() + var alpha: CGFloat = 1 + let patternColor = try CGColor(patternSpace: CGColorSpace(patternBaseSpace: nil).unwrap(), pattern: pattern, components: &alpha).unwrap() + + // then: a color and a pattern don't interpolate, either way + expect(AnimationInterpolation.color(from: red, to: patternColor, progress: 0.5)) == nil + expect(AnimationInterpolation.color(from: patternColor, to: red, progress: 0.5)) == nil + } + + // MARK: - Path + + func test_path_interpolatesPointByPoint() throws { + // given: rounded rects with the same segments + let from = CGPath(roundedRect: CGRect(x: 0, y: 0, width: 100, height: 100), cornerWidth: 10, cornerHeight: 10, transform: nil) + let to = CGPath(roundedRect: CGRect(x: 0, y: 0, width: 200, height: 100), cornerWidth: 20, cornerHeight: 20, transform: nil) + + // when: interpolating a quarter of the way + let path = try AnimationInterpolation.path(from: from, to: to, progress: 0.25).unwrap() + + // then: each point is a quarter of the way + let expected = PathPoints(from).adding(PathPoints(to).subtracting(PathPoints(from)), multipliedBy: 0.25) + expect(PathPoints(path)) == expected + } + + func test_path_otherSegmentsOrInfinitePoints_isNil() { + // given: a square, and paths that can't be added to it point by point + let square = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) + let ellipse = CGPath(ellipseIn: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) + let nullRect = CGPath(rect: .null, transform: nil) + + // then: they don't interpolate, either way + expect(AnimationInterpolation.path(from: square, to: ellipse, progress: 0.5)) == nil + expect(AnimationInterpolation.path(from: square, to: nullRect, progress: 0.5)) == nil + expect(AnimationInterpolation.path(from: nullRect, to: square, progress: 0.5)) == nil + } + + // MARK: - Core Animation + + func test_value_matchesCoreAnimation() throws { + // given: layers on a paused timeline, each with a linear animation between two values, from 100 over 10 s + let testWindow = TestWindow() + let root = CALayer() + root.speed = 0 + root.timeOffset = 100 + testWindow.layer.addSublayer(root) + CATransaction.flush() + + let displayP3 = try unwrap(CGColorSpace(name: CGColorSpace.displayP3)) + let extendedSRGB = try unwrap(CGColorSpace(name: CGColorSpace.extendedSRGB)) + let linearSRGB = try unwrap(CGColorSpace(name: CGColorSpace.extendedLinearSRGB)) + let cases: [(keyPath: String, from: AnyObject, to: AnyObject)] = try [ + ("cornerRadius", NSNumber(value: 2), NSNumber(value: 18)), + ("shadowOffset", CGSize(width: 0, height: 8) as AnyObject, CGSize(width: 8, height: -4) as AnyObject), + ("position", CGPoint(x: 10, y: 20) as AnyObject, CGPoint(x: 50, y: -20) as AnyObject), + ("backgroundColor", red, CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 0.2)), + ("backgroundColor", CGColor(gray: 0.2, alpha: 1), CGColor(colorSpace: displayP3, components: [0, 1, 0, 1]).unwrap()), + ("backgroundColor", CGColor(red: 1, green: 0, blue: 0, alpha: 1), CGColor(colorSpace: extendedSRGB, components: [1.2, -0.1, 0.5, 1]).unwrap()), + ("backgroundColor", CGColor(colorSpace: linearSRGB, components: [1, 0.5, 0, 1]).unwrap(), blue), + ("shadowPath", CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil), CGPath(rect: CGRect(x: 10, y: 20, width: 20, height: 50), transform: nil)), + ("shadowPath", CGPath(roundedRect: CGRect(x: 0, y: 0, width: 100, height: 100), cornerWidth: 10, cornerHeight: 10, transform: nil), CGPath(roundedRect: CGRect(x: 0, y: 0, width: 200, height: 60), cornerWidth: 20, cornerHeight: 20, transform: nil)), + ] + let layers = cases.map { testCase -> CALayer in + let layer = CALayer() + layer.frame = CGRect(x: 0, y: 0, width: 10, height: 10) + root.addSublayer(layer) + CATransaction.disableAnimations { + layer.setValue(testCase.to, forKeyPath: testCase.keyPath) + } + let animation = CABasicAnimation(keyPath: testCase.keyPath) + animation.fromValue = testCase.from + animation.toValue = testCase.to + animation.beginTime = 100 + animation.duration = 10 + animation.fillMode = .both + layer.add(animation, forKey: testCase.keyPath) + return layer + } + + for progress in [0.25, 0.5, 0.75] { + // when: moving the timeline + root.timeOffset = 100 + 10 * progress + CATransaction.flush() + + for (testCase, layer) in zip(cases, layers) { + // then: the interpolated value is the one Core Animation shows, paced exactly without a timing function + let description = "\(testCase.keyPath) from \(testCase.from) at \(progress)" + let shown = try unwrap(layer.presentation()?.value(forKeyPath: testCase.keyPath), description) + let interpolated = try unwrap(AnimationInterpolation.value(from: testCase.from, to: testCase.to, progress: progress), description) + switch testCase.keyPath { + case "cornerRadius": + expect(try numberScalar(interpolated), description).to(try beApproximatelyEqual(to: numberScalar(shown), within: 1e-4)) + case "shadowOffset": + let size = try unwrap(interpolated as? CGSize) + let shownSize = try unwrap(shown as? CGSize) + expect(size.width, description).to(beApproximatelyEqual(to: shownSize.width, within: 1e-4)) + expect(size.height, description).to(beApproximatelyEqual(to: shownSize.height, within: 1e-4)) + case "position": + let point = try unwrap(interpolated as? CGPoint) + let shownPoint = try unwrap(shown as? CGPoint) + expect(point.x, description).to(beApproximatelyEqual(to: shownPoint.x, within: 1e-4)) + expect(point.y, description).to(beApproximatelyEqual(to: shownPoint.y, within: 1e-4)) + case "backgroundColor": + try expectExtendedSRGBComponents(of: colorValue(interpolated), toBe: colorValue(shown).extendedSRGBComponents(), within: 1e-5) + default: + // Core Animation turns the lines of a path it shows into curves, so the paths are compared by their bounds + let bounds = try pathValue(interpolated).boundingBoxOfPath + let shownBounds = try pathValue(shown).boundingBoxOfPath + for (value, shownValue) in zip([bounds.minX, bounds.minY, bounds.width, bounds.height], [shownBounds.minX, shownBounds.minY, shownBounds.width, shownBounds.height]) { + expect(value, description).to(beApproximatelyEqual(to: shownValue, within: 1e-4)) + } + } + } + } + } +} diff --git a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+AnimationsTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+AnimationsTests.swift index 5a02eb4c..4e87e00b 100644 --- a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+AnimationsTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+AnimationsTests.swift @@ -456,6 +456,54 @@ class CALayer_AnimationsTests: XCTestCase { expect(layer.backgroundColor) == red } + func test_animate_delayed_nilFromValue_animationInFlight_resolvesToTheShownValue() throws { + // given: an unhosted layer whose background color animates from red to blue over two seconds, from 1000 + let red = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + let blue = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + let layer = CALayer() + AnimationClock.sharingTime(at: 1000) { + layer.animate(keyPath: "backgroundColor", timing: .linear(duration: 2), from: { _ in red }, to: { _ in blue }) + } + + // when: a quarter of the way, animating the background color to green with a delay, from a nil value + AnimationClock.sharingTime(at: 1000.5) { + layer.animate( + keyPath: "backgroundColor", + timing: .linear(duration: 1, delay: 0.5), + from: { _ -> CGColor? in nil }, + to: { _ in CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) } + ) + } + + // then: the nil from value is resolved to the color the layer shows at the clock's time, a quarter of the way to + // blue, instead of the model color + let animation = try unwrap(layer.animation(forKey: "backgroundColor") as? CABasicAnimation) + try expectExtendedSRGBComponents(of: colorValue(animation.fromValue), toBe: [0.75, 0, 0.25, 1]) + } + + func test_animate_delayed_nilFromValue_unevaluableAnimationInFlight_resolvesToTheModelValue() throws { + // given: an unhosted green layer with a keyframe animation of its background color, which can't be evaluated + let green = CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) + let layer = CALayer() + layer.backgroundColor = green + let keyframeAnimation = CAKeyframeAnimation(keyPath: "backgroundColor") + keyframeAnimation.values = [green, CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1)] + keyframeAnimation.duration = 2 + layer.add(keyframeAnimation, forKey: "keyframes") + + // when: animating the background color to red with a delay, from a nil value + layer.animate( + keyPath: "backgroundColor", + timing: .linear(duration: 1, delay: 0.5), + from: { _ -> CGColor? in nil }, + to: { _ in CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) } + ) + + // then: the layer has no presentation layer to fall back to, so the nil from value is resolved to the model color + let animation = try unwrap(layer.animation(forKey: "backgroundColor") as? CABasicAnimation) + expect(try colorValue(animation.fromValue)) == green + } + func test_animate_delayed_holdsFromValueDuringDelayWindow() throws { // given: a layer hosted in a window with partial opacity, in a new turn of the run loop, so an animation added now // begins now @@ -585,133 +633,6 @@ class CALayer_AnimationsTests: XCTestCase { } } - // MARK: - Key Path Values - - func test_pointValue() { - // given: a layer that counts KVC reads, with a position and an anchor point - let layer = KVCCountingLayer() - layer.position = CGPoint(x: 10, y: 20) - layer.anchorPoint = CGPoint(x: 0.25, y: 0.75) - - // when: reading the position - let position = layer.pointValue(forKeyPath: "position") - - // then: the position is read directly - expect(position) == CGPoint(x: 10, y: 20) - expect(layer.kvcReadCount) == 0 - - // when: reading another point key path - let anchorPoint = layer.pointValue(forKeyPath: "anchorPoint") - - // then: the value is read through KVC - expect(anchorPoint) == CGPoint(x: 0.25, y: 0.75) - expect(layer.kvcReadCount) == 1 - } - - func test_sizeValue() { - // given: a layer that counts KVC reads, with a bounds size, a shadow offset, and a translation - let layer = KVCCountingLayer() - layer.bounds.size = CGSize(width: 30, height: 40) - layer.shadowOffset = CGSize(width: 5, height: 6) - layer.transform = CATransform3DMakeTranslation(7, 8, 0) - - // when: reading the bounds size and the shadow offset - let boundsSize = layer.sizeValue(forKeyPath: "bounds.size") - let shadowOffset = layer.sizeValue(forKeyPath: "shadowOffset") - - // then: they are read directly - expect(boundsSize) == CGSize(width: 30, height: 40) - expect(shadowOffset) == CGSize(width: 5, height: 6) - expect(layer.kvcReadCount) == 0 - - // when: reading another size key path - let translation = layer.sizeValue(forKeyPath: "transform.translation") - - // then: the value is read through KVC - expect(translation) == CGSize(width: 7, height: 8) - expect(layer.kvcReadCount) == 1 - } - - func test_floatingPointValue() { - // given: a layer that counts KVC reads, with non-default values for the numbers read directly, and a scale - let layer = KVCCountingLayer() - layer.opacity = 0.5 - layer.shadowOpacity = 0.25 - layer.borderWidth = 2 - layer.cornerRadius = 3 - layer.shadowRadius = 4 - layer.transform = CATransform3DMakeScale(2, 2, 2) - - // when: reading the numbers as their property types - let opacity: Float = layer.floatingPointValue(forKeyPath: "opacity") - let shadowOpacity: Float = layer.floatingPointValue(forKeyPath: "shadowOpacity") - let borderWidth: CGFloat = layer.floatingPointValue(forKeyPath: "borderWidth") - let cornerRadius: CGFloat = layer.floatingPointValue(forKeyPath: "cornerRadius") - let shadowRadius: CGFloat = layer.floatingPointValue(forKeyPath: "shadowRadius") - - // then: they are read directly - expect(opacity) == 0.5 - expect(shadowOpacity) == 0.25 - expect(borderWidth) == 2 - expect(cornerRadius) == 3 - expect(shadowRadius) == 4 - expect(layer.kvcReadCount) == 0 - - // when: reading the numbers as other floating-point types - let opacityAsCGFloat: CGFloat = layer.floatingPointValue(forKeyPath: "opacity") - let shadowOpacityAsCGFloat: CGFloat = layer.floatingPointValue(forKeyPath: "shadowOpacity") - let borderWidthAsFloat: Float = layer.floatingPointValue(forKeyPath: "borderWidth") - let cornerRadiusAsFloat: Float = layer.floatingPointValue(forKeyPath: "cornerRadius") - let shadowRadiusAsFloat: Float = layer.floatingPointValue(forKeyPath: "shadowRadius") - - // then: they are read through KVC, which converts the boxed numbers - expect(opacityAsCGFloat) == 0.5 - expect(shadowOpacityAsCGFloat) == 0.25 - expect(borderWidthAsFloat) == 2 - expect(cornerRadiusAsFloat) == 3 - expect(shadowRadiusAsFloat) == 4 - expect(layer.kvcReadCount) == 5 - - // when: reading another number key path - let scale: CGFloat = layer.floatingPointValue(forKeyPath: "transform.scale") - - // then: the value is read through KVC - expect(scale) == 2 - expect(layer.kvcReadCount) == 6 - } - - func test_keyPathValues_directReadsMatchKVC() { - // given: a plain layer and a view's backing layer, with non-default values for the properties read directly - let testWindow = TestWindow() - let view = View(frame: CGRect(x: 10, y: 20, width: 30, height: 40)) - #if canImport(AppKit) - view.wantsLayer = true - #endif - testWindow.contentView().addSubview(view) - - let plainLayer = CALayer() - plainLayer.frame = CGRect(x: 10, y: 20, width: 30, height: 40) - - for layer in [plainLayer, view.layer()] { - layer.opacity = 0.5 - layer.shadowOpacity = 0.25 - layer.borderWidth = 2 - layer.cornerRadius = 3 - layer.shadowRadius = 4 - layer.shadowOffset = CGSize(width: 5, height: 6) - - // then: the direct reads return what KVC returns - expect(layer.value(forKeyPath: "position") as? CGPoint) == layer.pointValue(forKeyPath: "position") - expect(layer.value(forKeyPath: "bounds.size") as? CGSize) == layer.sizeValue(forKeyPath: "bounds.size") - expect(layer.value(forKeyPath: "shadowOffset") as? CGSize) == layer.sizeValue(forKeyPath: "shadowOffset") - expect(layer.value(forKeyPath: "opacity") as? Float) == layer.floatingPointValue(forKeyPath: "opacity") as Float - expect(layer.value(forKeyPath: "shadowOpacity") as? Float) == layer.floatingPointValue(forKeyPath: "shadowOpacity") as Float - expect(layer.value(forKeyPath: "borderWidth") as? CGFloat) == layer.floatingPointValue(forKeyPath: "borderWidth") as CGFloat - expect(layer.value(forKeyPath: "cornerRadius") as? CGFloat) == layer.floatingPointValue(forKeyPath: "cornerRadius") as CGFloat - expect(layer.value(forKeyPath: "shadowRadius") as? CGFloat) == layer.floatingPointValue(forKeyPath: "shadowRadius") as CGFloat - } - } - // MARK: - uniqueAnimationKey func test_uniqueAnimationKey_noExistingAnimations() { @@ -785,6 +706,9 @@ class CALayer_AnimationsTests: XCTestCase { spinAnimation.duration = 60 layer.add(spinAnimation, forKey: "spin") + // a group animating the key path is not a basic animation + layer.add(group(of: [CABasicAnimation(keyPath: "opacity")]), forKey: "group-fade") + // when: querying basic animations for the opacity key path let animations = layer.basicAnimations(forKeyPath: "opacity") @@ -794,7 +718,7 @@ class CALayer_AnimationsTests: XCTestCase { } func test_propertyAnimations_forKeyPath() { - // given: a layer with basic, keyframe, and different key path animations + // given: a layer with basic, keyframe, and different key path animations, and a group animating the key path let layer = CALayer() let fadeAnimation = CABasicAnimation(keyPath: "opacity") @@ -809,15 +733,135 @@ class CALayer_AnimationsTests: XCTestCase { spinAnimation.duration = 60 layer.add(spinAnimation, forKey: "spin") + layer.add(group(of: [CABasicAnimation(keyPath: "opacity")]), forKey: "group-fade") + // when: querying property animations for the opacity key path let animations = layer.propertyAnimations(forKeyPath: "opacity") - // then: both opacity animations match, in order, and the other key path doesn't + // then: both opacity animations match, in order, and neither the other key path nor the group does expect(animations.count) == 2 expect(animations.first is CABasicAnimation) == true expect(animations.last is CAKeyframeAnimation) == true } + func test_animationSequence_yieldsTheKeyPathsAnimationsWithTheirKeysInOrder() throws { + // given: a layer with opacity animations, and groups with one, directly and in a group of their own, between other + // animations: of another key path, a transition, a group of another key path and a transition, and an empty group + let layer = CALayer() + + let fadeAnimation = CABasicAnimation(keyPath: "opacity") + fadeAnimation.duration = 60 + layer.add(fadeAnimation, forKey: "fade") + + let spinAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + spinAnimation.duration = 60 + layer.add(spinAnimation, forKey: "spin") + + layer.add(group(of: [CABasicAnimation(keyPath: "position"), CABasicAnimation(keyPath: "opacity")]), forKey: "group-fade") + + let transition = CATransition() + transition.duration = 60 + layer.add(transition, forKey: "transition") + + layer.add(group(of: [CABasicAnimation(keyPath: "position"), CATransition()]), forKey: "group-move") + layer.add(group(of: nil), forKey: "empty-group") + layer.add(group(of: [group(of: [CABasicAnimation(keyPath: "position")]), group(of: [CABasicAnimation(keyPath: "opacity")])]), forKey: "nested-group-fade") + + let keyframeAnimation = CAKeyframeAnimation(keyPath: "opacity") + keyframeAnimation.duration = 60 + layer.add(keyframeAnimation, forKey: "keyframe-fade") + + // when: iterating the opacity animations + let animations = try Array(layer.animationSequence(forKeyPath: "opacity").unwrap()) + + // then: the opacity animations and the groups with one are yielded with their keys, in order, skipping the others, + // the opacity animations as direct ones and the groups as indirect ones + expect(animations.map(\.key)) == ["fade", "group-fade", "nested-group-fade", "keyframe-fade"] + expect(animations.map { kind(of: $0.animation) }) == ["direct", "indirect", "indirect", "direct"] + expect(animations[0].animation.animation is CABasicAnimation) == true + expect(animations[1].animation.animation is CAAnimationGroup) == true + expect(animations[2].animation.animation is CAAnimationGroup) == true + expect(animations[3].animation.animation is CAKeyframeAnimation) == true + } + + func test_animationSequence_relatedKeyPaths_areIndirect() throws { + // given: a layer whose position animates directly, through its x component, and through a group of it, with a + // sibling of its bounds size, its bounds, one of its bounds size's components, and a key path that only starts with + // `position` animating too + let layer = CALayer() + for keyPath in ["position", "position.x", "positionX", "bounds", "bounds.origin", "bounds.size.width"] { + let animation = CABasicAnimation(keyPath: keyPath) + animation.duration = 60 + layer.add(animation, forKey: keyPath) + } + layer.add(group(of: [CABasicAnimation(keyPath: "position.y")]), forKey: "group-move-y") + + // when: iterating the animations of the position and of the bounds size + let positionAnimations = try Array(layer.animationSequence(forKeyPath: "position").unwrap()) + let sizeAnimations = try Array(layer.animationSequence(forKeyPath: "bounds.size").unwrap()) + + // then: the position's animation is direct, and its component's and the group's are indirect, while a key path + // that doesn't continue with a dot isn't related + expect(positionAnimations.map(\.key)) == ["position", "position.x", "group-move-y"] + expect(positionAnimations.map { kind(of: $0.animation) }) == ["direct", "indirect", "indirect"] + + // then: the bounds and the size's component change the size indirectly, while a sibling of the size doesn't + expect(sizeAnimations.map(\.key)) == ["bounds", "bounds.size.width"] + expect(sizeAnimations.map { kind(of: $0.animation) }) == ["indirect", "indirect"] + } + + func test_keyPathLookups_leaveRelatedKeyPathsOut() { + // given: a layer whose position animates directly and through its x component + let layer = CALayer() + for keyPath in ["position", "position.x"] { + let animation = CABasicAnimation(keyPath: keyPath) + animation.duration = 60 + layer.add(animation, forKey: keyPath) + } + + // then: the position's lookups only find the direct animation + expect(layer.basicAnimations(forKeyPath: "position").map(\.keyPath)) == ["position"] + expect(layer.propertyAnimations(forKeyPath: "position").map(\.keyPath)) == ["position"] + + // when: removing the position's animations + layer.removeAnimations(forKeyPath: "position") + + // then: the component's animation is left alone + expect(layer.animationKeys()) == ["position.x"] + } + + func test_animationSequence_noAnimationOfTheKeyPath_isNil() { + // given: a layer without animations, and a layer with an animation, a group and an empty group of other key paths, + // and a group with an animation without a key path + let layer = CALayer() + let spinningLayer = CALayer() + let spinAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + spinAnimation.duration = 60 + spinningLayer.add(spinAnimation, forKey: "spin") + spinningLayer.add(group(of: [CABasicAnimation(keyPath: "position")]), forKey: "group-move") + spinningLayer.add(group(of: nil), forKey: "empty-group") + spinningLayer.add(group(of: [CABasicAnimation()]), forKey: "group-no-key-path") + + // then: neither has opacity animations + expect(layer.animationSequence(forKeyPath: "opacity")).to(beNil()) + expect(spinningLayer.animationSequence(forKeyPath: "opacity")).to(beNil()) + } + + func test_animationSequence_doesntExtendTheLayersLifetime() { + // given: a layer + var layer: CALayer? = CALayer() + weak let weakLayer = layer + + autoreleasepool { + // when: looking up its animations, then letting the layer go before the enclosing autorelease pool drains + _ = layer?.animationSequence(forKeyPath: "opacity") + layer = nil + + // then: the layer is released right away + expect(weakLayer).to(beNil()) + } + } + func test_removeAnimations_forKeyPath() { // given: a layer with basic, keyframe, and different key path animations let layer = CALayer() @@ -836,12 +880,34 @@ class CALayer_AnimationsTests: XCTestCase { spinAnimation.duration = 60 layer.add(spinAnimation, forKey: "spin") + // a group animating the key path survives, since it may animate other key paths + layer.add(group(of: [CABasicAnimation(keyPath: "opacity")]), forKey: "group-fade") + // when: removing animations for the opacity key path layer.removeAnimations(forKeyPath: "opacity") - // then: both opacity animations are removed and the other key path animation survives + // then: both opacity animations are removed, and the other key path animation and the group survive expect(layer.animation(forKey: "fade")) == nil expect(layer.animation(forKey: "keyframe-fade")) == nil expect(layer.animation(forKey: "spin")) != nil + expect(layer.animation(forKey: "group-fade")) != nil + } + + // MARK: - Helpers + + private func group(of animations: [CAAnimation]?) -> CAAnimationGroup { + let group = CAAnimationGroup() + group.animations = animations + group.duration = 60 + return group + } + + private func kind(of animation: KeyPathAnimation) -> String { + switch animation { + case .direct: + return "direct" + case .indirect: + return "indirect" + } } } diff --git a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+KeyPathValueTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+KeyPathValueTests.swift index 703bac28..917d61d1 100644 --- a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+KeyPathValueTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+KeyPathValueTests.swift @@ -1006,6 +1006,181 @@ class CALayer_KeyPathValueTests: XCTestCase { } } + func test_modelValue_directReads() { + // given: a layer and a shape layer that count KVC reads, with a value for each property read directly + let layer = KVCCountingLayer() + let shapeLayer = KVCCountingShapeLayer() + let red = CGColor(red: 1, green: 0, blue: 0, alpha: 1) + let rect = CGPath(rect: CGRect(x: 0, y: 0, width: 10, height: 20), transform: nil) + layer.bounds.size = CGSize(width: 30, height: 40) + layer.position = CGPoint(x: 10, y: 20) + layer.cornerRadius = 3 + layer.borderWidth = 2 + layer.borderColor = red + layer.opacity = 0.5 + layer.shadowColor = red + layer.shadowOpacity = 0.25 + layer.shadowOffset = CGSize(width: 5, height: 6) + layer.shadowRadius = 4 + layer.shadowPath = rect + shapeLayer.path = rect + + // then: each value is the property's, in its own type, and a color that isn't set is nil + expect(layer.modelValue(forKeyPath: "bounds.size") as? CGSize) == CGSize(width: 30, height: 40) + expect(layer.modelValue(forKeyPath: "position") as? CGPoint) == CGPoint(x: 10, y: 20) + expect(layer.modelValue(forKeyPath: "backgroundColor")) == nil + expect(layer.modelValue(forKeyPath: "cornerRadius") as? CGFloat) == 3 + expect(layer.modelValue(forKeyPath: "borderWidth") as? CGFloat) == 2 + expect(layer.modelValue(forKeyPath: "borderColor") as AnyObject) === red + expect(layer.modelValue(forKeyPath: "opacity") as? Float) == 0.5 + expect(layer.modelValue(forKeyPath: "shadowColor") as AnyObject) === red + expect(layer.modelValue(forKeyPath: "shadowOpacity") as? Float) == 0.25 + expect(layer.modelValue(forKeyPath: "shadowOffset") as? CGSize) == CGSize(width: 5, height: 6) + expect(layer.modelValue(forKeyPath: "shadowRadius") as? CGFloat) == 4 + expect(layer.modelValue(forKeyPath: "shadowPath") as AnyObject) === layer.shadowPath + expect(shapeLayer.modelValue(forKeyPath: "path") as AnyObject) === shapeLayer.path + + // then: the values are read without KVC + expect(layer.kvcReadCount) == 0 + expect(shapeLayer.kvcReadCount) == 0 + } + + func test_modelValue_otherKeyPaths_readThroughKVC() { + // given: a layer that counts KVC reads, with an anchor point + let layer = KVCCountingLayer() + layer.anchorPoint = CGPoint(x: 0.25, y: 0.75) + + // then: the values of other key paths, and the `path` of a layer that isn't a shape layer, are read through KVC + expect(layer.modelValue(forKeyPath: "anchorPoint") as? CGPoint) == CGPoint(x: 0.25, y: 0.75) + expect(layer.modelValue(forKeyPath: "path")) == nil + expect(layer.kvcReadCount) == 2 + } + + func test_pointValue() { + // given: a layer that counts KVC reads, with a position and an anchor point + let layer = KVCCountingLayer() + layer.position = CGPoint(x: 10, y: 20) + layer.anchorPoint = CGPoint(x: 0.25, y: 0.75) + + // when: reading the position + let position = layer.pointValue(forKeyPath: "position") + + // then: the position is read directly + expect(position) == CGPoint(x: 10, y: 20) + expect(layer.kvcReadCount) == 0 + + // when: reading another point key path + let anchorPoint = layer.pointValue(forKeyPath: "anchorPoint") + + // then: the value is read through KVC + expect(anchorPoint) == CGPoint(x: 0.25, y: 0.75) + expect(layer.kvcReadCount) == 1 + } + + func test_sizeValue() { + // given: a layer that counts KVC reads, with a bounds size, a shadow offset, and a translation + let layer = KVCCountingLayer() + layer.bounds.size = CGSize(width: 30, height: 40) + layer.shadowOffset = CGSize(width: 5, height: 6) + layer.transform = CATransform3DMakeTranslation(7, 8, 0) + + // when: reading the bounds size and the shadow offset + let boundsSize = layer.sizeValue(forKeyPath: "bounds.size") + let shadowOffset = layer.sizeValue(forKeyPath: "shadowOffset") + + // then: they are read directly + expect(boundsSize) == CGSize(width: 30, height: 40) + expect(shadowOffset) == CGSize(width: 5, height: 6) + expect(layer.kvcReadCount) == 0 + + // when: reading another size key path + let translation = layer.sizeValue(forKeyPath: "transform.translation") + + // then: the value is read through KVC + expect(translation) == CGSize(width: 7, height: 8) + expect(layer.kvcReadCount) == 1 + } + + func test_floatingPointValue() { + // given: a layer that counts KVC reads, with non-default values for the numbers read directly, and a scale + let layer = KVCCountingLayer() + layer.opacity = 0.5 + layer.shadowOpacity = 0.25 + layer.borderWidth = 2 + layer.cornerRadius = 3 + layer.shadowRadius = 4 + layer.transform = CATransform3DMakeScale(2, 2, 2) + + // when: reading the numbers as their property types + let opacity: Float = layer.floatingPointValue(forKeyPath: "opacity") + let shadowOpacity: Float = layer.floatingPointValue(forKeyPath: "shadowOpacity") + let borderWidth: CGFloat = layer.floatingPointValue(forKeyPath: "borderWidth") + let cornerRadius: CGFloat = layer.floatingPointValue(forKeyPath: "cornerRadius") + let shadowRadius: CGFloat = layer.floatingPointValue(forKeyPath: "shadowRadius") + + // then: they are read directly + expect(opacity) == 0.5 + expect(shadowOpacity) == 0.25 + expect(borderWidth) == 2 + expect(cornerRadius) == 3 + expect(shadowRadius) == 4 + expect(layer.kvcReadCount) == 0 + + // when: reading the numbers as other floating-point types + let opacityAsCGFloat: CGFloat = layer.floatingPointValue(forKeyPath: "opacity") + let shadowOpacityAsCGFloat: CGFloat = layer.floatingPointValue(forKeyPath: "shadowOpacity") + let borderWidthAsFloat: Float = layer.floatingPointValue(forKeyPath: "borderWidth") + let cornerRadiusAsFloat: Float = layer.floatingPointValue(forKeyPath: "cornerRadius") + let shadowRadiusAsFloat: Float = layer.floatingPointValue(forKeyPath: "shadowRadius") + + // then: they are read through KVC, which converts the boxed numbers + expect(opacityAsCGFloat) == 0.5 + expect(shadowOpacityAsCGFloat) == 0.25 + expect(borderWidthAsFloat) == 2 + expect(cornerRadiusAsFloat) == 3 + expect(shadowRadiusAsFloat) == 4 + expect(layer.kvcReadCount) == 5 + + // when: reading another number key path + let scale: CGFloat = layer.floatingPointValue(forKeyPath: "transform.scale") + + // then: the value is read through KVC + expect(scale) == 2 + expect(layer.kvcReadCount) == 6 + } + + func test_keyPathValues_directReadsMatchKVC() { + // given: a plain layer and a view's backing layer, with non-default values for the properties read directly + let testWindow = TestWindow() + let view = View(frame: CGRect(x: 10, y: 20, width: 30, height: 40)) + #if canImport(AppKit) + view.wantsLayer = true + #endif + testWindow.contentView().addSubview(view) + + let plainLayer = CALayer() + plainLayer.frame = CGRect(x: 10, y: 20, width: 30, height: 40) + + for layer in [plainLayer, view.layer()] { + layer.opacity = 0.5 + layer.shadowOpacity = 0.25 + layer.borderWidth = 2 + layer.cornerRadius = 3 + layer.shadowRadius = 4 + layer.shadowOffset = CGSize(width: 5, height: 6) + + // then: the direct reads return what KVC returns + expect(layer.value(forKeyPath: "position") as? CGPoint) == layer.pointValue(forKeyPath: "position") + expect(layer.value(forKeyPath: "bounds.size") as? CGSize) == layer.sizeValue(forKeyPath: "bounds.size") + expect(layer.value(forKeyPath: "shadowOffset") as? CGSize) == layer.sizeValue(forKeyPath: "shadowOffset") + expect(layer.value(forKeyPath: "opacity") as? Float) == layer.floatingPointValue(forKeyPath: "opacity") as Float + expect(layer.value(forKeyPath: "shadowOpacity") as? Float) == layer.floatingPointValue(forKeyPath: "shadowOpacity") as Float + expect(layer.value(forKeyPath: "borderWidth") as? CGFloat) == layer.floatingPointValue(forKeyPath: "borderWidth") as CGFloat + expect(layer.value(forKeyPath: "cornerRadius") as? CGFloat) == layer.floatingPointValue(forKeyPath: "cornerRadius") as CGFloat + expect(layer.value(forKeyPath: "shadowRadius") as? CGFloat) == layer.floatingPointValue(forKeyPath: "shadowRadius") as CGFloat + } + } + func test_setKeyPathValue_disablesImplicitAnimations() { // given: a layer hosted in a window, committed so that its changes animate implicitly let testWindow = TestWindow() diff --git a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+RetargetTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+RetargetTests.swift index de18b90a..8a56c53d 100644 --- a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+RetargetTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+RetargetTests.swift @@ -41,7 +41,7 @@ class CALayer_RetargetTests: XCTestCase { func test_retarget_withoutInFlightAnimation_setsValue() { // given: a hosted layer with a background color and an animation of another key path let testWindow = TestWindow() - let layer = CALayer() + let layer = TimeConversionCountingLayer() layer.frame = CGRect(x: 0, y: 0, width: 50, height: 50) layer.backgroundColor = Color.red.cgColor testWindow.layer.addSublayer(layer) @@ -50,13 +50,16 @@ class CALayer_RetargetTests: XCTestCase { let spinAnimation = CABasicAnimation(keyPath: "transform.rotation.z") spinAnimation.duration = 60 layer.add(spinAnimation, forKey: "spin") + let timeConversionCount = layer.timeConversionCount // when: retargeting the background color to a new color layer.retarget(keyPath: "backgroundColor", to: Color.blue.cgColor) - // then: the color is set without an animation, implicit or otherwise, and the other animation is left alone + // then: the color is set without an animation, implicit or otherwise, the other animation is left alone, and the + // layer's current time isn't read expect(layer.backgroundColor) == Color.blue.cgColor expect(layer.animationKeys()) == ["spin"] + expect(layer.timeConversionCount) == timeConversionCount } func test_retarget_endedAnimation_isSkipped() { @@ -474,7 +477,7 @@ class CALayer_RetargetTests: XCTestCase { expect(layer.presentation()).toEventuallyNot(beNil()) func shownBlue() throws -> CGFloat { - try blueComponent(of: layer.presentation().unwrap().backgroundColor.unwrap()) + try layer.presentation().unwrap().backgroundColor.unwrap().sRGBComponents()[2] } layer.animate( @@ -495,7 +498,7 @@ class CALayer_RetargetTests: XCTestCase { // then: the retargeting animation starts from the shown color and lands when the interrupted one would have let animation = try (layer.animation(forKey: "backgroundColor") as? CABasicAnimation).unwrap() // a Core Foundation type can't be checked at runtime, so the cast is forced - expect(try blueComponent(of: animation.fromValue as! CGColor)).to(beApproximatelyEqual(to: blueBeforeRetarget, within: 0.1)) // swiftlint:disable:this force_cast + expect(try colorValue(animation.fromValue).sRGBComponents()[2]).to(beApproximatelyEqual(to: blueBeforeRetarget, within: 0.1)) expect(animation.duration).to(beApproximatelyEqual(to: interruptedBeginTime + 0.5 - retargetTime, within: 0.02)) expect(layer.backgroundColor) == Color.red.cgColor @@ -523,7 +526,8 @@ class CALayer_RetargetTests: XCTestCase { func shownColor() throws -> (blue: CGFloat, green: CGFloat) { let color = try layer.presentation().unwrap().backgroundColor.unwrap() - return try (blue: blueComponent(of: color), green: greenComponent(of: color)) + let components = try color.sRGBComponents() + return (blue: components[2], green: components[1]) } let toBlue = CABasicAnimation(keyPath: "backgroundColor") @@ -553,15 +557,177 @@ class CALayer_RetargetTests: XCTestCase { // landing when the longer one would have expect(layer.animationKeys()) == ["backgroundColor"] let animation = try (layer.animation(forKey: "backgroundColor") as? CABasicAnimation).unwrap() - // a Core Foundation type can't be checked at runtime, so the cast is forced - let fromColor = animation.fromValue as! CGColor // swiftlint:disable:this force_cast - expect(try greenComponent(of: fromColor)).to(beApproximatelyEqual(to: shownBeforeRetarget.green, within: 0.1)) - expect(try blueComponent(of: fromColor)) < 0.1 + let fromComponents = try colorValue(animation.fromValue).sRGBComponents() + expect(fromComponents[1]).to(beApproximatelyEqual(to: shownBeforeRetarget.green, within: 0.1)) + expect(fromComponents[2]) < 0.1 expect(animation.duration).to(beApproximatelyEqual(to: toBlueBeginTime + 1 - retargetTime, within: 0.02)) expect(animation.toValue as! CGColor) == Color.yellow.cgColor // swiftlint:disable:this force_cast expect(layer.backgroundColor) == Color.yellow.cgColor } + func test_retarget_replacingAnimation_startsFromTheValueAtTheClocksTime() throws { + // given: a layer whose background color animates from red to blue, and shadow opacity from 0 to 1, over two seconds + // from 1000 + let layer = CALayer() + let red = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + let blue = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + AnimationClock.sharingTime(at: 1000) { + layer.animate(keyPath: "backgroundColor", timing: .linear(duration: 2), from: { _ in red }, to: { _ in blue }) + layer.animate(keyPath: "shadowOpacity", timing: .linear(duration: 2), from: { _ in Float(0) }, to: { _ in Float(1) }) + } + + // when: retargeting both a quarter of the way, where the layer has no presentation layer to read + AnimationClock.sharingTime(at: 1000.5) { + layer.retarget(keyPath: "backgroundColor", to: Color.green.cgColor) + layer.retarget(keyPath: "shadowOpacity", to: Float(0.5)) + } + + // then: the replacing animations begin at the clock's time and start from what the layer shows then + let colorAnimation = try (layer.animation(forKey: "backgroundColor") as? CABasicAnimation).unwrap() + expect(colorAnimation.beginTime) == 1000.5 + try expectExtendedSRGBComponents(of: colorValue(colorAnimation.fromValue), toBe: [0.75, 0, 0.25, 1]) + let opacityAnimation = try (layer.animation(forKey: "shadowOpacity") as? CABasicAnimation).unwrap() + expect(opacityAnimation.beginTime) == 1000.5 + expect(opacityAnimation.fromValue as? Float) == 0.25 + } + + func test_retarget_hosted_startsFromTheValueAtTheClocksTime() throws { + // given: a hosted layer whose background color animates from red to blue over two seconds, and shows it + let testWindow = TestWindow() + let layer = CALayer() + layer.frame = CGRect(x: 0, y: 0, width: 50, height: 50) + let red = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + let blue = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + layer.backgroundColor = red + testWindow.layer.addSublayer(layer) + CATransaction.flush() + expect(layer.presentation()).toEventuallyNot(beNil()) + layer.animate(keyPath: "backgroundColor", timing: .linear(duration: 2), from: { _ in red }, to: { _ in blue }) + let beginTime = try layer.animation(forKey: "backgroundColor").unwrap().beginTime + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + expect(layer.presentation()?.backgroundColor) != red + + // when: retargeting with the clock half a second into the animation, while Core Animation evaluates the + // presentation layer at its own time + AnimationClock.sharingTime(at: layer.convertTime(beginTime + 0.5, to: nil)) { + layer.retarget(keyPath: "backgroundColor", to: Color.green.cgColor) + } + + // then: the replacing animation starts from the color at the clock's time, a quarter of the way, instead of the + // color the presentation layer shows + let animation = try (layer.animation(forKey: "backgroundColor") as? CABasicAnimation).unwrap() + try expectExtendedSRGBComponents(of: colorValue(animation.fromValue), toBe: [0.75, 0, 0.25, 1]) + } + + func test_retarget_valueFunctionAnimationInFlight_startsFromTheShownTransform() throws { + // given: a hosted layer spinning with a rotation that a value function turns into its transform, and shows it + let testWindow = TestWindow() + let layer = CALayer() + layer.frame = CGRect(x: 0, y: 0, width: 50, height: 50) + testWindow.layer.addSublayer(layer) + CATransaction.flush() + expect(layer.presentation()).toEventuallyNot(beNil()) + let spinAnimation = CABasicAnimation(keyPath: "transform") + spinAnimation.valueFunction = CAValueFunction(name: .rotateZ) + spinAnimation.fromValue = 0.0 + spinAnimation.toValue = Double.pi + spinAnimation.duration = 60 + layer.add(spinAnimation, forKey: "spin") + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + let shownTransform = try (layer.presentation()?.value(forKeyPath: "transform") as? NSValue).unwrap().caTransform3DValue + expect(CATransform3DIsIdentity(shownTransform)) == false + + // when: retargeting the transform to a scale + layer.retarget(keyPath: "transform", to: CATransform3DMakeScale(2, 2, 1)) + + // then: the spin is replaced by an animation from the transform the layer shows, read from the presentation layer + // since the value function isn't evaluated + expect(layer.animation(forKey: "spin")) == nil + let animation = try (layer.animation(forKey: "transform") as? CABasicAnimation).unwrap() + let fromTransform = try (animation.fromValue as? NSValue).unwrap().caTransform3DValue + expect(CATransform3DEqualToTransform(fromTransform, shownTransform)) == true + + // when: committing the replacement + CATransaction.flush() + + // then: Core Animation evaluates it into the transform the layer shows, still rotated as it starts + let replacementTransform = try (layer.presentation()?.value(forKeyPath: "transform") as? NSValue).unwrap().caTransform3DValue + expect(CATransform3DIsIdentity(replacementTransform)) == false + } + + func test_retarget_groupAnimatingTheKeyPath_startsFromTheShownValueAndKeepsTheGroup() throws { + // given: a hosted red layer whose background color animates to blue, and a group added later that animates it from + // green, so the group's color shows + let testWindow = TestWindow() + let layer = CALayer() + layer.frame = CGRect(x: 0, y: 0, width: 50, height: 50) + let red = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + let green = CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) + let blue = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + layer.backgroundColor = red + testWindow.layer.addSublayer(layer) + CATransaction.flush() + expect(layer.presentation()).toEventuallyNot(beNil()) + layer.animate(keyPath: "backgroundColor", timing: .linear(duration: 60), from: { _ in red }, to: { _ in blue }) + layer.add(colorGroup(from: green, to: blue), forKey: "tint") + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + let shownColor = try layer.presentation().unwrap().backgroundColor.unwrap() + + // when: retargeting the background color to yellow + layer.retarget(keyPath: "backgroundColor", to: CGColor(srgbRed: 1, green: 1, blue: 0, alpha: 1)) + + // then: the first animation is replaced by one from the color the layer shows, read from the presentation layer + // since the group isn't evaluated, and the group keeps running + let animation = try (layer.animation(forKey: "backgroundColor") as? CABasicAnimation).unwrap() + expect(try colorValue(animation.fromValue)) == shownColor + expect(layer.animation(forKey: "tint")) != nil + } + + func test_retarget_onlyAGroupAnimatingTheKeyPath_setsTheValueAndKeepsTheGroup() { + // given: a red layer whose background color only a group animates + let layer = CALayer() + layer.backgroundColor = Color.red.cgColor + layer.add(colorGroup(from: Color.green.cgColor, to: Color.blue.cgColor), forKey: "tint") + + // when: retargeting the background color to blue + layer.retarget(keyPath: "backgroundColor", to: Color.blue.cgColor) + + // then: the color is set directly, as the group isn't an in-flight animation to retarget, and the group keeps running + expect(layer.backgroundColor) == Color.blue.cgColor + expect(layer.animationKeys()) == ["tint"] + } + + func test_retarget_relatedKeyPathAnimating_startsFromTheShownValueAndKeepsItsAnimation() throws { + // given: a hosted layer whose position animates, and whose x component animates too, added later so it shows + let testWindow = TestWindow() + let layer = CALayer() + layer.frame = CGRect(x: 0, y: 0, width: 50, height: 50) + testWindow.layer.addSublayer(layer) + CATransaction.flush() + expect(layer.presentation()).toEventuallyNot(beNil()) + let moveAnimation = CABasicAnimation(keyPath: "position") + moveAnimation.fromValue = CGPoint(x: 0, y: 0) + moveAnimation.toValue = CGPoint(x: 100, y: 100) + moveAnimation.duration = 60 + layer.add(moveAnimation, forKey: "position") + let moveXAnimation = CABasicAnimation(keyPath: "position.x") + moveXAnimation.fromValue = 300.0 + moveXAnimation.toValue = 400.0 + moveXAnimation.duration = 60 + layer.add(moveXAnimation, forKey: "move-x") + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + let shownPosition = try layer.presentation().unwrap().position + + // when: retargeting the position + layer.retarget(keyPath: "position", to: CGPoint(x: 200, y: 200)) + + // then: the position's animation is replaced by one from the position the layer shows, read from the presentation + // layer since the component's animation isn't evaluated, and the component's animation keeps running + let animation = try (layer.animation(forKey: "position") as? CABasicAnimation).unwrap() + expect(animation.fromValue as? CGPoint) == shownPosition + expect(layer.animation(forKey: "move-x")) != nil + } + // MARK: - Retarget, additive animations in flight func test_retarget_additiveAnimationInFlight_number_foldsIntoOneGlideFromTheShownValue() throws { @@ -1141,19 +1307,18 @@ class CALayer_RetargetTests: XCTestCase { expect(landed) == true } - /// The blue component of the color in the sRGB color space. - private func blueComponent(of color: CGColor) throws -> CGFloat { - try sRGBComponents(of: color)[2] - } - - /// The green component of the color in the sRGB color space. - private func greenComponent(of color: CGColor) throws -> CGFloat { - try sRGBComponents(of: color)[1] - } - - private func sRGBComponents(of color: CGColor) throws -> [CGFloat] { - let sRGB = try CGColorSpace(name: CGColorSpace.sRGB).unwrap() - return try color.converted(to: sRGB, intent: .defaultIntent, options: nil).unwrap().components.unwrap() + // MARK: - Helpers + + /// A group that animates the background color from a color to a color over 60 seconds. + private func colorGroup(from: CGColor, to: CGColor) -> CAAnimationGroup { + let tint = CABasicAnimation(keyPath: "backgroundColor") + tint.fromValue = from + tint.toValue = to + tint.duration = 60 + let group = CAAnimationGroup() + group.animations = [tint] + group.duration = 60 + return group } } diff --git a/ComposeUI/Tests/ComposeUITests/Animations/CALayer+ShownValueTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+ShownValueTests.swift new file mode 100644 index 00000000..31b8bbce --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/Animations/CALayer+ShownValueTests.swift @@ -0,0 +1,648 @@ +// +// CALayer+ShownValueTests.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 CALayer_ShownValueTests: XCTestCase { + + private let red = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + private let blue = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + + // MARK: - Model Value + + func test_shownValue_nothingAnimating_isTheModelValue() throws { + // given: a layer with model values and nothing animating + let layer = CALayer() + layer.opacity = 0.3 + layer.cornerRadius = 5 + layer.backgroundColor = red + layer.shadowPath = CGPath(rect: CGRect(x: 0, y: 0, width: 10, height: 10), transform: nil) + layer.zPosition = 2 + + // then: each key path shows its model value, and a layer without a background color shows none + expect(layer.shownValue(forKeyPath: "opacity") as? Float) == 0.3 + expect(layer.shownValue(forKeyPath: "cornerRadius") as? CGFloat) == 5 + expect(layer.shownValue(forKeyPath: "backgroundColor") as AnyObject) === red + expect(layer.shownValue(forKeyPath: "shadowPath") as AnyObject) === layer.shadowPath + expect(layer.shownValue(forKeyPath: "zPosition") as? CGFloat) == 2 + expect(CALayer().shownValue(forKeyPath: "backgroundColor")) == nil + } + + func test_shownValue_otherKeyPathsAnimating_isTheModelValue() { + // given: a red layer whose position animates, directly and in a group + let layer = TimeConversionCountingLayer() + layer.backgroundColor = red + layer.add(animation(keyPath: "position", from: CGPoint(x: 0, y: 0), to: CGPoint(x: 10, y: 10)), forKey: "position") + layer.add(group(of: [CABasicAnimation(keyPath: "position")]), forKey: "group-move") + + // when: reading the background color + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "backgroundColor") + } + + // then: it's the model color, read without converting the clock's time to the layer's + expect(shown as AnyObject) === red + expect(layer.timeConversionCount) == 0 + } + + func test_shownValue_readsTheClocksTime() throws { + // given: a layer whose border width animates from 0 to 20 over two seconds, from 1000 + let layer = TimeConversionCountingLayer() + layer.add(animation(keyPath: "borderWidth", from: 0.0, to: 20.0), forKey: "borderWidth") + + // when: reading the border width with the clock at 1000.5 + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "borderWidth") + } + + // then: it's a quarter of the way, with the clock's time converted to the layer's once + expect(shown as? Double) == 5 + expect(layer.timeConversionCount) == 1 + } + + // MARK: - Clamped Numbers + + func test_shownValue_opacity_isTheModelsType() throws { + for keyPath in ["opacity", "shadowOpacity"] { + // given: a layer whose opacity animates from 0.2 to 1 over two seconds, from 1000 + let layer = CALayer() + layer.add(animation(keyPath: keyPath, from: 0.2, to: 1.0), forKey: keyPath) + + // when: reading the opacity at 1000.5 + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: keyPath) + } + + // then: it's a quarter of the way, as the property's Float + expect(try unwrap(shown as? Float, keyPath)).to(beApproximatelyEqual(to: 0.4, within: 1e-6)) + } + } + + func test_shownValue_opacity_clampsAfterEachAnimation() throws { + // given: a layer at 0.5 with two additive opacity animations, from 0.8 and from -0.3 to 0, over two seconds + let layer = CALayer() + layer.opacity = 0.5 + layer.add(animation(keyPath: "opacity", from: 0.8, to: 0.0, isAdditive: true), forKey: "opacity") + layer.add(animation(keyPath: "opacity", from: -0.3, to: 0.0, isAdditive: true), forKey: "opacity-1") + + // when: reading the opacity a quarter of the way + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "opacity") + } + + // then: the first animation takes it to 1.1, clamped to 1 as the render server does, and the second to 0.775, + // instead of the unclamped sum 0.875 + expect(try unwrap(shown as? Float)).to(beApproximatelyEqual(to: 0.775, within: 1e-6)) + } + + func test_shownValue_cornerRadius_clampsAtZeroAfterEachAnimation() throws { + // given: a layer with a corner radius of 2 and two additive animations, from -4 and from 2 to 0, beginning at 1000.5 + let layer = CALayer() + layer.cornerRadius = 2 + layer.add(animation(keyPath: "cornerRadius", from: -4.0, to: 0.0, beginTime: 1000.5, isAdditive: true), forKey: "cornerRadius") + layer.add(animation(keyPath: "cornerRadius", from: 2.0, to: 0.0, beginTime: 1000.5, isAdditive: true), forKey: "cornerRadius-1") + + // when: reading the corner radius as they begin + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "cornerRadius") + } + + // then: the first animation takes it to -2, clamped to 0 as the render server does, and the second to 2, as the + // property's CGFloat, instead of the unclamped sum 0 + expect(shown as? CGFloat) == 2 + } + + func test_shownValue_clampedNumber_animationNotShowing_isTheModelValue() throws { + // given: a layer at 0.3 whose opacity animation to 1 begins at 1001, without a backwards fill + let layer = CALayer() + layer.opacity = 0.3 + let opacityAnimation = animation(keyPath: "opacity", from: 0.0, to: 1.0, beginTime: 1001) + opacityAnimation.fillMode = .removed + layer.add(opacityAnimation, forKey: "opacity") + + // when: reading the opacity at 1000.5 + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "opacity") + } + + // then: the animation doesn't show yet, so it's the model opacity + expect(shown as? Float) == 0.3 + } + + // MARK: - Values + + func test_shownValue_color_interpolates() throws { + // given: a layer whose background color animates from red to blue over two seconds, from 1000 + let layer = CALayer() + layer.add(animation(keyPath: "backgroundColor", from: red, to: blue), forKey: "backgroundColor") + + // when: reading the background color a quarter of the way + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "backgroundColor") + } + + // then: it's a quarter of the way, see `AnimationInterpolation` + try expectExtendedSRGBComponents(of: colorValue(shown), toBe: [0.75, 0, 0.25, 1]) + } + + func test_shownValue_additiveAndNonAdditiveAnimations_composeInOrder() throws { + // given: a layer at (100, 100) whose position animates additively from (-40, 20) to (0, 0), and a layer whose + // shadow radius animates additively from 4 to 0 under a non-additive animation from 2 to 10, over two seconds + let pointLayer = CALayer() + pointLayer.position = CGPoint(x: 100, y: 100) + pointLayer.add(animation(keyPath: "position", from: CGPoint(x: -40, y: 20), to: CGPoint.zero, isAdditive: true), forKey: "position") + let radiusLayer = CALayer() + radiusLayer.shadowRadius = 10 + radiusLayer.add(animation(keyPath: "shadowRadius", from: 4.0, to: 0.0, isAdditive: true), forKey: "shadowRadius") + radiusLayer.add(animation(keyPath: "shadowRadius", from: 2.0, to: 10.0), forKey: "shadowRadius-1") + + // when: reading them a quarter of the way + let shown = AnimationClock.sharingTime(at: 1000.5) { + (pointLayer.shownValue(forKeyPath: "position"), radiusLayer.shownValue(forKeyPath: "shadowRadius")) + } + + // then: the additive animation adds to the model value, and the non-additive one replaces what's below it + expect(shown.0 as? CGPoint) == CGPoint(x: 70, y: 115) + expect(shown.1 as? Double) == 4 + } + + func test_shownValue_animationBeginningNow_isItsFromValue() throws { + // given: a layer whose background color animation from red to blue begins at 1000.5 + let layer = CALayer() + layer.add(animation(keyPath: "backgroundColor", from: red, to: blue, beginTime: 1000.5), forKey: "backgroundColor") + + // when: reading the background color at 1000.5 + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "backgroundColor") + } + + // then: it's the from color itself + expect(shown as AnyObject) === red + } + + func test_shownValue_endedAnimation_showsItsToValueOnlyWhenKeptWithAForwardsFill() throws { + let cases: [(name: String, isRemovedOnCompletion: Bool, fillMode: CAMediaTimingFillMode, shown: CGColor)] = [ + ("removed on completion", true, .both, red), + ("kept with a forwards fill", false, .forwards, blue), + ("kept with a backwards fill", false, .backwards, red), + ] + for testCase in cases { + // given: a red layer whose background color animation to blue ended at 999 + let layer = CALayer() + layer.backgroundColor = red + let colorAnimation = animation(keyPath: "backgroundColor", from: CGColor(gray: 0, alpha: 1), to: blue, beginTime: 997) + colorAnimation.isRemovedOnCompletion = testCase.isRemovedOnCompletion + colorAnimation.fillMode = testCase.fillMode + layer.add(colorAnimation, forKey: "backgroundColor") + + // when: reading the background color at 1000.5 + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "backgroundColor") + } + + // then: a kept animation with a forwards fill shows its to color itself, and the others the model color + expect(shown as AnyObject, testCase.name) === testCase.shown + } + } + + func test_shownValue_animationWithoutADuration_evaluatesWithTheDurationCoreAnimationGivesIt() throws { + let cases: [(name: String, transactionDuration: TimeInterval?, shown: Double)] = [ + ("the default duration", nil, 5), + ("the transaction's animation duration", 1, 1.25), + ("a zero transaction duration, which drops the animation", 0, 20), + ] + for testCase in cases { + // given: a layer with a border width of 20, and a linear animation of it from 0 to 10 from 1000 without a + // duration, added in a fresh transaction, since one an earlier test left open could carry its own duration + CATransaction.flush() + let layer = CALayer() + layer.borderWidth = 20 + let borderAnimation = CABasicAnimation(keyPath: "borderWidth") + borderAnimation.fromValue = 0.0 + borderAnimation.toValue = 10.0 + borderAnimation.beginTime = 1000 + CATransaction.begin() + if let duration = testCase.transactionDuration { + CATransaction.setAnimationDuration(duration) + } + layer.add(borderAnimation, forKey: "borderWidth") + CATransaction.commit() + + // when: reading the border width at 1000.125 + let shown = AnimationClock.sharingTime(at: 1000.125) { + layer.shownValue(forKeyPath: "borderWidth") + } + + // then: Core Animation gave the animation the transaction's animation duration, 0.25 s by default, which it's + // evaluated with instead of being skipped as ended, and a zero one drops the animation, leaving the model value + expect(try unwrap((shown as? NSNumber)?.doubleValue, testCase.name), testCase.name) == testCase.shown + } + } + + func test_shownValue_scheduledAnimation_showsItsFromValueOnlyWithABackwardsFill() throws { + let cases: [(fillMode: CAMediaTimingFillMode, shown: CGColor)] = [ + (.removed, red), + (.backwards, CGColor(gray: 0, alpha: 1)), + ] + for testCase in cases { + // given: a red layer whose background color animation to blue begins at 1001 + let layer = CALayer() + layer.backgroundColor = red + let colorAnimation = animation(keyPath: "backgroundColor", from: testCase.shown, to: blue, beginTime: 1001) + colorAnimation.fillMode = testCase.fillMode + layer.add(colorAnimation, forKey: "backgroundColor") + + // when: reading the background color at 1000.5 + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: "backgroundColor") + } + + // then: with a backwards fill it's the from color, and without one the model color + expect(shown as AnyObject, "\(testCase.fillMode)") === testCase.shown + } + } + + func test_shownValue_animationNotShowing_isSkippedWhateverItsValuesOrTiming() throws { + for keyPath in ["borderWidth", "opacity"] { + let laterToOnlyAnimation = animation(keyPath: keyPath, from: nil, to: 1.0, beginTime: 1001) + laterToOnlyAnimation.fillMode = .removed + let endedToOnlyAnimation = animation(keyPath: keyPath, from: nil, to: 1.0, beginTime: 997) + let laterKeyframeAnimation = CAKeyframeAnimation(keyPath: keyPath) + laterKeyframeAnimation.values = [0.0, 1.0] + laterKeyframeAnimation.beginTime = 1001 + laterKeyframeAnimation.duration = 2 + func laterAnimation(_ configure: (CABasicAnimation) -> Void) -> CABasicAnimation { + let animation = animation(keyPath: keyPath, from: 0.0, to: 1.0, beginTime: 1001) + animation.fillMode = .removed + configure(animation) + return animation + } + + let cases: [(name: String, animation: CAAnimation)] = [ + ("a to-only animation that begins later", laterToOnlyAnimation), + ("a to-only animation that has ended", endedToOnlyAnimation), + ("a keyframe animation that begins later", laterKeyframeAnimation), + ("a repeating animation that begins later", laterAnimation { $0.repeatCount = 2 }), + ("an animation repeating for a duration that begins later", laterAnimation { $0.repeatDuration = 4 }), + ("an autoreversing animation that begins later", laterAnimation { $0.autoreverses = true }), + ("an animation with a time offset that begins later", laterAnimation { $0.timeOffset = 0.5 }), + ("a reversed animation that begins later", laterAnimation { $0.speed = -1 }), + ("a paused animation that begins later", laterAnimation { $0.speed = 0 }), + ] + for testCase in cases { + // given: a layer at 0.25, raised by an additive animation of 0.5 over two seconds from 1000, with an animation + // that can't be evaluated and doesn't show at 1000.5, as it begins later without a backwards fill or has ended + let layer = CALayer() + layer.borderWidth = 0.25 + layer.opacity = 0.25 + layer.add(animation(keyPath: keyPath, from: 0.0, to: 0.5, isAdditive: true), forKey: "raise") + layer.add(testCase.animation, forKey: "unevaluable") + + // when: reading the key path at 1000.5 + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: keyPath) + } + + // then: the animation that doesn't show is skipped, so the model value is raised by a quarter of 0.5 + let description = "\(testCase.name) of \(keyPath)" + expect(try unwrap((shown as? NSNumber)?.doubleValue, description), description) == 0.375 + } + } + } + + // MARK: - Typed Values + + func test_shownColorPathAndOpacity() throws { + // given: a layer whose background color, shadow path and shadow opacity animate over two seconds, from 1000 + let layer = CALayer() + let square = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) + layer.add(animation(keyPath: "backgroundColor", from: red, to: blue), forKey: "backgroundColor") + layer.add(animation(keyPath: "shadowPath", from: square, to: CGPath(rect: CGRect(x: 0, y: 0, width: 200, height: 100), transform: nil)), forKey: "shadowPath") + layer.add(animation(keyPath: "shadowOpacity", from: 0.0, to: 1.0), forKey: "shadowOpacity") + + // when: reading them a quarter of the way + let shown = AnimationClock.sharingTime(at: 1000.5) { + (color: layer.shownColor(forKeyPath: "backgroundColor"), path: layer.shownPath(forKeyPath: "shadowPath"), opacity: layer.shownOpacity(forKeyPath: "shadowOpacity")) + } + + // then: they're the values of their types a quarter of the way + try expectExtendedSRGBComponents(of: unwrap(shown.color), toBe: [0.75, 0, 0.25, 1]) + expect(try PathPoints(unwrap(shown.path))) == PathPoints(CGPath(rect: CGRect(x: 0, y: 0, width: 125, height: 100), transform: nil)) + expect(shown.opacity) == 0.25 + + // then: a key path without a value, or with a value of another type, has none of the type + expect(CALayer().shownColor(forKeyPath: "backgroundColor")) == nil + expect(layer.shownColor(forKeyPath: "opacity")) == nil + expect(layer.shownPath(forKeyPath: "backgroundColor")) == nil + expect(layer.shownOpacity(forKeyPath: "backgroundColor")) == nil + } + + // MARK: - Unevaluable Animations + + func test_shownValue_unevaluableAnimation_withoutPresentation_isNil() throws { + var callbacks = CGPatternCallbacks(version: 0, drawPattern: { _, _ in }, releaseInfo: nil) + let pattern = try CGPattern(info: nil, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), matrix: .identity, xStep: 1, yStep: 1, tiling: .noDistortion, isColored: true, callbacks: &callbacks).unwrap() + var alpha: CGFloat = 1 + let patternColor = try CGColor(patternSpace: CGColorSpace(patternBaseSpace: nil).unwrap(), pattern: pattern, components: &alpha).unwrap() + let square = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) + let ellipse = CGPath(ellipseIn: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) + let keyframeAnimation = CAKeyframeAnimation(keyPath: "backgroundColor") + keyframeAnimation.values = [red, blue] + keyframeAnimation.beginTime = 1000 + keyframeAnimation.duration = 2 + let byAnimation = animation(keyPath: "backgroundColor", from: red, to: blue) + byAnimation.byValue = blue + let repeatingAnimation = animation(keyPath: "backgroundColor", from: red, to: blue) + repeatingAnimation.repeatCount = 2 + let laterRepeatingAnimation = animation(keyPath: "backgroundColor", from: red, to: blue, beginTime: 1001) + laterRepeatingAnimation.repeatCount = 2 + let reversedAnimation = animation(keyPath: "backgroundColor", from: red, to: blue) + reversedAnimation.speed = -1 + let reversedOpacityAnimation = animation(keyPath: "opacity", from: 0.0, to: 1.0) + reversedOpacityAnimation.speed = -1 + let opacityKeyframeAnimation = CAKeyframeAnimation(keyPath: "opacity") + opacityKeyframeAnimation.values = [0, 1] + opacityKeyframeAnimation.beginTime = 1000 + opacityKeyframeAnimation.duration = 2 + let opacityByAnimation = animation(keyPath: "opacity", from: 0.0, to: 1.0) + opacityByAnimation.byValue = 1.0 + let repeatingOpacityAnimation = animation(keyPath: "opacity", from: 0.0, to: 1.0) + repeatingOpacityAnimation.repeatCount = 2 + let rotation = animation(keyPath: "transform", from: 0.0, to: Double.pi) + rotation.valueFunction = CAValueFunction(name: .rotateZ) + + let cases: [(name: String, keyPath: String, animation: CAAnimation)] = [ + ("a keyframe animation", "backgroundColor", keyframeAnimation), + ("a by value", "backgroundColor", byAnimation), + ("an unresolved from value", "backgroundColor", animation(keyPath: "backgroundColor", from: NSNull(), to: blue)), + ("an unresolved to value", "backgroundColor", animation(keyPath: "backgroundColor", from: red, to: NSNull())), + ("no to value", "backgroundColor", animation(keyPath: "backgroundColor", from: red, to: nil)), + ("a repeat", "backgroundColor", repeatingAnimation), + ("a repeat that begins later with a backwards fill", "backgroundColor", laterRepeatingAnimation), + ("a negative speed", "backgroundColor", reversedAnimation), + ("an opacity at a negative speed", "opacity", reversedOpacityAnimation), + ("values of other kinds", "shadowOffset", animation(keyPath: "shadowOffset", from: 1.0, to: CGSize(width: 8, height: 4))), + ("a pattern color", "backgroundColor", animation(keyPath: "backgroundColor", from: red, to: patternColor)), + ("paths of other segments", "shadowPath", animation(keyPath: "shadowPath", from: square, to: ellipse)), + ("an additive color", "backgroundColor", animation(keyPath: "backgroundColor", from: red, to: blue, isAdditive: true)), + ("an additive number over a size", "shadowOffset", animation(keyPath: "shadowOffset", from: 1.0, to: 0.0, isAdditive: true)), + ("an opacity keyframe animation", "opacity", opacityKeyframeAnimation), + ("an opacity by value", "opacity", opacityByAnimation), + ("an opacity from a color", "opacity", animation(keyPath: "opacity", from: red, to: 1.0)), + ("an opacity to a color", "opacity", animation(keyPath: "opacity", from: 0.0, to: red)), + ("a repeating opacity", "opacity", repeatingOpacityAnimation), + ("a value function", "transform", rotation), + ("a group", "backgroundColor", group(of: [CABasicAnimation(keyPath: "backgroundColor")])), + ("a group in a group", "backgroundColor", group(of: [group(of: [CABasicAnimation(keyPath: "backgroundColor")])])), + ("an opacity group", "opacity", group(of: [CABasicAnimation(keyPath: "opacity")])), + ("a component key path", "position", animation(keyPath: "position.x", from: 0.0, to: 10.0)), + ("a parent key path", "bounds.size", animation(keyPath: "bounds", from: CGRect(x: 0, y: 0, width: 10, height: 10), to: CGRect(x: 0, y: 0, width: 20, height: 20))), + ("a group of a component key path", "position", group(of: [CABasicAnimation(keyPath: "position.x")])), + ] + for testCase in cases { + // given: a red layer without a presentation layer, with an animation that can't be evaluated + let layer = CALayer() + layer.backgroundColor = red + layer.add(testCase.animation, forKey: "unevaluable") + + // when: reading the key path at 1000.5 + let shown = AnimationClock.sharingTime(at: 1000.5) { + layer.shownValue(forKeyPath: testCase.keyPath) + } + + // then: it's the presentation value, which the layer doesn't have + expect(shown, testCase.name) == nil + } + } + + func test_shownValue_unevaluableAnimation_isThePresentationValue() throws { + // given: a hosted red layer with a keyframe animation of its background color that holds green + let testWindow = TestWindow() + let layer = CALayer() + layer.frame = CGRect(x: 0, y: 0, width: 10, height: 10) + layer.backgroundColor = red + testWindow.layer.addSublayer(layer) + let green = CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) + let keyframeAnimation = CAKeyframeAnimation(keyPath: "backgroundColor") + keyframeAnimation.values = [green, green] + keyframeAnimation.duration = 100 + layer.add(keyframeAnimation, forKey: "keyframes") + CATransaction.flush() + expect(layer.presentation()).toEventuallyNot(beNil()) + + // when: reading the background color + let shown = layer.shownValue(forKeyPath: "backgroundColor") + + // then: the animation can't be evaluated, so it's the color the presentation layer shows + try expectExtendedSRGBComponents(of: colorValue(shown), toBe: [0, 1, 0, 1]) + } + + // MARK: - Core Animation + + func test_shownValue_matchesThePresentationLayer() throws { + // given: layers on a paused timeline, with stacks of animations from 100 over 10 s, of values the render server + // doesn't clamp, so the presentation layer reports what shows + let testWindow = TestWindow() + let root = CALayer() + root.speed = 0 + root.timeOffset = 100 + testWindow.layer.addSublayer(root) + CATransaction.flush() + + func basicAnimation(_ keyPath: String, from: Any, to: Any, beginTime: TimeInterval = 100, isAdditive: Bool = false, timingFunction: CAMediaTimingFunction? = nil) -> CABasicAnimation { + let animation = CABasicAnimation(keyPath: keyPath) + animation.fromValue = from + animation.toValue = to + animation.beginTime = beginTime + animation.duration = 10 + animation.timingFunction = timingFunction + animation.fillMode = .both + animation.isAdditive = isAdditive + return animation + } + + // a slow, bouncy spring, still well away from landing at the times compared + let spring = CASpringAnimation(keyPath: "position") + spring.fromValue = CGPoint(x: -40, y: 20) + spring.toValue = CGPoint.zero + spring.isAdditive = true + spring.stiffness = 10 + spring.damping = 1 + spring.beginTime = 100 + spring.duration = 10 + spring.fillMode = .both + + let cases: [(keyPath: String, model: Any, animations: [CABasicAnimation])] = [ + ("backgroundColor", blue, [basicAnimation("backgroundColor", from: red, to: blue, timingFunction: CAMediaTimingFunction(name: .easeInEaseOut))]), + ("position", CGPoint(x: 50, y: 50), [spring]), + ("borderWidth", 4.0, [basicAnimation("borderWidth", from: 2.0, to: 0.0, isAdditive: true), basicAnimation("borderWidth", from: -1.0, to: 0.0, isAdditive: true)]), + ("shadowRadius", 10.0, [basicAnimation("shadowRadius", from: 4.0, to: 0.0, isAdditive: true), basicAnimation("shadowRadius", from: 2.0, to: 10.0)]), + ("shadowOffset", CGSize(width: 1, height: 1), [basicAnimation("shadowOffset", from: CGSize(width: 4, height: 4), to: CGSize(width: 1, height: 1), beginTime: 104)]), + ("opacity", Float(0.5), [basicAnimation("opacity", from: 0.2, to: 0.0, isAdditive: true)]), + ("cornerRadius", 10.0, [basicAnimation("cornerRadius", from: -4.0, to: 0.0, isAdditive: true)]), + ("shadowPath", CGPath(rect: CGRect(x: 0, y: 0, width: 200, height: 50), transform: nil), [basicAnimation("shadowPath", from: CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil), to: CGPath(rect: CGRect(x: 0, y: 0, width: 200, height: 50), transform: nil))]), + ] + // each stack is on a hosted layer, which Core Animation shows, and on a detached twin, which the value is computed + // for, since the twin has no presentation layer for the computation to fall back to + func makeLayer(for testCase: (keyPath: String, model: Any, animations: [CABasicAnimation])) -> CALayer { + let layer = CALayer() + layer.frame = CGRect(x: 0, y: 0, width: 10, height: 10) + // without implicit animations, which the stacks would include + CATransaction.disableAnimations { + layer.setValue(testCase.model, forKeyPath: testCase.keyPath) + } + for (index, animation) in testCase.animations.enumerated() { + layer.add(animation, forKey: "\(testCase.keyPath)-\(index)") + } + return layer + } + let hostedLayers = cases.map { testCase -> CALayer in + let layer = makeLayer(for: testCase) + root.addSublayer(layer) + return layer + } + + for time in [103.0, 106.0] { + // when: moving the timeline + root.timeOffset = time + CATransaction.flush() + + for (testCase, hostedLayer) in zip(cases, hostedLayers) { + // then: the computed value is the one the presentation layer shows, within Core Animation's single precision + // solving of timing functions and springs. the twin is made after the commit, which drops a detached layer's + // animations + let description = "\(testCase.keyPath) at \(time)" + let shown = try unwrap(hostedLayer.presentation()?.value(forKeyPath: testCase.keyPath), description) + let twin = makeLayer(for: testCase) + let computed = try unwrap(twin.shownValue(forKeyPath: testCase.keyPath, animations: twin.propertyAnimations(forKeyPath: testCase.keyPath).map(KeyPathAnimation.direct), at: time), description) + switch testCase.keyPath { + case "backgroundColor": + try expectExtendedSRGBComponents(of: colorValue(computed), toBe: colorValue(shown).extendedSRGBComponents(), within: 1e-4) + case "position": + let point = try unwrap(computed as? CGPoint, description) + let shownPoint = try unwrap(shown as? CGPoint, description) + expect(point.x, description).to(beApproximatelyEqual(to: shownPoint.x, within: 1e-3)) + expect(point.y, description).to(beApproximatelyEqual(to: shownPoint.y, within: 1e-3)) + case "shadowOffset": + let size = try unwrap(computed as? CGSize, description) + let shownSize = try unwrap(shown as? CGSize, description) + expect(size.width, description).to(beApproximatelyEqual(to: shownSize.width, within: 1e-4)) + expect(size.height, description).to(beApproximatelyEqual(to: shownSize.height, within: 1e-4)) + case "shadowPath": + expect(try pathValue(computed).boundingBoxOfPath.width, description).to(try beApproximatelyEqual(to: pathValue(shown).boundingBoxOfPath.width, within: 1e-4)) + expect(try pathValue(computed).boundingBoxOfPath.height, description).to(try beApproximatelyEqual(to: pathValue(shown).boundingBoxOfPath.height, within: 1e-4)) + default: + expect(try numberScalar(computed), description).to(try beApproximatelyEqual(to: numberScalar(shown), within: 1e-4)) + } + } + } + } + + #if canImport(AppKit) + func test_shownValue_clampedNumbers_matchWhatRenders() throws { + // given: a rendered white layer at 0.5 opacity with additive opacity animations from 0.8 and from -0.3 to 0, over two + // seconds from 10 + let opacityRenderer = try PausedLayerRenderer(time: 10) + let opacityLayer = opacityRenderer.layer + opacityLayer.disableActions(for: "opacity") { + opacityLayer.opacity = 0.5 + } + opacityLayer.add(animation(keyPath: "opacity", from: 0.8, to: 0.0, beginTime: 10, isAdditive: true), forKey: "a") + opacityLayer.add(animation(keyPath: "opacity", from: -0.3, to: 0.0, beginTime: 10, isAdditive: true), forKey: "b") + + // when: moving the timeline a quarter of the way + opacityRenderer.move(to: 10.5) + + // then: the computed opacity, clamped after each animation, is the one that renders, while the presentation layer + // reports the unclamped sum + let opacity = try unwrap(opacityLayer.shownValue(forKeyPath: "opacity", animations: opacityLayer.propertyAnimations(forKeyPath: "opacity").map(KeyPathAnimation.direct), at: 10.5) as? Float) + expect(Double(opacity)).to(beApproximatelyEqual(to: 0.775, within: 1e-6)) + expect(opacityRenderer.renderedOpacity()).to(beApproximatelyEqual(to: Double(opacity), within: 0.01)) + expect(try Double(unwrap(opacityLayer.presentation()).opacity)).to(beApproximatelyEqual(to: 0.875, within: 1e-6)) + + // given: a rendered white layer with a corner radius of 2 and additive animations from -4 and from 2 to 0, beginning + // at 10 + let radiusRenderer = try PausedLayerRenderer(time: 10) + let radiusLayer = radiusRenderer.layer + radiusLayer.disableActions(for: "cornerRadius") { + radiusLayer.cornerRadius = 2 + } + radiusLayer.add(animation(keyPath: "cornerRadius", from: -4.0, to: 0.0, beginTime: 10, isAdditive: true), forKey: "a") + radiusLayer.add(animation(keyPath: "cornerRadius", from: 2.0, to: 0.0, beginTime: 10, isAdditive: true), forKey: "b") + + // when: reading the corner radius as they begin, and rendering the corner's pixel + let radius = try unwrap(radiusLayer.shownValue(forKeyPath: "cornerRadius", animations: radiusLayer.propertyAnimations(forKeyPath: "cornerRadius").map(KeyPathAnimation.direct), at: 10) as? CGFloat) + let renderedCorner = radiusRenderer.renderedOpacity(x: 0, y: 0) + + // then: the computed radius, clamped at 0 after each animation, is 2, and the corner renders as a still corner of that + // radius does, instead of the square corner of the unclamped sum 0 + expect(radius) == 2 + radiusLayer.removeAllAnimations() + radiusLayer.disableActions(for: "cornerRadius") { + radiusLayer.cornerRadius = radius + } + expect(radiusRenderer.renderedOpacity(x: 0, y: 0)).to(beApproximatelyEqual(to: renderedCorner, within: 0.01)) + radiusLayer.disableActions(for: "cornerRadius") { + radiusLayer.cornerRadius = 0 + } + expect(radiusRenderer.renderedOpacity(x: 0, y: 0)) > renderedCorner + 0.1 + } + #endif + + // MARK: - Helpers + + /// A basic animation from a value to a value over two seconds, linear, from a begin time, with a backwards and a + /// forwards fill. + private func animation(keyPath: String, from: Any?, to: Any?, beginTime: TimeInterval = 1000, isAdditive: Bool = false) -> CABasicAnimation { + let animation = CABasicAnimation(keyPath: keyPath) + animation.fromValue = from + animation.toValue = to + animation.beginTime = beginTime + animation.duration = 2 + animation.timingFunction = CAMediaTimingFunction(name: .linear) + animation.fillMode = .both + animation.isAdditive = isAdditive + return animation + } + + /// A group of animations over two seconds from 1000, with a backwards and a forwards fill. + private func group(of animations: [CAAnimation]) -> CAAnimationGroup { + let group = CAAnimationGroup() + group.animations = animations + group.beginTime = 1000 + group.duration = 2 + group.fillMode = .both + return group + } +} diff --git a/ComposeUI/Tests/ComposeUITests/Animations/ExtendedSRGBTests.swift b/ComposeUI/Tests/ComposeUITests/Animations/ExtendedSRGBTests.swift new file mode 100644 index 00000000..23b332d8 --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/Animations/ExtendedSRGBTests.swift @@ -0,0 +1,122 @@ +// +// ExtendedSRGBTests.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 + +@testable import ComposeUI + +class ExtendedSRGBTests: XCTestCase { + + func test_components_sRGBColor_areItsComponents() throws { + // given: an sRGB color and an extended sRGB color with components outside [0, 1] + let sRGBColor = CGColor(srgbRed: 0.2, green: 0.4, blue: 0.6, alpha: 0.8) + let extendedColor = try CGColor(colorSpace: unwrap(CGColorSpace(name: CGColorSpace.extendedSRGB)), components: [1.2, -0.1, 0.5, 1]).unwrap() + + // then: their components are read as they are + expect(ExtendedSRGB.components(of: sRGBColor)) == SIMD4(0.2, 0.4, 0.6, 0.8) + expect(ExtendedSRGB.components(of: extendedColor)) == SIMD4(1.2, -0.1, 0.5, 1) + } + + func test_components_colorOfAnotherColorSpace_areItsConvertedComponents() throws { + // given: colors of other color spaces: a gray, a Display P3 green outside sRGB, and a generic RGB red + let colors = try [ + CGColor(gray: 0.3, alpha: 0.5), + CGColor(colorSpace: unwrap(CGColorSpace(name: CGColorSpace.displayP3)), components: [0, 1, 0, 1]).unwrap(), + CGColor(colorSpace: unwrap(CGColorSpace(name: CGColorSpace.genericRGBLinear)), components: [1, 0, 0, 1]).unwrap(), + ] + + for color in colors { + // when: reading its components twice, the second time from the kept conversion + let components = [ExtendedSRGB.components(of: color), ExtendedSRGB.components(of: color)] + + // then: both are the color's components converted to extended sRGB + let expected = try color.extendedSRGBComponents().map { Double($0) } + for value in components { + let value = try unwrap(value) + expect([value.x, value.y, value.z, value.w], "\(color)") == expected + } + } + } + + func test_components_moreColorsThanKept_convertsEach() throws { + // given: twelve grays, more than the conversions kept + let grays = (0 ..< 12).map { CGColor(gray: CGFloat($0) / 12, alpha: 1) } + + // when: reading their components twice around + let components = (grays + grays).map { ExtendedSRGB.components(of: $0) } + + // then: each is its gray's converted components + for (index, value) in components.enumerated() { + let value = try unwrap(value) + expect([value.x, value.y, value.z, value.w]) == (try grays[index % grays.count].extendedSRGBComponents().map { Double($0) }) + } + } + + func test_components_offTheMainThread_converts() throws { + // given: a gray + let gray = CGColor(gray: 0.7, alpha: 1) + + // when: reading its components off the main thread + var components: SIMD4? + let didRead = expectation(description: "read off the main thread") + DispatchQueue.global().async { + expect(Thread.isMainThread) == false + components = ExtendedSRGB.components(of: gray) + didRead.fulfill() + } + wait(for: [didRead], timeout: 5) + + // then: they're its converted components, converted without the main thread's kept conversions + let value = try unwrap(components) + expect([value.x, value.y, value.z, value.w]) == (try gray.extendedSRGBComponents().map { Double($0) }) + } + + func test_components_pattern_isNil() throws { + // given: a pattern color, which has no RGB components + var callbacks = CGPatternCallbacks(version: 0, drawPattern: { _, _ in }, releaseInfo: nil) + let pattern = try CGPattern(info: nil, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), matrix: .identity, xStep: 1, yStep: 1, tiling: .noDistortion, isColored: true, callbacks: &callbacks).unwrap() + var alpha: CGFloat = 1 + let patternColor = try CGColor(patternSpace: CGColorSpace(patternBaseSpace: nil).unwrap(), pattern: pattern, components: &alpha).unwrap() + + // then: it has no extended sRGB components + expect(ExtendedSRGB.components(of: patternColor)) == nil + } + + func test_color_isTheColorOfTheComponentsInExtendedSRGB() throws { + // when: making the color of components outside [0, 1] + let color = try ExtendedSRGB.color(components: SIMD4(1.25, -0.5, 0.5, 0.75)).unwrap() + + // then: it's in extended sRGB with the components + expect(color.colorSpace?.name) == CGColorSpace.extendedSRGB + expect(color.components) == [1.25, -0.5, 0.5, 0.75] + } +} diff --git a/ComposeUI/Tests/ComposeUITests/Components/DropShadowLayerTests.swift b/ComposeUI/Tests/ComposeUITests/Components/DropShadowLayerTests.swift index ee047a47..0d9a2cd3 100644 --- a/ComposeUI/Tests/ComposeUITests/Components/DropShadowLayerTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Components/DropShadowLayerTests.swift @@ -251,6 +251,33 @@ final class DropShadowLayerTests: XCTestCase { expect(replacedPathAnimation.duration) == 2 } + func test_update_withAnimation_colorAndOpacityInFlight_startFromTheValuesAtTheClocksTime() throws { + // given: a layer whose shadow color animates from red to blue, and shadow opacity from 0 to 1, over two seconds + // from 1000 + let layer = DropShadowLayer() + layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + + func update(color: Color, opacity: CGFloat, animationTiming: AnimationTiming?) { + layer.update(color: color, opacity: opacity, radius: 4, offset: .zero, path: { CGPath(rect: CGRect(origin: .zero, size: $0), transform: nil) }, animationTiming: animationTiming) + } + + AnimationClock.sharingTime(at: 1000) { + update(color: .red, opacity: 0, animationTiming: nil) + update(color: .blue, opacity: 1, animationTiming: .linear(duration: 2)) + } + + // when: updating with animation timing a quarter of the way, where the layer has no presentation layer to read + AnimationClock.sharingTime(at: 1000.5) { + update(color: .green, opacity: 0.5, animationTiming: .linear(duration: 2)) + } + + // then: the new animations start from the color and opacity the layer shows at the clock's time + let colorAnimation = try (layer.animation(forKey: "shadowColor") as? CABasicAnimation).unwrap() + expect(try colorValue(colorAnimation.fromValue).sRGBComponents()[2]).to(beApproximatelyEqual(to: 0.25, within: 1e-6)) + let opacityAnimation = try (layer.animation(forKey: "shadowOpacity") as? CABasicAnimation).unwrap() + expect(opacityAnimation.fromValue as? Float) == 0.25 + } + func test_update_withoutAnimation_continuesInFlightShadowAnimations() throws { // given: a layer with a cutout, resized and animated towards new values of every shadow property, with a second, // delayed animated update stacked on the radius, plus animations of other properties standing in for a transition @@ -362,8 +389,8 @@ final class DropShadowLayerTests: XCTestCase { let shadowPathAnimation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() expect(shadowPathAnimation.duration) == 10 expect(shadowPathAnimation.timingFunction) == CAMediaTimingFunction(name: .linear) - expect(try PathPoints(path(shadowPathAnimation.fromValue))) == PathPoints(CGPath(rect: CGRect(x: 1, y: 1, width: 98, height: 98), transform: nil)) - expect(try path(shadowPathAnimation.toValue)) == referenceShadowPath + expect(try PathPoints(pathValue(shadowPathAnimation.fromValue))) == PathPoints(CGPath(rect: CGRect(x: 1, y: 1, width: 98, height: 98), transform: nil)) + expect(try pathValue(shadowPathAnimation.toValue)) == referenceShadowPath // then: the mask keeps its frame animations, and its path does as the shadow path expect(Set(mask.animationKeys() ?? [])) == ["position", "bounds.size", "path"] @@ -376,8 +403,8 @@ final class DropShadowLayerTests: XCTestCase { let maskPathAnimation = try (mask.animation(forKey: "path") as? CABasicAnimation).unwrap() expect(maskPathAnimation.duration) == 10 expect(maskPathAnimation.timingFunction) == CAMediaTimingFunction(name: .linear) - expect(try PathPoints(path(maskPathAnimation.fromValue))) == PathPoints(maskPath(cutout: CGPath(rect: CGRect(x: 2, y: 2, width: 96, height: 96), transform: nil))) - expect(try path(maskPathAnimation.toValue)) == referenceMaskPath + expect(try PathPoints(pathValue(maskPathAnimation.fromValue))) == PathPoints(maskPath(cutout: CGPath(rect: CGRect(x: 2, y: 2, width: 96, height: 96), transform: nil))) + expect(try pathValue(maskPathAnimation.toValue)) == referenceMaskPath // when: updating with animation timing and the same values update(layer, green, animationTiming: .linear(duration: 2)) @@ -450,9 +477,7 @@ final class DropShadowLayerTests: XCTestCase { } func renderedBlue() throws -> CGFloat { - let color = try layer.presentation().unwrap().shadowColor.unwrap() - let sRGB = try CGColorSpace(name: CGColorSpace.sRGB).unwrap() - return try color.converted(to: sRGB, intent: .defaultIntent, options: nil).unwrap().components.unwrap()[2] + try layer.presentation().unwrap().shadowColor.unwrap().sRGBComponents()[2] } update(color: .red, animationTiming: nil) @@ -478,8 +503,8 @@ final class DropShadowLayerTests: XCTestCase { var landed = false for _ in 0 ..< 40 where !landed { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) - let (blue, times) = try layer.readPresentation { try blueComponent(of: $0.shadowColor.unwrap()) } - try layer.expectShown(blue, forKeyPath: "shadowColor", between: times, scalar: blueComponent, within: 0.05) + let (blue, times) = try layer.readPresentation { try blueScalar($0.shadowColor.unwrap()) } + try layer.expectShown(blue, forKeyPath: "shadowColor", between: times, scalar: blueScalar, within: 0.05) expect(blue) <= blueBeforeUpdate + 0.05 landed = blue < 0.01 } @@ -487,13 +512,6 @@ final class DropShadowLayerTests: XCTestCase { } /// The blue component of a color value in the sRGB color space, for `predictedValue(forKeyPath:at:scalar:)`. - private func blueComponent(of value: Any) throws -> CGFloat { - // a Core Foundation type can't be checked at runtime, so the cast is forced - let color = value as! CGColor // swiftlint:disable:this force_cast - let sRGB = try CGColorSpace(name: CGColorSpace.sRGB).unwrap() - return try color.converted(to: sRGB, intent: .defaultIntent, options: nil).unwrap().components.unwrap()[2] - } - func test_update_withoutAnimation_opacityAndRadius_renderContinuously() throws { // given: a hosted layer whose shadow opacity and radius are animating up from zero, showing what their animations // give @@ -522,19 +540,17 @@ final class DropShadowLayerTests: XCTestCase { // animations at the clock's time for this turn, which the test reads first, see `AnimationClock` let updateTime = layer.currentTime let radiusAtUpdate = try layer.predictedValue(forKeyPath: "shadowRadius", at: updateTime, scalar: numberScalar) + let opacityAtUpdate = try layer.predictedValue(forKeyPath: "shadowOpacity", at: updateTime, scalar: numberScalar) update(opacity: 0, radius: 0, animationTiming: nil) - // then: neither value drops to zero at the update. the radius' animation is folded into a glide from the radius at - // the update's time, so the radius doesn't jump. the opacity is retargeted from what the layer shows, which Core - // Animation evaluates once per transaction, at its first presentation read, the read before the update here. so it - // starts from exactly the opacity that read showed, which trails the interrupted animation at the update's time - // when the main thread stalls between that read and the clock read + // then: neither value drops to zero at the update, or jumps. the radius' animation is folded into a glide from the + // radius at the update's time, and the opacity's is replaced by one from the opacity at that time expect(layer.shadowOpacity) == 0 expect(layer.shadowRadius) == 0 expect(try layer.predictedValue(forKeyPath: "shadowRadius", at: updateTime, scalar: numberScalar)) .to(beApproximatelyEqual(to: radiusAtUpdate, within: 1e-6)) expect(try layer.predictedValue(forKeyPath: "shadowOpacity", at: updateTime, scalar: numberScalar)) - .to(beApproximatelyEqual(to: shownBeforeUpdate.opacity, within: 1e-6)) + .to(beApproximatelyEqual(to: opacityAtUpdate, within: 1e-6)) // then: both head back to zero along their animations and land when the interrupted animations would have. the run // loop's timing isn't reliable, so the test checks each look against the animations' own values for the times just @@ -568,15 +584,15 @@ final class DropShadowLayerTests: XCTestCase { let sizeAnimation = try (layer.animation(forKey: "bounds.size") as? CABasicAnimation).unwrap() let shadowPathAnimation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() expectSameTiming(shadowPathAnimation, as: sizeAnimation) - expect(try isPath(path(shadowPathAnimation.fromValue), closeTo: roundedRect(width: 100))) == true - expect(try path(shadowPathAnimation.toValue)) == roundedRect(width: 200) + expect(try isPath(pathValue(shadowPathAnimation.fromValue), closeTo: roundedRect(width: 100))) == true + expect(try pathValue(shadowPathAnimation.toValue)) == roundedRect(width: 200) // then: so does the mask path, with the mask's frame let maskSizeAnimation = try (mask.animation(forKey: "bounds.size") as? CABasicAnimation).unwrap() let maskPathAnimation = try (mask.animation(forKey: "path") as? CABasicAnimation).unwrap() expectSameTiming(maskPathAnimation, as: maskSizeAnimation) - expect(try isPath(path(maskPathAnimation.fromValue), closeTo: maskPath(width: 100))) == true - expect(try PathPoints(path(maskPathAnimation.toValue))) == PathPoints(maskPath(width: 200)) + expect(try isPath(pathValue(maskPathAnimation.fromValue), closeTo: maskPath(width: 100))) == true + expect(try PathPoints(pathValue(maskPathAnimation.toValue))) == PathPoints(maskPath(width: 200)) } } @@ -597,8 +613,8 @@ final class DropShadowLayerTests: XCTestCase { expect(shadowPathAnimation.keyTimes) == nil expect(maskPathAnimation.keyTimes) == nil - let shadowPaths = try paths(of: shadowPathAnimation) - let maskPaths = try paths(of: maskPathAnimation) + let shadowPaths = try pathValues(of: shadowPathAnimation) + let maskPaths = try pathValues(of: maskPathAnimation) expect(shadowPaths.count) == maskPaths.count for index in shadowPaths.indices { let time = 2 * CGFloat(index) / CGFloat(shadowPaths.count - 1) @@ -625,8 +641,8 @@ final class DropShadowLayerTests: XCTestCase { expect(shadowPathAnimation.keyTimes) == nil expect(maskPathAnimation.keyTimes) == nil - let shadowPaths = try paths(of: shadowPathAnimation) - let maskPaths = try paths(of: maskPathAnimation) + let shadowPaths = try pathValues(of: shadowPathAnimation) + let maskPaths = try pathValues(of: maskPathAnimation) expect(isPath(shadowPaths[0], closeTo: roundedRect(width: 100))) == true expect(isPath(shadowPaths[shadowPaths.count / 2], closeTo: roundedRect(width: 150, cornerRadius: 20))) == true expect(shadowPaths.last) == roundedRect(width: 200, cornerRadius: 20) @@ -651,8 +667,8 @@ final class DropShadowLayerTests: XCTestCase { let sizeAnimation = try (layer.animation(forKey: "bounds.size") as? CABasicAnimation).unwrap() let shadowPathAnimation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() expectSameTiming(shadowPathAnimation, as: sizeAnimation) - expect(try isPath(path(shadowPathAnimation.fromValue), closeTo: roundedRect(width: 150))) == true - expect(try path(shadowPathAnimation.toValue)) == roundedRect(width: 250) + expect(try isPath(pathValue(shadowPathAnimation.fromValue), closeTo: roundedRect(width: 150))) == true + expect(try pathValue(shadowPathAnimation.toValue)) == roundedRect(width: 250) expect(layer.shadowPath) == roundedRect(width: 250) // then: so does the mask path, and the mask's frame changes at once too, keeping its animations @@ -660,8 +676,8 @@ final class DropShadowLayerTests: XCTestCase { let maskSizeAnimation = try (mask.animation(forKey: "bounds.size") as? CABasicAnimation).unwrap() let maskPathAnimation = try (mask.animation(forKey: "path") as? CABasicAnimation).unwrap() expectSameTiming(maskPathAnimation, as: maskSizeAnimation) - expect(try isPath(path(maskPathAnimation.fromValue), closeTo: maskPath(width: 150))) == true - expect(try PathPoints(path(maskPathAnimation.toValue))) == PathPoints(maskPath(width: 250)) + expect(try isPath(pathValue(maskPathAnimation.fromValue), closeTo: maskPath(width: 150))) == true + expect(try PathPoints(pathValue(maskPathAnimation.toValue))) == PathPoints(maskPath(width: 250)) } func test_update_withAnimation_pathsOfOtherElements_changeAtOnce() throws { @@ -771,15 +787,4 @@ final class DropShadowLayerTests: XCTestCase { abs($0.x - $1.x) <= 1e-6 && abs($0.y - $1.y) <= 1e-6 } } - - /// The paths of a keyframe animation. - private func paths(of animation: CAKeyframeAnimation) throws -> [CGPath] { - try animation.values.unwrap().map { try path($0) } - } - - /// A path given as an animation value. - private func path(_ value: Any?) throws -> CGPath { - // a Core Foundation type can't be checked at runtime, so the cast is forced - try (value.unwrap() as! CGPath) // swiftlint:disable:this force_cast - } } diff --git a/ComposeUI/Tests/ComposeUITests/Components/InnerShadowLayerTests.swift b/ComposeUI/Tests/ComposeUITests/Components/InnerShadowLayerTests.swift index fd1172fb..81c2fe88 100644 --- a/ComposeUI/Tests/ComposeUITests/Components/InnerShadowLayerTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Components/InnerShadowLayerTests.swift @@ -351,6 +351,33 @@ final class InnerShadowLayerTests: XCTestCase { expect(replacedPathAnimation.duration) == 2 } + func test_update_withAnimation_colorAndOpacityInFlight_startFromTheValuesAtTheClocksTime() throws { + // given: an inner shadow layer whose shadow color animates from red to blue, and shadow opacity from 0 to 1, over + // two seconds from 1000 + let layer = InnerShadowLayer() + layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + + func update(color: Color, opacity: CGFloat, animationTiming: AnimationTiming?) { + layer.update(color: color, opacity: opacity, radius: 10, offset: .zero, path: { CGPath(rect: CGRect(origin: .zero, size: $0), transform: nil) }, animationTiming: animationTiming) + } + + AnimationClock.sharingTime(at: 1000) { + update(color: .red, opacity: 0, animationTiming: nil) + update(color: .blue, opacity: 1, animationTiming: .linear(duration: 2)) + } + + // when: updating with animation timing a quarter of the way, where the layer has no presentation layer to read + AnimationClock.sharingTime(at: 1000.5) { + update(color: .green, opacity: 0.5, animationTiming: .linear(duration: 2)) + } + + // then: the new animations start from the color and opacity the layer shows at the clock's time + let colorAnimation = try (layer.animation(forKey: "shadowColor") as? CABasicAnimation).unwrap() + expect(try colorValue(colorAnimation.fromValue).sRGBComponents()[2]).to(beApproximatelyEqual(to: 0.25, within: 1e-6)) + let opacityAnimation = try (layer.animation(forKey: "shadowOpacity") as? CABasicAnimation).unwrap() + expect(opacityAnimation.fromValue as? Float) == 0.25 + } + func test_update_withoutAnimation_continuesInFlightShadowAnimations() throws { // given: an inner shadow layer resized and animated towards new values of every shadow property, with a second, // delayed animated update stacked on the radius, plus animations of other properties standing in for a transition @@ -464,8 +491,8 @@ final class InnerShadowLayerTests: XCTestCase { let shownHolePath = CGPath(rect: CGRect(x: -2, y: -2, width: 104, height: 104), transform: nil) expect(shadowPathAnimation.duration) == 10 expect(shadowPathAnimation.timingFunction) == CAMediaTimingFunction(name: .linear) - expect(try PathPoints(path(shadowPathAnimation.fromValue))) == PathPoints(shownHolePath) - expect(try path(shadowPathAnimation.toValue)) == referenceShadowPath + expect(try PathPoints(pathValue(shadowPathAnimation.fromValue))) == PathPoints(shownHolePath) + expect(try pathValue(shadowPathAnimation.toValue)) == referenceShadowPath // then: the mask keeps its frame animations, and its path, the hole, does as the shadow path expect(Set(maskLayer.animationKeys() ?? [])) == ["position", "bounds.size", "path"] @@ -478,8 +505,8 @@ final class InnerShadowLayerTests: XCTestCase { let maskPathAnimation = try (maskLayer.animation(forKey: "path") as? CABasicAnimation).unwrap() expect(maskPathAnimation.duration) == 10 expect(maskPathAnimation.timingFunction) == CAMediaTimingFunction(name: .linear) - expect(try PathPoints(path(maskPathAnimation.fromValue))) == PathPoints(CGPath(rect: CGRect(x: -2, y: -2, width: 104, height: 104), transform: nil)) - expect(try path(maskPathAnimation.toValue)) == referenceMaskPath + expect(try PathPoints(pathValue(maskPathAnimation.fromValue))) == PathPoints(CGPath(rect: CGRect(x: -2, y: -2, width: 104, height: 104), transform: nil)) + expect(try pathValue(maskPathAnimation.toValue)) == referenceMaskPath // when: updating with animation timing and the same values update(layer, green, animationTiming: .linear(duration: 2)) @@ -551,9 +578,7 @@ final class InnerShadowLayerTests: XCTestCase { } func renderedBlue() throws -> CGFloat { - let color = try layer.presentation().unwrap().shadowColor.unwrap() - let sRGB = try CGColorSpace(name: CGColorSpace.sRGB).unwrap() - return try color.converted(to: sRGB, intent: .defaultIntent, options: nil).unwrap().components.unwrap()[2] + try layer.presentation().unwrap().shadowColor.unwrap().sRGBComponents()[2] } update(color: .red, animationTiming: nil) @@ -579,8 +604,8 @@ final class InnerShadowLayerTests: XCTestCase { var landed = false for _ in 0 ..< 40 where !landed { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) - let (blue, times) = try layer.readPresentation { try blueComponent(of: $0.shadowColor.unwrap()) } - try layer.expectShown(blue, forKeyPath: "shadowColor", between: times, scalar: blueComponent, within: 0.05) + let (blue, times) = try layer.readPresentation { try blueScalar($0.shadowColor.unwrap()) } + try layer.expectShown(blue, forKeyPath: "shadowColor", between: times, scalar: blueScalar, within: 0.05) expect(blue) <= blueBeforeUpdate + 0.05 landed = blue < 0.01 } @@ -588,13 +613,6 @@ final class InnerShadowLayerTests: XCTestCase { } /// The blue component of a color value in the sRGB color space, for `predictedValue(forKeyPath:at:scalar:)`. - private func blueComponent(of value: Any) throws -> CGFloat { - // a Core Foundation type can't be checked at runtime, so the cast is forced - let color = value as! CGColor // swiftlint:disable:this force_cast - let sRGB = try CGColorSpace(name: CGColorSpace.sRGB).unwrap() - return try color.converted(to: sRGB, intent: .defaultIntent, options: nil).unwrap().components.unwrap()[2] - } - // MARK: - Missing invertsShadow func test_update_withoutInvertsShadow_assertsAndDrawsNothing() { @@ -653,15 +671,15 @@ final class InnerShadowLayerTests: XCTestCase { let sizeAnimation = try (layer.animation(forKey: "bounds.size") as? CABasicAnimation).unwrap() let shadowPathAnimation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() expectSameTiming(shadowPathAnimation, as: sizeAnimation) - expect(try isPath(path(shadowPathAnimation.fromValue), closeTo: paths100.shadow), scenario) == true - expect(try path(shadowPathAnimation.toValue), scenario) == paths200.shadow + expect(try isPath(pathValue(shadowPathAnimation.fromValue), closeTo: paths100.shadow), scenario) == true + expect(try pathValue(shadowPathAnimation.toValue), scenario) == paths200.shadow // then: so does the clip path, with the mask's frame let maskSizeAnimation = try (maskLayer.animation(forKey: "bounds.size") as? CABasicAnimation).unwrap() let clipPathAnimation = try (maskLayer.animation(forKey: "path") as? CABasicAnimation).unwrap() expectSameTiming(clipPathAnimation, as: maskSizeAnimation) - expect(try isPath(path(clipPathAnimation.fromValue), closeTo: paths100.clip), scenario) == true - expect(try path(clipPathAnimation.toValue), scenario) == paths200.clip + expect(try isPath(pathValue(clipPathAnimation.fromValue), closeTo: paths100.clip), scenario) == true + expect(try pathValue(clipPathAnimation.toValue), scenario) == paths200.clip } } @@ -682,8 +700,8 @@ final class InnerShadowLayerTests: XCTestCase { expect(shadowPathAnimation.keyTimes) == nil expect(clipPathAnimation.keyTimes) == nil - let shadowPaths = try paths(of: shadowPathAnimation) - let clipPaths = try paths(of: clipPathAnimation) + let shadowPaths = try pathValues(of: shadowPathAnimation) + let clipPaths = try pathValues(of: clipPathAnimation) expect(shadowPaths.count) == clipPaths.count for index in shadowPaths.indices { let time = 2 * CGFloat(index) / CGFloat(shadowPaths.count - 1) @@ -713,8 +731,8 @@ final class InnerShadowLayerTests: XCTestCase { let sizeAnimation = try (layer.animation(forKey: "bounds.size") as? CABasicAnimation).unwrap() let shadowPathAnimation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() expectSameTiming(shadowPathAnimation, as: sizeAnimation) - expect(try isPath(path(shadowPathAnimation.fromValue), closeTo: paths150.shadow)) == true - expect(try path(shadowPathAnimation.toValue)) == paths250.shadow + expect(try isPath(pathValue(shadowPathAnimation.fromValue), closeTo: paths150.shadow)) == true + expect(try pathValue(shadowPathAnimation.toValue)) == paths250.shadow expect(layer.shadowPath) == paths250.shadow // then: so does the clip path, and the mask's frame changes at once too, keeping its animations @@ -722,8 +740,8 @@ final class InnerShadowLayerTests: XCTestCase { let maskSizeAnimation = try (maskLayer.animation(forKey: "bounds.size") as? CABasicAnimation).unwrap() let clipPathAnimation = try (maskLayer.animation(forKey: "path") as? CABasicAnimation).unwrap() expectSameTiming(clipPathAnimation, as: maskSizeAnimation) - expect(try isPath(path(clipPathAnimation.fromValue), closeTo: paths150.clip)) == true - expect(try path(clipPathAnimation.toValue)) == paths250.clip + expect(try isPath(pathValue(clipPathAnimation.fromValue), closeTo: paths150.clip)) == true + expect(try pathValue(clipPathAnimation.toValue)) == paths250.clip } // MARK: - Helpers @@ -788,15 +806,4 @@ final class InnerShadowLayerTests: XCTestCase { abs($0.x - $1.x) <= 1e-6 && abs($0.y - $1.y) <= 1e-6 } } - - /// The paths of a keyframe animation. - private func paths(of animation: CAKeyframeAnimation) throws -> [CGPath] { - try animation.values.unwrap().map { try path($0) } - } - - /// A path given as an animation value. - private func path(_ value: Any?) throws -> CGPath { - // a Core Foundation type can't be checked at runtime, so the cast is forced - try (value.unwrap() as! CGPath) // swiftlint:disable:this force_cast - } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ColorNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ColorNodeTests.swift index 1b571c9f..4f55ab88 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ColorNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ColorNodeTests.swift @@ -291,6 +291,41 @@ class ColorNodeTests: XCTestCase { expect(layer.animation(forKey: "backgroundColor")) === animation } + func test_update_withAnimation_colorInFlight_startsFromTheColorAtTheClocksTime() throws { + // given: a themed color node rendered in the light theme, whose color animates towards the dark theme's over two + // seconds from 1000 + var node = ColorNode(ThemedColor(light: .red, dark: .blue)) + _ = node.layout(containerSize: CGSize(width: 100, height: 100), context: ComposeNodeLayoutContext(scaleFactor: 1)) + let frame = CGRect(x: 0, y: 0, width: 100, height: 100) + let item = try node.renderableItems(in: frame).first.unwrap() + + let contentView = ComposeView() + contentView.overrideTheme = .light + let renderable = item.make(RenderableMakeContext(initialFrame: frame, contentView: contentView)) + let layer = renderable.layer + + func update(animationTiming: AnimationTiming?) { + let animationDecision = animationTiming == nil ? ComposeView.AnimationDecision.disabled : ComposeView.AnimationDecision.all + item.update(renderable, RenderableUpdateContext(updateType: .refresh, oldFrame: frame, newFrame: frame, previousRenderBounds: frame, renderBounds: frame, animationTiming: animationTiming, contentView: contentView, contentEvaluation: nil, animationDecision: animationDecision)) + } + AnimationClock.sharingTime(at: 1000) { + update(animationTiming: nil) + contentView.overrideTheme = .dark + update(animationTiming: .linear(duration: 2)) + } + + // when: the theme flips back and the node is refreshed with animation a quarter of the way, where the layer has no + // presentation layer to read + contentView.overrideTheme = .light + AnimationClock.sharingTime(at: 1000.5) { + update(animationTiming: .linear(duration: 2)) + } + + // then: the new animation starts from the color the layer shows at the clock's time + let animation = try (layer.animation(forKey: "backgroundColor") as? CABasicAnimation).unwrap() + try expectExtendedSRGBComponents(of: colorValue(animation.fromValue), toBe: [0.75, 0, 0.25, 1]) + } + func test_boundsChange_keepsColor_withUnchangedItemFrame() throws { // given: a fixed-size color node configured from the container width var renderedLayer: CALayer? diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ModifierNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ModifierNodeTests.swift index a9c566f9..4d7f9949 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ModifierNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ModifierNodeTests.swift @@ -2714,6 +2714,107 @@ class ModifierNodeTests: XCTestCase { expect(renderable.layer.shadowOpacity) == 0.2 } + func test_colors_animatedUpdate_colorsInFlight_startFromTheColorsAtTheClocksTime() throws { + // given: a layer whose background and border colors animate from red to blue over two seconds, from 1000 + let renderable = Renderable.layer(CALayer()) + func item(_ color: Color) throws -> RenderableItem { + try firstRenderableItem(of: LayerNode().backgroundColor(color).border(color: color, width: 1)).unwrap() + } + try AnimationClock.sharingTime(at: 1000) { + try refresh(renderable, with: item(.red), animationTiming: nil) + try refresh(renderable, with: item(.blue), animationTiming: .linear(duration: 2)) + } + + // when: an animated update to green a quarter of the way, where the layer has no presentation layer to read + try AnimationClock.sharingTime(at: 1000.5) { + try refresh(renderable, with: item(.green), animationTiming: .linear(duration: 2)) + } + + // then: the new animations start from the colors the layer shows at the clock's time, instead of the model colors + for keyPath in ["backgroundColor", "borderColor"] { + let animation = try (renderable.layer.animation(forKey: keyPath) as? CABasicAnimation).unwrap() + try expectExtendedSRGBComponents(of: colorValue(animation.fromValue), toBe: [0.75, 0, 0.25, 1]) + } + } + + func test_shadow_animatedUpdate_shadowInFlight_startsFromTheShadowAtTheClocksTime() throws { + // given: a layer whose shadow color animates from red to blue, opacity from 0 to 1, and path from 100 to 200 points + // wide, over two seconds from 1000 + let renderable = Renderable.layer(CALayer()) + func item(color: Color, opacity: CGFloat, width: CGFloat) throws -> RenderableItem { + try firstRenderableItem(of: LayerNode().shadow(color: color, opacity: opacity, radius: 2, offset: .zero, path: { _ in + CGPath(rect: CGRect(x: 0, y: 0, width: width, height: 10), transform: nil) + })).unwrap() + } + try AnimationClock.sharingTime(at: 1000) { + try refresh(renderable, with: item(color: .red, opacity: 0, width: 100), animationTiming: nil) + try refresh(renderable, with: item(color: .blue, opacity: 1, width: 200), animationTiming: .linear(duration: 2)) + } + + // when: an animated update a quarter of the way, where the layer has no presentation layer to read + try AnimationClock.sharingTime(at: 1000.5) { + try refresh(renderable, with: item(color: .green, opacity: 0.5, width: 300), animationTiming: .linear(duration: 2)) + } + + // then: the new animations start from the color, opacity and path the layer shows at the clock's time, instead of + // the model values + let layer = renderable.layer + let colorAnimation = try (layer.animation(forKey: "shadowColor") as? CABasicAnimation).unwrap() + try expectExtendedSRGBComponents(of: colorValue(colorAnimation.fromValue), toBe: [0.75, 0, 0.25, 1]) + let opacityAnimation = try (layer.animation(forKey: "shadowOpacity") as? CABasicAnimation).unwrap() + expect(opacityAnimation.fromValue as? Float) == 0.25 + let pathAnimation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() + expect(try PathPoints(pathValue(pathAnimation.fromValue))) == PathPoints(CGPath(rect: CGRect(x: 0, y: 0, width: 125, height: 10), transform: nil)) + } + + func test_colorsAndShadowPath_animatedUpdate_unevaluableAnimationsWithoutPresentation_startFromTheModelValues() throws { + // given: a layer with red colors and a 100 point wide shadow path, and keyframe animations of each, which the shown + // value can't evaluate, while the layer has no presentation layer to fall back to + let renderable = Renderable.layer(CALayer()) + func path(width: CGFloat) -> CGPath { + CGPath(rect: CGRect(x: 0, y: 0, width: width, height: 10), transform: nil) + } + func item(_ color: Color, pathWidth: CGFloat) throws -> RenderableItem { + try firstRenderableItem(of: LayerNode() + .backgroundColor(color) + .border(color: color, width: 1) + .shadow(color: color, opacity: 1, radius: 2, offset: .zero, path: { _ in path(width: pathWidth) })).unwrap() + } + try refresh(renderable, with: item(.red, pathWidth: 100), animationTiming: nil) + let layer = renderable.layer + for (keyPath, value) in [("backgroundColor", Color.green.cgColor), ("borderColor", Color.green.cgColor), ("shadowColor", Color.green.cgColor), ("shadowPath", path(width: 50))] as [(String, Any)] { + let keyframeAnimation = CAKeyframeAnimation(keyPath: keyPath) + keyframeAnimation.values = [value, value] + keyframeAnimation.duration = 10 + layer.add(keyframeAnimation, forKey: "keyframe-\(keyPath)") + } + + // when: an animated update to blue colors and a 200 point wide shadow path + try refresh(renderable, with: item(.blue, pathWidth: 200), animationTiming: .linear(duration: 2)) + + // then: the new animations start from the model values, instead of from clear and no path + for keyPath in ["backgroundColor", "borderColor", "shadowColor"] { + let animation = try (layer.animation(forKey: keyPath) as? CABasicAnimation).unwrap() + expect(try colorValue(animation.fromValue), keyPath) == Color.red.cgColor + } + let pathAnimation = try (layer.animation(forKey: "shadowPath") as? CABasicAnimation).unwrap() + expect(try pathValue(pathAnimation.fromValue)) == path(width: 100) + } + + func test_shadow_animatedUpdate_withoutShadowColor_animatesTheColorFromClear() throws { + // given: a layer without a shadow color + let layer = CALayer() + layer.shadowColor = nil + let renderable = Renderable.layer(layer) + + // when: an animated update sets a black shadow + try refresh(renderable, with: shadowItem(opacity: 0.5), animationTiming: .linear(duration: 1)) + + // then: the shadow color animates from clear + let animation = try (layer.animation(forKey: "shadowColor") as? CABasicAnimation).unwrap() + expect(try colorValue(animation.fromValue)) == Color.clear.cgColor + } + // MARK: - Helpers /// The renderable item of a layer node with every layer modifier, with the given values. diff --git a/ComposeUI/Tests/ComposeUITests/TestUtilities/AnimationValues.swift b/ComposeUI/Tests/ComposeUITests/TestUtilities/AnimationValues.swift new file mode 100644 index 00000000..b20ffcdd --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/TestUtilities/AnimationValues.swift @@ -0,0 +1,84 @@ +// +// AnimationValues.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 + +// Core Animation gives animation values, presentation values and KVC values as `Any`. A conditional cast to a Core +// Foundation type succeeds for any object, and a forced one crashes on use instead of failing the test, so colors and +// paths are told apart by their type IDs. + +/// The value as a color. +/// +/// - Parameter value: The value. +/// - Returns: The color. +/// - Throws: When the value isn't a color, such as an animation's unresolved from value, `NSNull`. +func colorValue(_ value: Any?, file: StaticString = #filePath, line: UInt = #line) throws -> CGColor { + let color = object(value, withTypeID: CGColor.typeID).map { unsafeDowncast($0, to: CGColor.self) } + return try unwrap(color, "expected a color, got \(String(describing: value))", file: file, line: line) +} + +/// The value as a path. +/// +/// - Parameter value: The value. +/// - Returns: The path. +/// - Throws: When the value isn't a path. +func pathValue(_ value: Any?, file: StaticString = #filePath, line: UInt = #line) throws -> CGPath { + let path = object(value, withTypeID: CGPath.typeID).map { unsafeDowncast($0, to: CGPath.self) } + return try unwrap(path, "expected a path, got \(String(describing: value))", file: file, line: line) +} + +/// The values of a keyframe animation of paths. +/// +/// - Parameter animation: The animation. +/// - Returns: The paths. +/// - Throws: When the animation has no values, or a value isn't a path. +func pathValues(of animation: CAKeyframeAnimation, file: StaticString = #filePath, line: UInt = #line) throws -> [CGPath] { + try unwrap(animation.values, file: file, line: line).map { try pathValue($0, file: file, line: line) } +} + +/// The scalar of a number value, for `CALayer.predictedValue(forKeyPath:at:scalar:)`. +func numberScalar(_ value: Any) throws -> CGFloat { + try CGFloat((value as? NSNumber).unwrap().doubleValue) +} + +/// The scalar of a color value, its blue component in sRGB, for `CALayer.predictedValue(forKeyPath:at:scalar:)`. +func blueScalar(_ value: Any) throws -> CGFloat { + try colorValue(value).sRGBComponents()[2] +} + +/// The value as an object of a Core Foundation type, or `nil` for a value of another type or no value. +private func object(_ value: Any?, withTypeID typeID: CFTypeID) -> AnyObject? { + guard let object = value.map({ $0 as AnyObject }), CFGetTypeID(object) == typeID else { + return nil + } + return object +} diff --git a/ComposeUI/Tests/ComposeUITests/TestUtilities/CALayer+PredictedValue.swift b/ComposeUI/Tests/ComposeUITests/TestUtilities/CALayer+PredictedValue.swift index abd820ca..5a350b62 100644 --- a/ComposeUI/Tests/ComposeUITests/TestUtilities/CALayer+PredictedValue.swift +++ b/ComposeUI/Tests/ComposeUITests/TestUtilities/CALayer+PredictedValue.swift @@ -73,8 +73,8 @@ extension CALayer { /// since `currentTime` holds one time per run loop turn, see `AnimationClock`. /// /// - Important: Core Animation evaluates every presentation layer of a transaction at the time of the transaction's - /// first presentation read, until it commits, so the read must be the first since a run loop turn or a - /// `CATransaction.flush()`. + /// first presentation read of a layer with animations, until it commits, so the read must be the first such read + /// since a run loop turn or a `CATransaction.flush()`. /// /// - Parameter read: Reads the values from the presentation layer. /// - Returns: The values, and the times before and after the read, in the layer's time space. @@ -113,8 +113,3 @@ extension CALayer { expect(shown, description, file: file, line: line) <= max(earlier, later) + tolerance } } - -/// The scalar of a number value, for `predictedValue(forKeyPath:at:scalar:)`. -func numberScalar(_ value: Any) throws -> CGFloat { - try CGFloat((value as? NSNumber).unwrap().doubleValue) -} diff --git a/ComposeUI/Tests/ComposeUITests/TestUtilities/CGColor+Components.swift b/ComposeUI/Tests/ComposeUITests/TestUtilities/CGColor+Components.swift new file mode 100644 index 00000000..6db25861 --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/TestUtilities/CGColor+Components.swift @@ -0,0 +1,76 @@ +// +// CGColor+Components.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 CoreGraphics + +import ChouTiTest + +extension CGColor { + + /// The color's red, green, blue and alpha components in sRGB, which clamps them to [0, 1]. + func sRGBComponents() throws -> [CGFloat] { + try components(in: CGColorSpace.sRGB) + } + + /// The color's red, green, blue and alpha components in extended sRGB, the color space Core Animation interpolates + /// colors in. + func extendedSRGBComponents() throws -> [CGFloat] { + try components(in: CGColorSpace.extendedSRGB) + } + + private func components(in spaceName: CFString) throws -> [CGFloat] { + let space = try unwrap(CGColorSpace(name: spaceName)) + return try unwrap(converted(to: space, intent: .defaultIntent, options: nil)?.components) + } +} + +/// The components a progress of the way between two colors' extended sRGB components. +func interpolatedExtendedSRGBComponents(from: CGColor, to: CGColor, progress: CGFloat) throws -> [CGFloat] { + try zip(from.extendedSRGBComponents(), to.extendedSRGBComponents()).map { $0 + ($1 - $0) * progress } +} + +/// Expects a color's extended sRGB components, within a tolerance. +/// +/// - Parameters: +/// - color: The color. +/// - expected: The expected red, green, blue and alpha components in extended sRGB. +/// - tolerance: How far each component may be from the expected one. +func expectExtendedSRGBComponents(of color: CGColor, + toBe expected: [CGFloat], + within tolerance: CGFloat = 1e-6, + file: StaticString = #filePath, + line: UInt = #line) throws +{ + let components = try color.extendedSRGBComponents() + expect(components.count, "\(components)", file: file, line: line) == expected.count + for (component, expectedComponent) in zip(components, expected) { + expect(component, "\(components)", file: file, line: line).to(beApproximatelyEqual(to: expectedComponent, within: tolerance)) + } +} diff --git a/ComposeUI/Tests/ComposeUITests/TestUtilities/PausedLayerRenderer.swift b/ComposeUI/Tests/ComposeUITests/TestUtilities/PausedLayerRenderer.swift index 35d9f70d..bbb66660 100644 --- a/ComposeUI/Tests/ComposeUITests/TestUtilities/PausedLayerRenderer.swift +++ b/ComposeUI/Tests/ComposeUITests/TestUtilities/PausedLayerRenderer.swift @@ -93,8 +93,13 @@ final class PausedLayerRenderer { CATransaction.flush() } - /// The opacity the white layer shows at the current time. - func renderedOpacity() -> Double { + /// The opacity the white layer shows at the current time, at a pixel of the 4 by 4 render. + /// + /// - Parameters: + /// - x: The pixel's column. The default is inside the layer's rounded corners. + /// - y: The pixel's row. + /// - Returns: The opacity. + func renderedOpacity(x: Int = 1, y: Int = 1) -> Double { commit() renderer.beginFrame(atTime: CACurrentMediaTime(), timeStamp: nil) @@ -108,7 +113,7 @@ final class PausedLayerRenderer { fence?.waitUntilCompleted() var pixel = [UInt8](repeating: 0, count: 4) - texture.getBytes(&pixel, bytesPerRow: 16, from: MTLRegionMake2D(1, 1, 1, 1), mipmapLevel: 0) + texture.getBytes(&pixel, bytesPerRow: 16, from: MTLRegionMake2D(x, y, 1, 1), mipmapLevel: 0) return Double(pixel[2]) / 255 // BGRA: the red channel } } diff --git a/ComposeUI/Tests/ComposeUITests/TestUtilities/TimeConversionCountingLayer.swift b/ComposeUI/Tests/ComposeUITests/TestUtilities/TimeConversionCountingLayer.swift new file mode 100644 index 00000000..66f77975 --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/TestUtilities/TimeConversionCountingLayer.swift @@ -0,0 +1,43 @@ +// +// TimeConversionCountingLayer.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/29/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 + +/// A layer that counts its time conversions, to verify which reads skip the layer's current time, which converts the +/// time through every layer up the tree. +final class TimeConversionCountingLayer: CALayer { + + private(set) var timeConversionCount = 0 + + override func convertTime(_ time: CFTimeInterval, from layer: CALayer?) -> CFTimeInterval { + timeConversionCount += 1 + return super.convertTime(time, from: layer) + } +}