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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<T>`, 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

Expand All @@ -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
Expand All @@ -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.
117 changes: 117 additions & 0 deletions ComposeUI/Sources/ComposeUI/Animations/AdditiveValue.swift
Original file line number Diff line number Diff line change
@@ -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<Double>

/// 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<Double>) {
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)
}
}
103 changes: 103 additions & 0 deletions ComposeUI/Sources/ComposeUI/Animations/AnimationInterpolation.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading