Skip to content
21 changes: 15 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,19 @@ The render pipeline: a `ComposeView` hosts `ComposeContent`, lays out `ComposeNo
- Place a new `ComposeNode` extension API in a dedicated file named after its concern (for example `ComposeNode+Transform.swift`), not in an unrelated extension file.
- Comments must explain the decision, not just state a fact: prefer "X can happen, so we do Y (instead of Z)" over "X can happen".
- No em-dashes (—) and no semicolons in code comments or markdown docs. Use commas, hyphens, colons, or separate sentences.
- Use `private enum Constants` at the bottom of the file for repeated literals and magic numbers where it improves clarity. For example:
- Use `private enum Constants` for repeated literals and magic numbers where it improves clarity, nested at the bottom of the primary scope. A top-level private `Constants` collides with the module's public `Constants` type. For example:
```swift
// MARK: - Constants
public class SomeType {

private enum Constants {
// APIs...

/// The spacing between the items.
static let spacing: CGFloat = 8
// MARK: - Constants

private enum Constants {

/// The spacing between the items.
static let spacing: CGFloat = 8
}
}
```

Expand All @@ -83,7 +88,7 @@ The render pipeline: a `ComposeView` hosts `ComposeContent`, lays out `ComposeNo
Before reporting a change complete, verify in order:

1. Focused tests pass: `cd ComposeUI && swift test --filter <TestCase>`.
2. New code has full test coverage, including guard/assertion paths and both branches of conditionals. Verify with `swift test --enable-code-coverage` + `xcrun llvm-cov report` on the touched files.
2. New code MUST have full test coverage, including guard/assertion paths and both branches of conditionals. Enumerate and test the logic's edge cases (boundary values, zero or empty inputs, interrupted in-flight states) and assert their observable outcomes. Full coverage must fall out of covering every case, not be the goal itself: a test can execute many lines without checking any corner case. Verify with `swift test --enable-code-coverage` + `xcrun llvm-cov report` on the touched files.
3. `make format` and `make lint` pass.
4. Cross-platform changes: both `AppKit` and `UIKit` conditional compilation paths build and are exercised by platform tests.
5. User-facing behavior changes have an entry under `Unreleased` in `CHANGELOG.md`.
Expand Down Expand Up @@ -130,6 +135,10 @@ Hard-won rules from past corrections, grouped by theme.
- On few-core CI runners, do not overlap simulator boot with compilation. Both are CPU-heavy, and contention makes the total slower than running them serially (build first, then boot).
- To get CI telemetry without log access, emit `::notice::` workflow commands. They become check-run annotations readable via the public Checks API (capped at 10 annotations per step, so emit before noisy output).

## Cross-platform

- Do not ship platform-dependent rendered output with a doc note. When a platform primitive differs (for example AppKit anchors view-backing layers at the bottom left corner while everything else anchors at the center), compensate in the implementation so the visual result matches across platforms, instead of documenting the difference.

## Scripts

- When rewriting or porting a script, audit the new version against the original behavior by behavior (selection logic, guard conditions, exit codes, environment propagation, output ordering), and disclose every intentional deviation. Do not assume a rewrite is equivalent because the happy path passes.
Expand Down
23 changes: 7 additions & 16 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,16 @@

### Breaking Changes

- Delayed animations are now scheduled with Core Animation's `beginTime` instead of a GCD timer. The animation is added
and the model value is set at dispatch, the layer's rendered output holds the pre-animation state for the delay window.
An interrupted in-flight opacity transition freezes at its sampled value for a delayed retargeting's delay window
instead of continuing to play, and a delayed spring retargeting launches from rest.
- Zero-duration transitions now call their completion. Without a delay, the end state applies and the completion runs
immediately. With a delay, the change is scheduled as a snap that applies right after the delay window. A transition
completion is also called when its animation is torn down before finishing (superseded, reset, or the layer leaving
the layer tree).
- Delayed animations are now scheduled with Core Animation's `beginTime` instead of a GCD timer.
- Zero-duration transitions now call their completion, and a completion is also called when its animation is torn down early.

### Changes

- Slide transitions now continue a revival from wherever the removal left the renderable, for any side configuration:
the `from` side applies only to fresh insertions, and a renderable that fully slid out re-enters from its exit side.
The insert transition context gains `revivalPosition`, the model position captured for taking-over transitions.
- `ComposeView` now adopts display scale changes on iOS/tvOS (for example, when the window moves to a screen with a
different scale) and re-renders, matching the existing macOS backing scale handling.
- `ComposeView.setNeedsRefresh(animated:)` now merges coalesced requests to non-animated when any request was
non-animated (previously the last request's flag won), so a scale-driven or window-driven snap is never animated by a
concurrent theme change.
- Added a scale transition, `.scale(from:anchor:timing:options:)`.
- Slide transitions now continue a revival from wherever the removal left the renderable.
- The insert transition context gains `revivalPosition` and `revivalTransform` for taking-over transitions.
- `ComposeView` now re-renders on display scale changes on iOS/tvOS, matching the existing macOS handling.
- `ComposeView.setNeedsRefresh(animated:)` now merges coalesced requests to non-animated when any request was non-animated.

## [0.0.5](https://github.com/honghaoz/ComposeUI/releases/tag/0.0.5) (2026-08-08)

Expand Down
10 changes: 10 additions & 0 deletions ComposeUI/Sources/ComposeUI/Animations/CALayer+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ extension CALayer {
)
}

/// Restores the layer's model transform to identity.
func restoreIdentityTransformIfNeeded() {
guard !CATransform3DIsIdentity(transform) else {
return
}
disableActions(for: "transform") {
transform = CATransform3DIdentity
}
}

/// Moves the sublayer to the front.
///
/// - Parameter sublayer: The sublayer to move to the front.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,9 @@ public extension RenderableTransition {

/// Creates an opacity transition.
///
/// The transition keeps a single opacity animation on the renderable: starting a transition while another one is in
/// flight replaces the in-flight animation with one that continues from the current visual opacity (and, for spring
/// timings, the current velocity).
/// Stacking is not an option for opacity because the render server clamps opacity per animation while compositing
/// additive animations, so opposing stacked animations do not compose (the screen diverges from the unclamped sum
/// that `presentation()` reports).
/// For insertion, the renderable fades from `from` to `to`. For removal, the renderable fades from its current
/// opacity back to `from`. Starting a transition while another one is in flight continues from the current visual
/// opacity.
///
/// - Parameters:
/// - from: The starting opacity value.
Expand All @@ -52,16 +49,19 @@ public extension RenderableTransition {
options: RenderableTransition.Options = .both) -> RenderableTransition
{
RenderableTransition(
insert: options.contains(.insert) ? InsertTransition(takesOverKeyPaths: ["opacity"]) { renderable, context, completion in
renderable.setFrame(context.targetFrame)

renderable.layer.retargetOpacity(
freshStartValue: Float(from),
targetValue: Float(to),
timing: timing,
completion: completion
)
} : nil,
insert: options.contains(.insert) ? InsertTransition(
takesOverKeyPaths: ["opacity"],
animate: { renderable, context, completion in
renderable.setFrame(context.targetFrame)

renderable.layer.retargetOpacity(
freshStartValue: Float(from),
targetValue: Float(to),
timing: timing,
completion: completion
)
}
) : nil,
remove: options.contains(.remove) ? RemoveTransition(
animatedKeyPaths: ["opacity"],
animate: { renderable, _, completion in
Expand Down Expand Up @@ -128,6 +128,10 @@ private extension CALayer {
/// with the current velocity, through the spring's initial velocity. Without an in-flight transition, the new
/// animation starts from `freshStartValue`.
///
/// Retargeting replaces the in-flight animations instead of stacking on them, because the render server clamps
/// opacity per animation while compositing additive animations, so opposing stacked animations do not compose (the
/// screen diverges from the unclamped sum that `presentation()` reports).
///
/// The timing's delay schedules the new animation's begin time: the interrupted state is evaluated when the
/// retargeting is dispatched, and the animation holds its start value until the delay elapses, so an interrupted
/// in-flight animation freezes at its sampled value for the delay window.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
//
// RenderableTransition+Scale.swift
// ComposéUI
//
// Created by Honghao Zhang on 9/1/26.
// Copyright © 2024 Honghao Zhang.
//
// MIT License
//
// Copyright (c) 2024 Honghao Zhang (github.com/honghaoz)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//

import QuartzCore

public extension RenderableTransition {

/// Creates a scale transition.
///
/// For insertion, the renderable starts at `from` scale and scales to its natural size at `targetFrame`. For removal,
/// the renderable scales from its current scale down to `from`. The scaling pivots about `anchor` for any layer
/// anchor point.
///
/// The transition owns the layer's transform and assumes no other writer touches it. Reviving a renderable while its
/// scale-out is in flight continues the motion from the removal's in-flight state. A zero-duration timing applies the
/// end state and completes immediately, or right after the delay window when the timing has a delay.
///
/// - Parameters:
/// - from: The scale to insert from and remove to. Values above 1 zoom down into place. Defaults to 0.
/// - anchor: The point of the renderable that stays fixed while it scales. Defaults to `.center`.
/// - timing: The timing of the scale transition.
/// - options: The options for the scale transition.
static func scale(from: CGFloat = 0,
anchor: Layout.Alignment = .center, // TODO: replace with https://github.com/honghaoz/ChouTiUI/blob/master/ChouTiUI/Sources/ChouTiUI/Universal/Layout/UnitPoint.swift
timing: AnimationTiming = .spring(),
options: RenderableTransition.Options = .both) -> Self
{
RenderableTransition(
insert: options.contains(.insert) ? InsertTransition(
takesOverKeyPaths: Constants.animatedKeyPaths,
animate: { renderable, context, completion in
let layer = renderable.layer
ComposeUI.assert(CATransform3DIsIdentity(layer.transform), "scale insert transition requires an identity model transform")

guard timing.timing.duration > 0 || timing.delay > 0 else {
if context.revivalTransform != nil {
// the taken-over leftover exit animations would render the snapped model off the resting scale until they
// decay, so a snap clears them
for keyPath in Constants.animatedKeyPaths {
layer.removeAnimations(forKeyPath: keyPath)
}
}
renderable.setFrame(context.targetFrame)
completion()
return
}

let startScale: CGFloat
let startTranslation: CGSize
if let revivalTransform = context.revivalTransform {
// a revival continues from the removal's model transform: the offsets from its scale and translation
// components cancel the model change exactly, so the rendered transform doesn't move at the revival instant,
// and the removal's leftover offsets keep decaying on top.
// the transition owns the transform and writes it through the "transform.scale" and "transform.translation"
// key paths, so the captured transform's x scale and translation components are those written values.
startScale = revivalTransform.m11
startTranslation = CGSize(width: revivalTransform.m41, height: revivalTransform.m42)
} else {
startScale = from
startTranslation = layer.pivotTranslation(towards: anchor.unitPoint, for: from, size: context.targetFrame.size)
Comment thread
honghaoz marked this conversation as resolved.
}

renderable.setFrame(context.targetFrame)

layer.animate(
keyPath: "transform.scale",
timing: timing,
from: { _ in startScale - 1 },
Comment thread
honghaoz marked this conversation as resolved.
to: { _ in CGFloat(0) },
model: { _ in CGFloat(1) },
updateAnimation: {
$0.isAdditive = true
$0.delegate = AnimationDelegate(animationDidStop: { _, _ in
completion()
})
}
)

// the pair shares the scale animation's timing, so the compensation tracks the rendered scale exactly and the
// completion can ride on the scale animation alone
if startTranslation != .zero {
layer.animate(
keyPath: "transform.translation",
timing: timing,
from: { _ in startTranslation },
to: { _ in CGSize.zero },
model: { _ in CGSize.zero },
updateAnimation: {
$0.isAdditive = true
}
)
}
}
) : nil,
remove: options.contains(.remove) ? RemoveTransition(
animatedKeyPaths: Constants.animatedKeyPaths,
animate: { renderable, _, completion in
let layer = renderable.layer
let endTranslation = layer.pivotTranslation(towards: anchor.unitPoint, for: from, size: layer.bounds.size)

guard timing.timing.duration > 0 || timing.delay > 0 else {
layer.setKeyPathValue("transform.scale", from)
if endTranslation != .zero {
layer.setKeyPathValue("transform.translation", endTranslation)
}
completion()
return
}

layer.animate(
keyPath: "transform.scale",
to: from,
timing: timing,
updateAnimation: {
$0.delegate = AnimationDelegate(animationDidStop: { _, _ in
completion()
})
}
)

if endTranslation != .zero {
layer.animate(keyPath: "transform.translation", to: endTranslation, timing: timing)
}
},
resetForReuse: { renderable in
renderable.layer.restoreIdentityTransformIfNeeded()
}
) : nil
)
}

// MARK: - Constants

/// The enum is nested so it doesn't collide with the module's public `Constants` type.
private enum Constants {

/// The root-layer key paths the scale transition animates.
static let animatedKeyPaths: Set<String> = ["transform.scale", "transform.translation"]
}
}

private extension Layout.Alignment {

/// The alignment as a point in the unit coordinate space, with the origin at the top left.
var unitPoint: CGPoint {
switch self {
case .center:
return CGPoint(x: 0.5, y: 0.5)
case .left:
return CGPoint(x: 0, y: 0.5)
case .right:
return CGPoint(x: 1, y: 0.5)
case .top:
return CGPoint(x: 0.5, y: 0)
case .bottom:
return CGPoint(x: 0.5, y: 1)
case .topLeft:
return CGPoint(x: 0, y: 0)
case .topRight:
return CGPoint(x: 1, y: 0)
case .bottomLeft:
return CGPoint(x: 0, y: 1)
case .bottomRight:
return CGPoint(x: 1, y: 1)
}
}
}

private extension CALayer {

/// The translation that keeps the layer's `pivot` unit point fixed when it renders at `scale`.
///
/// The transform applies about the layer's `anchorPoint`, so scaling shifts every point except the anchor. The
/// compensation is the pivot's offset from the anchor, `(pivot - anchorPoint) * size`, scaled by how far the scale
/// is from resting.
///
/// - Parameters:
/// - pivot: The unit point to keep fixed.
/// - scale: The rendered scale.
/// - size: The layer's rendered size.
/// - Returns: The compensating translation. Zero when the pivot coincides with the layer's anchor point.
func pivotTranslation(towards pivot: CGPoint, for scale: CGFloat, size: CGSize) -> CGSize {
CGSize(
width: (pivot.x - anchorPoint.x) * size.width * (1 - scale),
height: (pivot.y - anchorPoint.y) * size.height * (1 - scale)
)
}
}
Loading
Loading