From 8febec06698c6e97504856bcab5cd2c92f3b56ee Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 21 Jan 2026 01:07:04 +0800 Subject: [PATCH 1/6] Add AsymmetricTransition support --- .../Transition/AsymmetricTransition.swift | 112 ++++++++++++++++++ .../Animation/Transition/Transition.swift | 12 +- 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 Sources/OpenSwiftUICore/Animation/Transition/AsymmetricTransition.swift diff --git a/Sources/OpenSwiftUICore/Animation/Transition/AsymmetricTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/AsymmetricTransition.swift new file mode 100644 index 000000000..ae9d29a98 --- /dev/null +++ b/Sources/OpenSwiftUICore/Animation/Transition/AsymmetricTransition.swift @@ -0,0 +1,112 @@ +// +// AsymmetricTransition.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: 144244338250150A46EBD0B28C550067 (SwiftUICore) + +// MARK: - AnyTransition + asymmetric + +@available(OpenSwiftUI_v1_0, *) +extension AnyTransition { + + /// Provides a composite transition that uses a different transition for + /// insertion versus removal. + public static func asymmetric(insertion: AnyTransition, removal: AnyTransition) -> AnyTransition { + var insertionVisitor = InsertionVisitor(removal: removal, result: nil) + insertion.visitBase(applying: &insertionVisitor) + return insertionVisitor.result! + } + + private struct InsertionVisitor: TransitionVisitor { + var removal: AnyTransition + var result: AnyTransition? + + mutating func visit(_ transition: T) where T: Transition { + var removalVisitor = RemovalVisitor(insertion: transition, result: nil) + removal.visitBase(applying: &removalVisitor) + result = removalVisitor.result + } + } + + private struct RemovalVisitor: TransitionVisitor where Insertion: Transition { + let insertion: Insertion + var result: AnyTransition? + + mutating func visit(_ transition: T) where T: Transition { + result = AnyTransition(AsymmetricTransition(insertion: insertion, removal: transition)) + } + } +} + +// MARK: - AsymmetricTransition + +/// A composite `Transition` that uses a different transition for +/// insertion versus removal. +@available(OpenSwiftUI_v5_0, *) +public struct AsymmetricTransition: Transition where Insertion: Transition, Removal: Transition { + /// The `Transition` defining the insertion phase of `self`. + public var insertion: Insertion + + /// The `Transition` defining the removal phase of `self`. + public var removal: Removal + + /// Creates a composite `Transition` that uses a different transition for + /// insertion versus removal. + public init(insertion: Insertion, removal: Removal) { + self.insertion = insertion + self.removal = removal + } + + public func body(content: Content, phase: TransitionPhase) -> some View { + removal.apply( + content: insertion.apply( + content: content, + phase: phase != .didDisappear ? phase : .identity + ), + phase: phase == .didDisappear ? phase : .identity + ) + } + + public static var properties: TransitionProperties { + Insertion.properties.union(Removal.properties) + } + + public func _makeContentTransition(transition: inout _Transition_ContentTransition) { + switch transition.operation { + case .hasContentTransition: + transition.result = .bool(insertion.hasContentTransition || removal.hasContentTransition) + case .effects(let style, let size): + var effects: [ContentTransition.Effect] = [] + let insertionEffects = insertion.contentTransitionEffects(style: style, size: size) + for effect in insertionEffects { + effects.append( + ContentTransition.Effect( + type: effect.type, + begin: effect.begin, + duration: effect.duration, + events: .add, + flags: effect.flags + ) + ) + } + let removalEffects = removal.contentTransitionEffects(style: style, size: size) + for effect in removalEffects { + effects.append( + ContentTransition.Effect( + type: effect.type, + begin: effect.begin, + duration: effect.duration, + events: .remove, + flags: effect.flags + ) + ) + } + transition.result = .effects(effects) + } + } +} + +@available(*, unavailable) +extension AsymmetricTransition: Sendable {} diff --git a/Sources/OpenSwiftUICore/Animation/Transition/Transition.swift b/Sources/OpenSwiftUICore/Animation/Transition/Transition.swift index 0a9356e13..586931d84 100644 --- a/Sources/OpenSwiftUICore/Animation/Transition/Transition.swift +++ b/Sources/OpenSwiftUICore/Animation/Transition/Transition.swift @@ -117,10 +117,10 @@ extension Transition { package var hasContentTransition: Bool { var contentTransition = _Transition_ContentTransition(operation: .hasContentTransition, result: .none) _makeContentTransition(transition: &contentTransition) - return switch contentTransition.result { - case let .bool(result): result - default: false + guard case let .bool(result) = contentTransition.result else { + return false } + return result } package func contentTransitionEffects( @@ -129,10 +129,10 @@ extension Transition { ) -> [ContentTransition.Effect] { var contentTransition = _Transition_ContentTransition(operation: .effects(style: style, size: size), result: .none) _makeContentTransition(transition: &contentTransition) - return switch contentTransition.result { - case let .effects(effects): effects - default: [] + guard case let .effects(effects) = contentTransition.result else { + return [] } + return effects } } From 81bb9e520be7001dc55afbb5d3bffd4987a29be2 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 21 Jan 2026 01:13:57 +0800 Subject: [PATCH 2/6] Add ModifierTransition --- .../Transition/ModifierTransition.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Sources/OpenSwiftUICore/Animation/Transition/ModifierTransition.swift diff --git a/Sources/OpenSwiftUICore/Animation/Transition/ModifierTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/ModifierTransition.swift new file mode 100644 index 000000000..ac2323233 --- /dev/null +++ b/Sources/OpenSwiftUICore/Animation/Transition/ModifierTransition.swift @@ -0,0 +1,44 @@ +// +// ModifierTransition.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +// MARK: - AnyTransition + modifier + +@available(OpenSwiftUI_v1_0, *) +extension AnyTransition { + + /// Returns a transition defined between an active modifier and an identity + /// modifier. + public static func modifier(active: E, identity: E) -> AnyTransition where E: ViewModifier { + .init(ModifierTransition(activeModifier: active, identityModifier: identity)) + } +} + +// MARK: - ModifierTransition + +/// A transition defined between an active modifier and an identity modifier. +struct ModifierTransition: Transition where Modifier: ViewModifier { + /// The modifier applied when the view is not in the identity phase. + var activeModifier: Modifier + + /// The modifier applied when the view is in the identity phase. + var identityModifier: Modifier + + /// Creates a transition defined between an active modifier and an identity + /// modifier. + init(activeModifier: Modifier, identityModifier: Modifier) { + self.activeModifier = activeModifier + self.identityModifier = identityModifier + } + + func body(content: Content, phase: TransitionPhase) -> some View { + content.modifier(phase.isIdentity ? identityModifier : activeModifier) + } +} + +@available(*, unavailable) +extension ModifierTransition: Sendable {} + From 8bcd38dd314f1bfb0b81ba5863b7fa1c2b69e886 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 21 Jan 2026 01:26:33 +0800 Subject: [PATCH 3/6] Add CombiningTransition --- .../Transition/CombiningTransition.swift | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 Sources/OpenSwiftUICore/Animation/Transition/CombiningTransition.swift diff --git a/Sources/OpenSwiftUICore/Animation/Transition/CombiningTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/CombiningTransition.swift new file mode 100644 index 000000000..0348dcbb4 --- /dev/null +++ b/Sources/OpenSwiftUICore/Animation/Transition/CombiningTransition.swift @@ -0,0 +1,93 @@ +// +// CombiningTransition.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: E95479797AFE5A67B59EE39088DDE631 (SwiftUICore) + +// MARK: - AnyTransition + combined + +@available(OpenSwiftUI_v1_0, *) +extension AnyTransition { + + /// Combines this transition with another, returning a new transition that + /// is the result of both transitions being applied. + public func combined(with other: AnyTransition) -> AnyTransition { + var firstVisitor = FirstVisitor(second: other, result: nil) + visitBase(applying: &firstVisitor) + return firstVisitor.result! + } + + private struct FirstVisitor: TransitionVisitor { + var second: AnyTransition + var result: AnyTransition? + + mutating func visit(_ transition: T) where T: Transition { + var secondVisitor = SecondVisitor(first: transition, result: nil) + second.visitBase(applying: &secondVisitor) + result = secondVisitor.result + } + } + + private struct SecondVisitor: TransitionVisitor where First: Transition { + let first: First + var result: AnyTransition? + + mutating func visit(_ transition: T) where T: Transition { + result = AnyTransition(CombiningTransition(transition1: first, transition2: transition)) + } + } +} + +// MARK: - Transition + combined + +@available(OpenSwiftUI_v5_0, *) +extension Transition { + + /// Combines this transition with another, returning a new transition that + /// is the result of both transitions being applied. + @MainActor + @preconcurrency + public func combined(with other: T) -> some Transition where T: Transition { + CombiningTransition(transition1: self, transition2: other) + } +} + +// MARK: - CombiningTransition + +/// A transition that combines two transitions. +struct CombiningTransition: Transition where First: Transition, Second: Transition { + var transition1: First + var transition2: Second + + init(transition1: First, transition2: Second) { + self.transition1 = transition1 + self.transition2 = transition2 + } + + func body(content: Content, phase: TransitionPhase) -> some View { + transition2.apply( + content: transition1.apply(content: content, phase: phase), + phase: phase + ) + } + + static var properties: TransitionProperties { + First.properties.union(Second.properties) + } + + func _makeContentTransition(transition: inout _Transition_ContentTransition) { + switch transition.operation { + case .hasContentTransition: + transition.result = .bool(transition1.hasContentTransition || transition2.hasContentTransition) + case .effects(let style, let size): + var effects: [ContentTransition.Effect] = transition1.contentTransitionEffects(style: style, size: size) + effects.append(contentsOf: transition2.contentTransitionEffects(style: style, size: size)) + transition.result = .effects(effects) + } + } +} + +@available(*, unavailable) +extension CombiningTransition: Sendable {} \ No newline at end of file From c2ad0f8b658f08197fc04a8f4da0629a9bcef02a Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 21 Jan 2026 01:45:43 +0800 Subject: [PATCH 4/6] Add FilteredTransition --- .../Transition/FilteredTransition.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 Sources/OpenSwiftUICore/Animation/Transition/FilteredTransition.swift diff --git a/Sources/OpenSwiftUICore/Animation/Transition/FilteredTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/FilteredTransition.swift new file mode 100644 index 000000000..842fae7d9 --- /dev/null +++ b/Sources/OpenSwiftUICore/Animation/Transition/FilteredTransition.swift @@ -0,0 +1,81 @@ +// +// FilteredTransition.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: B9F0F810276E171D84377A7686E819B9 (SwiftUICore) + +// MARK: - AnyTransition + animation + +@available(OpenSwiftUI_v1_0, *) +extension AnyTransition { + + /// Attaches an animation to this transition. + public func animation(_ animation: Animation?) -> AnyTransition { + var filterVisitor = FilterVisitor(filter: { t, _ in + t.animation = animation + }, result: nil) + visitBase(applying: &filterVisitor) + return filterVisitor.result! + } + + private struct FilterVisitor: TransitionVisitor { + var filter: (inout Transaction, TransitionPhase) -> Void + var result: AnyTransition? + + mutating func visit(_ transition: T) where T: Transition { + result = AnyTransition(FilteredTransition(transition: transition, filter: filter)) + } + } +} + +// MARK: - Transition + animation + +@available(OpenSwiftUI_v5_0, *) +extension Transition { + + /// Attaches an animation to this transition. + @MainActor + @preconcurrency + public func animation(_ animation: Animation?) -> some Transition { + transaction { t, _ in + t.animation = animation + } + } + + func transaction(_ modify: @escaping (inout Transaction, TransitionPhase) -> Void) -> FilteredTransition { + FilteredTransition(transition: self, filter: modify) + } +} + +// MARK: - FilteredTransition + +/// A transition that applies a transaction filter. +struct FilteredTransition: Transition where Base: Transition { + var transition: Base + var filter: (inout Transaction, TransitionPhase) -> Void + + init(transition: Base, filter: @escaping (inout Transaction, TransitionPhase) -> Void) { + self.transition = transition + self.filter = filter + } + + func body(content: Content, phase: TransitionPhase) -> some View { + content.modifier( + ApplyTransitionModifier(transition: transition, phase: phase) + .transaction { filter(&$0, phase) } + ) + } + + static var properties: TransitionProperties { + Base.properties + } + + func _makeContentTransition(transition: inout _Transition_ContentTransition) { + self.transition._makeContentTransition(transition: &transition) + } +} + +@available(*, unavailable) +extension FilteredTransition: Sendable {} From 41e2b6b6c6b5362685b80a3ca28b45d9cd544c09 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 21 Jan 2026 02:16:52 +0800 Subject: [PATCH 5/6] Add PushTransition --- .../Animation/Transition/MoveTransition.swift | 2 +- .../Animation/Transition/PushTransition.swift | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 Sources/OpenSwiftUICore/Animation/Transition/PushTransition.swift diff --git a/Sources/OpenSwiftUICore/Animation/Transition/MoveTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/MoveTransition.swift index 26c54702b..da1649320 100644 --- a/Sources/OpenSwiftUICore/Animation/Transition/MoveTransition.swift +++ b/Sources/OpenSwiftUICore/Animation/Transition/MoveTransition.swift @@ -95,7 +95,7 @@ extension MoveTransition: Sendable {} extension Edge { @inline(__always) - fileprivate func translationOffset(for size: CGSize) -> CGSize { + func translationOffset(for size: CGSize) -> CGSize { switch self { case .top: return CGSize(width: 0, height: -size.height) diff --git a/Sources/OpenSwiftUICore/Animation/Transition/PushTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/PushTransition.swift new file mode 100644 index 000000000..ca171aa11 --- /dev/null +++ b/Sources/OpenSwiftUICore/Animation/Transition/PushTransition.swift @@ -0,0 +1,106 @@ +// +// PushTransition.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +import OpenCoreGraphicsShims + +// MARK: - AnyTransition + push + +@available(OpenSwiftUI_v4_0, *) +extension AnyTransition { + + /// Creates a transition that when added to a view will animate the + /// view's insertion by moving it in from the specified edge while + /// fading it in, and animate its removal by moving it out towards + /// the opposite edge and fading it out. + /// + /// - Parameters: + /// - edge: the edge from which the view will be animated in. + /// + /// - Returns: A transition that animates a view by moving and + /// fading it. + public static func push(from edge: Edge) -> AnyTransition { + .init(PushTransition(edge: edge)) + } +} + +// MARK: - Transition + push + +@available(OpenSwiftUI_v5_0, *) +extension Transition where Self == PushTransition { + + /// Creates a transition that when added to a view will animate the + /// view's insertion by moving it in from the specified edge while + /// fading it in, and animate its removal by moving it out towards + /// the opposite edge and fading it out. + /// + /// - Parameters: + /// - edge: the edge from which the view will be animated in. + /// + /// - Returns: A transition that animates a view by moving and + /// fading it. + @_alwaysEmitIntoClient + @MainActor + @preconcurrency + public static func push(from edge: Edge) -> Self { + Self(edge: edge) + } +} + +// MARK: - PushTransition + +/// A transition that when added to a view will animate the view's insertion by +/// moving it in from the specified edge while fading it in, and animate its +/// removal by moving it out towards the opposite edge and fading it out. +@available(OpenSwiftUI_v5_0, *) +public struct PushTransition: Transition { + + /// The edge from which the view will be animated in. + public var edge: Edge + + /// Creates a transition that animates a view by moving and fading it. + public init(edge: Edge) { + self.edge = edge + } + + public func body(content: Content, phase: TransitionPhase) -> some View { + let moveEdge: Edge? = switch phase { + case .willAppear: edge + case .identity: nil + case .didDisappear: edge.opposite + } + content + .modifier(MoveTransition.MoveLayout(edge: moveEdge)) + .opacity(phase.isIdentity ? 1 : 0) + } + + public func _makeContentTransition(transition: inout _Transition_ContentTransition) { + guard case let .effects(style, size) = transition.operation else { + transition.result = .bool(true) + return + } + var effectiveSize = edge.translationOffset(for: size) + if style != .default { + effectiveSize.width *= 0.4 + effectiveSize.height *= 0.4 + } + let insertionEffect = ContentTransition.Effect( + .translation(effectiveSize), + appliesOnInsertion: true, + appliesOnRemoval: false + ) + let removalEffect = ContentTransition.Effect( + .translation(-effectiveSize), + appliesOnInsertion: false, + appliesOnRemoval: true + ) + let opacityEffect = ContentTransition.Effect(.opacity) + transition.result = .effects([insertionEffect, removalEffect, opacityEffect]) + } +} + +@available(*, unavailable) +extension PushTransition: Sendable {} From 697c240348e93f8060ca7388c406368e808cea9e Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 21 Jan 2026 02:31:51 +0800 Subject: [PATCH 6/6] Add OffsetTransition --- .../Render/GeometryEffect/OffsetEffect.swift | 141 +++++++++++++++--- .../{ => RendererEffect}/OpacityEffect.swift | 0 2 files changed, 123 insertions(+), 18 deletions(-) rename Sources/OpenSwiftUICore/Render/{ => RendererEffect}/OpacityEffect.swift (100%) diff --git a/Sources/OpenSwiftUICore/Render/GeometryEffect/OffsetEffect.swift b/Sources/OpenSwiftUICore/Render/GeometryEffect/OffsetEffect.swift index 55e01e2bd..96fafe206 100644 --- a/Sources/OpenSwiftUICore/Render/GeometryEffect/OffsetEffect.swift +++ b/Sources/OpenSwiftUICore/Render/GeometryEffect/OffsetEffect.swift @@ -2,12 +2,15 @@ // OffsetEffect.swift // OpenSwiftUICore // -// Status: Complete +// Audited for 6.5.4 +// Status: Blocked by appearanceAnimation // ID: 72FB21917F353796516DFC9915156779 (SwiftUICore) public import OpenCoreGraphicsShims import OpenAttributeGraphShims +// MARK: - _OffsetEffect + /// Allows you to redefine origin of the child within its coordinate /// space @available(OpenSwiftUI_v1_0, *) @@ -21,12 +24,11 @@ public struct _OffsetEffect: GeometryEffect, Equatable { } public func effectValue(size: CGSize) -> ProjectionTransform { - ProjectionTransform( - CGAffineTransform( - translationX: offset.width, - y: offset.height - ) + let transform = CGAffineTransform( + translationX: offset.width, + y: offset.height ) + return ProjectionTransform(transform) } public var animatableData: CGSize.AnimatableData { @@ -51,8 +53,33 @@ public struct _OffsetEffect: GeometryEffect, Equatable { } } +// MARK: - OffsetPosition + +private struct OffsetPosition: Rule, AsyncAttribute { + @Attribute var effect: _OffsetEffect + @Attribute var position: CGPoint + @Attribute var layoutDirection: LayoutDirection + + var value: CGPoint { + position.resolved(in: layoutDirection) + effect.offset + } +} + +extension CGPoint { + @inline(__always) + fileprivate func resolved(in layoutDirection: LayoutDirection) -> CGPoint { + switch layoutDirection { + case .leftToRight: CGPoint(x: x, y: y) + case .rightToLeft: CGPoint(x: -x, y: y) + } + } +} + +// MARK: - View + offset + @available(OpenSwiftUI_v1_0, *) extension View { + /// Offset this view by the horizontal and vertical amount specified in the /// offset parameter. /// @@ -105,24 +132,102 @@ extension View { nonisolated public func offset(x: CGFloat = 0, y: CGFloat = 0) -> some View { offset(CGSize(width: x, height: y)) } + + // TODO: appearanceAnimation + @_spi(Private) + @available(OpenSwiftUI_v2_0, *) + nonisolated public func repeatingOffset( + from: CGSize, + to: CGSize, + animation: Animation = Animation.default + ) -> some View { + _openSwiftUIUnimplementedFailure() + } } -private struct OffsetPosition: Rule, AsyncAttribute { - @Attribute var effect: _OffsetEffect - @Attribute var position: CGPoint - @Attribute var layoutDirection: LayoutDirection - var value: CGPoint { - position.resolved(in: layoutDirection) + effect.offset +// MARK: - AnyTransition + offset + +@available(OpenSwiftUI_v1_0, *) +extension AnyTransition { + + public static func offset(_ offset: CGSize) -> AnyTransition { + .init(OffsetTransition(offset)) + } + + public static func offset(x: CGFloat = 0, y: CGFloat = 0) -> AnyTransition { + offset(CGSize(width: x, height: y)) } } -extension CGPoint { - @inline(__always) - fileprivate func resolved(in layoutDirection: LayoutDirection) -> CGPoint { - switch layoutDirection { - case .leftToRight: CGPoint(x: x, y: y) - case .rightToLeft: CGPoint(x: -x, y: y) +// MARK: - Transition + offset + +@available(OpenSwiftUI_v5_0, *) +extension Transition where Self == OffsetTransition { + + /// Returns a transition that offset the view by the specified amount. + @_alwaysEmitIntoClient + @MainActor + @preconcurrency + public static func offset(_ offset: CGSize) -> Self { + Self(offset) + } + + /// Returns a transition that offset the view by the specified x and y + /// values. + @_alwaysEmitIntoClient + @MainActor + @preconcurrency + public static func offset(x: CGFloat = 0, y: CGFloat = 0) -> Self { + offset(CGSize(width: x, height: y)) + } +} + +// MARK: - OffsetTransition + +/// Returns a transition that offset the view by the specified amount. +@available(OpenSwiftUI_v5_0, *) +public struct OffsetTransition: Transition { + /// The amount to offset the view by. + public var offset: CGSize + + /// Creates a transition that offset the view by the specified amount. + public init(_ offset: CGSize) { + self.offset = offset + } + + public func body(content: Content, phase: TransitionPhase) -> some View { + content.offset(phase.isIdentity ? .zero : offset) + } + + public func _makeContentTransition(transition: inout _Transition_ContentTransition) { + guard case .effects = transition.operation else { + transition.result = .bool(true) + return + } + let effect = ContentTransition.Effect(.translation(offset)) + transition.result = .effects([effect]) + } +} + +@available(*, unavailable) +extension OffsetTransition: Sendable {} + +// MARK: - _OffsetEffect + ProtobufMessage + +extension _OffsetEffect: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + try encoder.messageField(1, offset, defaultValue: .zero) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var offset: CGSize = .zero + while let field = try decoder.nextField() { + switch field.tag { + case 1: offset = try decoder.messageField(field) + default: try decoder.skipField(field) + } } + self.init(offset: offset) } } diff --git a/Sources/OpenSwiftUICore/Render/OpacityEffect.swift b/Sources/OpenSwiftUICore/Render/RendererEffect/OpacityEffect.swift similarity index 100% rename from Sources/OpenSwiftUICore/Render/OpacityEffect.swift rename to Sources/OpenSwiftUICore/Render/RendererEffect/OpacityEffect.swift