From 75cc43f6e116d116039d3b4b13f6ef2de08b1959 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 2 Apr 2026 01:09:04 +0800 Subject: [PATCH 1/8] Add CodableEffectAnimation --- .../Animation/CodableEffectAnimation.swift | 58 +++++++++++++++++++ .../Render/DisplayList/DisplayList.swift | 5 +- 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 Sources/OpenSwiftUICore/Animation/Animation/CodableEffectAnimation.swift diff --git a/Sources/OpenSwiftUICore/Animation/Animation/CodableEffectAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/CodableEffectAnimation.swift new file mode 100644 index 000000000..be17aba5f --- /dev/null +++ b/Sources/OpenSwiftUICore/Animation/Animation/CodableEffectAnimation.swift @@ -0,0 +1,58 @@ +// +// CodableEffectAnimation.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +// MARK: - CodableEffectAnimation + +package struct CodableEffectAnimation: ProtobufMessage { + package struct Tag: ProtobufTag { + package let rawValue: UInt + + package init(rawValue: UInt) { + self.rawValue = rawValue + } + } + + var base: any _DisplayList_AnyEffectAnimation + + package func encode(to encoder: inout ProtobufEncoder) throws { + try base.encodeAnimation(to: &encoder) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var base: (any _DisplayList_AnyEffectAnimation)? + while let field = try decoder.nextField() { + switch field.tag { + case 1: + base = try decoder.messageField(field) as DisplayList.OffsetAnimation + case 2: + base = try decoder.messageField(field) as DisplayList.ScaleAnimation + case 3: + base = try decoder.messageField(field) as DisplayList.RotationAnimation + case 4: + base = try decoder.messageField(field) as DisplayList.OpacityAnimation + default: + try decoder.skipField(field) + } + } + guard let base else { + throw ProtobufDecoder.DecodingError.failed + } + self.base = base + } +} + +// MARK: - _DisplayList_AnyEffectAnimation + Encode + +extension _DisplayList_AnyEffectAnimation { + package func encodeAnimation(to encoder: inout ProtobufEncoder) throws { + if let leafTag = Self.leafProtobufTag { + try encoder.messageField(leafTag.rawValue, self) + } else { + try encode(to: &encoder) + } + } +} diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift index 3a030c77f..97af052af 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift @@ -568,13 +568,12 @@ extension DisplayList { package struct AccessibilityNodeAttachment {} package protocol _DisplayList_AnyEffectAnimation: ProtobufMessage { - // FIXME: CodableEffectAnimation - static var leafProtobufTag: CodableAnimation.Tag? { get } + static var leafProtobufTag: CodableEffectAnimation.Tag? { get } func makeAnimator() -> any _DisplayList_AnyEffectAnimator } package protocol _DisplayList_AnyEffectAnimator { - func evaluate( + mutating func evaluate( _ animation: any DisplayList.AnyEffectAnimation, at time: Time, size: CGSize From 2c8e554f7db98ec672b1dc9b85491cd7c058fc03 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 2 Apr 2026 01:28:35 +0800 Subject: [PATCH 2/8] Update CodableAnimation --- .../Animation/Codable/CodableAnimation.swift | 95 +++++++++++++++++++ .../CodableEffectAnimation.swift | 4 + .../Animation/Animation/CustomAnimation.swift | 63 ++++++++++++ .../Animation/CustomAnimationModifier.swift | 4 + .../Animation/Animation/DelayAnimation.swift | 10 +- .../Animation/EncodableAnimation.swift | 71 -------------- .../Animation/Animation/SpeedAnimation.swift | 12 +-- 7 files changed, 177 insertions(+), 82 deletions(-) create mode 100644 Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableAnimation.swift rename Sources/OpenSwiftUICore/Animation/Animation/{ => Codable}/CodableEffectAnimation.swift (95%) delete mode 100644 Sources/OpenSwiftUICore/Animation/Animation/EncodableAnimation.swift diff --git a/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableAnimation.swift new file mode 100644 index 000000000..e87d84616 --- /dev/null +++ b/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableAnimation.swift @@ -0,0 +1,95 @@ +// +// CodableAnimation.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complte + +// MARK: - CodableAnimation + +package final class CodableAnimation: ProtobufMessage { + package struct Tag: ProtobufTag { + package let rawValue: UInt + + package init(rawValue: UInt) { + self.rawValue = rawValue + } + } + + var base: any CustomAnimation + + init(base: any CustomAnimation) { + self.base = base + } + + package func encode(to encoder: inout ProtobufEncoder) throws { + let animation = base as? any EncodableAnimation ?? DefaultAnimation() + try animation.encodeAnimation(to: &encoder) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var base: (any CustomAnimation)? + while let field = try decoder.nextField() { + switch field.tag { + case 1: + base = try decoder.messageField(field) as BezierAnimation + case 2: + base = try decoder.messageField(field) as SpringAnimation + case 3: + base = try decoder.messageField(field) as FluidSpringAnimation + case 4: + guard let existing = base else { continue } + let delay = try decoder.doubleField(field) + base = Animation(existing) + .modifier(DelayAnimation(delay: delay)) + .codableValue + case 5: + let (repeatCount, autoreverses) = try decoder.messageField(field) { decoder in + try Animation.decodeRepeatMessage(from: &decoder) + } + guard let existing = base else { continue } + base = Animation(existing) + .modifier(RepeatAnimation(repeatCount: repeatCount, autoreverses: autoreverses)) + .codableValue + case 6: + guard let existing = base else { continue } + let speed = try decoder.doubleField(field) + base = Animation(existing) + .modifier(SpeedAnimation(speed: speed)) + .codableValue + case 7: + base = try decoder.messageField(field) { _ in + DefaultAnimation() + } + default: + try decoder.skipField(field) + } + } + guard let base else { + throw ProtobufDecoder.DecodingError.failed + } + self.base = base + } +} + +// MARK: - Animation + decodeRepeatMessage + +extension Animation { + package static func decodeRepeatMessage( + from decoder: inout ProtobufDecoder + ) throws -> (Int?, Bool) { + var repeatCount: Int? + var autoreverses = false + while let field = try decoder.nextField() { + switch field.tag { + case 1: + repeatCount = try decoder.intField(field) + case 2: + autoreverses = try decoder.boolField(field) + default: + try decoder.skipField(field) + } + } + return (repeatCount, autoreverses) + } +} diff --git a/Sources/OpenSwiftUICore/Animation/Animation/CodableEffectAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableEffectAnimation.swift similarity index 95% rename from Sources/OpenSwiftUICore/Animation/Animation/CodableEffectAnimation.swift rename to Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableEffectAnimation.swift index be17aba5f..58a7f5c6a 100644 --- a/Sources/OpenSwiftUICore/Animation/Animation/CodableEffectAnimation.swift +++ b/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableEffectAnimation.swift @@ -18,6 +18,10 @@ package struct CodableEffectAnimation: ProtobufMessage { var base: any _DisplayList_AnyEffectAnimation + init(base: any _DisplayList_AnyEffectAnimation) { + self.base = base + } + package func encode(to encoder: inout ProtobufEncoder) throws { try base.encodeAnimation(to: &encoder) } diff --git a/Sources/OpenSwiftUICore/Animation/Animation/CustomAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/CustomAnimation.swift index 0ad320806..68a05be7a 100644 --- a/Sources/OpenSwiftUICore/Animation/Animation/CustomAnimation.swift +++ b/Sources/OpenSwiftUICore/Animation/Animation/CustomAnimation.swift @@ -206,3 +206,66 @@ extension CustomAnimation { false } } + +// MARK: - EncodableAnimation + +package protocol EncodableAnimation: ProtobufEncodableMessage { + static var leafProtobufTag: CodableAnimation.Tag? { get } +} + +extension EncodableAnimation { + package func encodeAnimation(to encoder: inout ProtobufEncoder) throws { + if let leafTag = Self.leafProtobufTag { + try encoder.messageField(leafTag.rawValue, self) + } else { + try encode(to: &encoder) + } + } +} + +extension BezierAnimation: EncodableAnimation { + package static var leafProtobufTag: CodableAnimation.Tag? { + .init(rawValue: 1) + } +} + +extension SpringAnimation: EncodableAnimation { + package static var leafProtobufTag: CodableAnimation.Tag? { + .init(rawValue: 2) + } +} + +extension FluidSpringAnimation: EncodableAnimation { + package static var leafProtobufTag: CodableAnimation.Tag? { + .init(rawValue: 3) + } +} + +extension DelayAnimation: ProtobufEncodableMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.doubleField(4, delay) + } +} + +extension RepeatAnimation: ProtobufEncodableMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.messageField(5) { encoder in + if let repeatCount { + encoder.intField(1, repeatCount, defaultValue: .min) + } + encoder.boolField(2, autoreverses) + } + } +} + +extension SpeedAnimation: ProtobufEncodableMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.doubleField(6, speed) + } +} + +extension DefaultAnimation: EncodableAnimation { + package static var leafProtobufTag: CodableAnimation.Tag? { + .init(rawValue: 7) + } +} diff --git a/Sources/OpenSwiftUICore/Animation/Animation/CustomAnimationModifier.swift b/Sources/OpenSwiftUICore/Animation/Animation/CustomAnimationModifier.swift index c604e4320..01ec18f6e 100644 --- a/Sources/OpenSwiftUICore/Animation/Animation/CustomAnimationModifier.swift +++ b/Sources/OpenSwiftUICore/Animation/Animation/CustomAnimationModifier.swift @@ -124,6 +124,8 @@ package struct CustomAnimationModifiedContent: InternalCustomAni } extension CustomAnimationModifiedContent: EncodableAnimation { + package static var leafProtobufTag: CodableAnimation.Tag? { nil } + package func encode(to encoder: inout ProtobufEncoder) throws { let encodableAnimation: any EncodableAnimation if let encodableBase = base as? EncodableAnimation { @@ -206,6 +208,8 @@ package struct InternalCustomAnimationModifiedContent: InternalC } extension InternalCustomAnimationModifiedContent: EncodableAnimation { + package static var leafProtobufTag: CodableAnimation.Tag? { nil } + package func encode(to encoder: inout ProtobufEncoder) throws { try _base.encode(to: &encoder) } diff --git a/Sources/OpenSwiftUICore/Animation/Animation/DelayAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/DelayAnimation.swift index 90b15aaf0..39b72c49a 100644 --- a/Sources/OpenSwiftUICore/Animation/Animation/DelayAnimation.swift +++ b/Sources/OpenSwiftUICore/Animation/Animation/DelayAnimation.swift @@ -47,15 +47,15 @@ extension Animation { } } -struct DelayAnimation: CustomAnimationModifier { - var delay: TimeInterval +package struct DelayAnimation: CustomAnimationModifier { + package var delay: TimeInterval @inline(__always) private func delayedTime(_ time: TimeInterval) -> TimeInterval { max(0, time - delay) } - func animate( + package func animate( base: B, value: V, time: TimeInterval, @@ -68,7 +68,7 @@ struct DelayAnimation: CustomAnimationModifier { ) } - func shouldMerge(base: B, previous: DelayAnimation, previousBase: B, value: V, time: TimeInterval, context: inout AnimationContext) -> Bool where V : VectorArithmetic, B : CustomAnimation { + package func shouldMerge(base: B, previous: DelayAnimation, previousBase: B, value: V, time: TimeInterval, context: inout AnimationContext) -> Bool where V : VectorArithmetic, B : CustomAnimation { self == previous && base.shouldMerge( previous: Animation(previousBase), value: value, @@ -77,7 +77,7 @@ struct DelayAnimation: CustomAnimationModifier { ) } - func function(base: Animation.Function) -> Animation.Function { + package func function(base: Animation.Function) -> Animation.Function { base } } diff --git a/Sources/OpenSwiftUICore/Animation/Animation/EncodableAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/EncodableAnimation.swift deleted file mode 100644 index 23e995173..000000000 --- a/Sources/OpenSwiftUICore/Animation/Animation/EncodableAnimation.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// EncodableAnimation.swift -// OpenSwiftUICore -// -// Audited for 6.5.4 -// Status: WIP - -// MARK: - CodableAnimation [WIP] - -package enum CodableAnimation { - package struct Tag: ProtobufTag { - package let rawValue: UInt - - package init(rawValue: UInt) { - self.rawValue = rawValue - } - } -} - -// MARK: - EncodableAnimation - -package protocol EncodableAnimation: ProtobufEncodableMessage { - static var leafProtobufTag: CodableAnimation.Tag? { get } -} - -extension EncodableAnimation { - package static var leafProtobufTag: CodableAnimation.Tag? { nil } - - package func encodeAnimation(to encoder: inout ProtobufEncoder) throws { - if let leafTag = Self.leafProtobufTag { - try encoder.messageField(leafTag.rawValue, self) - } else { - try encode(to: &encoder) - } - } -} - -extension SpringAnimation: EncodableAnimation { - package static var leafProtobufTag: CodableAnimation.Tag? { - .init(rawValue: 2) - } -} - -extension FluidSpringAnimation: EncodableAnimation { - package static var leafProtobufTag: CodableAnimation.Tag? { - .init(rawValue: 3) - } -} - -extension RepeatAnimation: ProtobufEncodableMessage { - func encode(to encoder: inout ProtobufEncoder) throws { - encoder.messageField(5) { encoder in - if let repeatCount { - encoder.intField(1, repeatCount, defaultValue: .min) - } - encoder.boolField(2, autoreverses) - } - } -} - -extension SpeedAnimation: ProtobufEncodableMessage { - func encode(to encoder: inout ProtobufEncoder) throws { - encoder.doubleField(6, speed) - } -} - -extension DefaultAnimation: EncodableAnimation { - package static var leafProtobufTag: CodableAnimation.Tag? { - .init(rawValue: 7) - } -} diff --git a/Sources/OpenSwiftUICore/Animation/Animation/SpeedAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/SpeedAnimation.swift index f3d22be5a..13636d089 100644 --- a/Sources/OpenSwiftUICore/Animation/Animation/SpeedAnimation.swift +++ b/Sources/OpenSwiftUICore/Animation/Animation/SpeedAnimation.swift @@ -5,7 +5,7 @@ // Audited for 6.5.4 // Status: Complete -import Foundation +package import Foundation // MARK: - View + speed Animation @@ -83,15 +83,15 @@ extension Animation { // MARK: - SpeedAnimation -struct SpeedAnimation: CustomAnimationModifier { - var speed: Double +package struct SpeedAnimation: CustomAnimationModifier { + package var speed: Double @inline(__always) private func speededTime(_ time: TimeInterval) -> TimeInterval { time * speed } - func animate( + package func animate( base: B, value: V, time: TimeInterval, @@ -104,7 +104,7 @@ struct SpeedAnimation: CustomAnimationModifier { ) } - func shouldMerge( + package func shouldMerge( base: B, previous: SpeedAnimation, previousBase: B, @@ -120,7 +120,7 @@ struct SpeedAnimation: CustomAnimationModifier { ) } - func function(base: Animation.Function) -> Animation.Function { + package func function(base: Animation.Function) -> Animation.Function { .speed(speed, base) } } From 650fadb972993dfe99ca23033fbe979fb45ae7eb Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 2 Apr 2026 03:00:09 +0800 Subject: [PATCH 3/8] Add DisplayListAnimations --- .../Render/DisplayList/DisplayList.swift | 11 + .../DisplayList/DisplayListAnimations.swift | 188 ++++++++++++++++++ .../DisplayList/DisplayListViewCache.swift | 6 +- 3 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 Sources/OpenSwiftUICore/Render/DisplayList/DisplayListAnimations.swift diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift index 97af052af..a01bfff14 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift @@ -573,6 +573,17 @@ package protocol _DisplayList_AnyEffectAnimation: ProtobufMessage { } package protocol _DisplayList_AnyEffectAnimator { + /// Evaluates the effect animation at the given time and viewport size. + /// + /// - Parameters: + /// - animation: The existential effect animation. Must be castable to + /// `Animation`; otherwise the method returns + /// ``DisplayList/Effect/identity`` with `finished: true`. + /// - time: The current render time. + /// - size: The viewport size passed to + /// ``EffectAnimation/effect(value:size:)``. + /// - Returns: A tuple of the resolved display list effect and a Boolean + /// indicating whether the animation has completed. mutating func evaluate( _ animation: any DisplayList.AnyEffectAnimation, at time: Time, diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListAnimations.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListAnimations.swift new file mode 100644 index 000000000..1444c7a65 --- /dev/null +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListAnimations.swift @@ -0,0 +1,188 @@ +// +// DisplayListAnimations.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: B86250B2E056EB47628ECF46032DFA4C (SwiftUICore) + +import Foundation + +// MARK: - EffectAnimation + +/// A private protocol that bridges animatable effect values to the display +/// list animation system. +/// +/// Conforming types represent a transition between two effect values (e.g. +/// offset, scale, rotation, opacity) driven by a ``Animation``. +/// +/// The protocol provides default implementations for ``ProtobufMessage`` +/// encoding/decoding and for ``_DisplayList_AnyEffectAnimator`` creation +/// via ``EffectAnimator``. +private protocol EffectAnimation: _DisplayList_AnyEffectAnimation { + /// The animatable effect value type + associatedtype Value: Animatable, ProtobufMessage + + /// Creates an effect animation with the given endpoints and curve. + init(from: Value, to: Value, animation: Animation) + + /// The starting effect value of the animation. + var from: Value { get } + + /// The ending effect value of the animation. + var to: Value { get set } + + /// The animation curve driving the transition from ``from`` to ``to``. + var animation: Animation { get } + + /// Converts a current effect value and viewport size into a concrete + /// display list effect. + static func effect(value: Value, size: CGSize) -> DisplayList.Effect +} + +extension EffectAnimation { + func makeAnimator() -> any _DisplayList_AnyEffectAnimator { + EffectAnimator(state: .pending) + } + + func encode(to encoder: inout ProtobufEncoder) throws { + try encoder.messageField(1, from) + try encoder.messageField(2, to) + let codableAnimation = CodableAnimation(base: animation.codableValue) + try encoder.messageField(3, codableAnimation) + } + + init(from decoder: inout ProtobufDecoder) throws { + var fromValue: Value? + var toValue: Value? + var animationValue: Animation? + while let field = try decoder.nextField() { + switch field.tag { + case 1: + fromValue = try decoder.messageField(field) + case 2: + toValue = try decoder.messageField(field) + case 3: + let codable: CodableAnimation = try decoder.messageField(field) + animationValue = Animation(codable.base) + default: + try decoder.skipField(field) + } + } + guard let fromValue, let toValue, let animationValue else { + throw ProtobufDecoder.DecodingError.failed + } + self.init(from: fromValue, to: toValue, animation: animationValue) + } +} + +extension EffectAnimation where Value: GeometryEffect { + static func effect(value: Value, size: CGSize) -> DisplayList.Effect { + var origin = CGPoint.zero + return DefaultGeometryEffectProvider.resolve( + effect: value, + origin: &origin, + size: size, + layoutDirection: .leftToRight + ) + } +} + +// MARK: - DisplayList + Animation + +extension DisplayList { + struct OffsetAnimation: EffectAnimation { + static var leafProtobufTag: CodableEffectAnimation.Tag? { .init(rawValue: 1) } + var from: _OffsetEffect + var to: _OffsetEffect + var animation: Animation + } + + struct ScaleAnimation: EffectAnimation { + static var leafProtobufTag: CodableEffectAnimation.Tag? { .init(rawValue: 2) } + var from: _ScaleEffect + var to: _ScaleEffect + var animation: Animation + } + + struct RotationAnimation: EffectAnimation { + static var leafProtobufTag: CodableEffectAnimation.Tag? { .init(rawValue: 3) } + var from: _RotationEffect + var to: _RotationEffect + var animation: Animation + } + + struct OpacityAnimation: EffectAnimation { + static var leafProtobufTag: CodableEffectAnimation.Tag? { .init(rawValue: 4) } + var from: _OpacityEffect + var to: _OpacityEffect + var animation: Animation + + static func effect(value: _OpacityEffect, size: CGSize) -> DisplayList.Effect { + value.effectValue(size: size) + } + } +} + +// MARK: - EffectAnimator + +/// Drives the frame-by-frame evaluation of an ``EffectAnimation``. +private struct EffectAnimator: DisplayList.AnyEffectAnimator where Animation: EffectAnimation { + enum State { + /// No ``AnimatorState`` yet. On the first ``evaluate`` call the + /// animator computes the animatable interval + /// (`to.animatableData − from.animatableData`) and creates an + /// ``AnimatorState`` starting at `Time.zero`. + case pending + + /// An ``AnimatorState`` exists and is updated each frame. The + /// animator interpolates the effect value and returns the + /// corresponding ``DisplayList/Effect``. + case active(AnimatorState) + + /// ``AnimatorState/update`` returned `true`. The animator returns + /// the final `to` effect value with no further interpolation. + case finished + } + + var state: State + + mutating func evaluate( + _ animation: any DisplayList.AnyEffectAnimation, + at time: Time, + size: CGSize + ) -> (DisplayList.Effect, finished: Bool) { + guard let animation = animation as? Animation else { + return (.identity, true) + } + + if case .pending = state { + let fromData = animation.from.animatableData + var interval = animation.to.animatableData + interval -= fromData + state = .active(AnimatorState( + animation: animation.animation, + interval: interval, + at: .zero, + in: Transaction() + )) + } + var toValue = animation.to + let finished: Bool + switch state { + case .pending: + _openSwiftUIUnreachableCode() + case let .active(animState): + var animatableData = toValue.animatableData + finished = animState.update(&animatableData, at: time, environment: nil) + if finished { + state = .finished + } else { + toValue.animatableData = animatableData + } + case .finished: + finished = true + } + return (Animation.effect(value: toValue, size: size), finished) + } +} diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListViewCache.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListViewCache.swift index 99a3d3181..178e80bd6 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListViewCache.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListViewCache.swift @@ -400,20 +400,17 @@ extension DisplayList.ViewUpdater { let time = parentState.pointee.globals.pointee.time var animatorInfo = animators[key, default: .init(state: .idle, deadline: .zero)] if case .idle = animatorInfo.state { - // If idle, initialize by creating an animator from the animation animatorInfo.state = .active(animation.makeAnimator()) } switch animatorInfo.state { case let .active(animator): - // Reset to idle before evaluation + var animator = animator animatorInfo.state = .idle - // Evaluate the animation effect let (effect, finished) = animator.evaluate( animation, at: time, size: item.size, ) - // Swap item value with the animation effect item.value = .effect(effect, displayList) let maxVersion = parentState.pointee.globals.pointee.maxVersion item.version = maxVersion @@ -426,7 +423,6 @@ extension DisplayList.ViewUpdater { animators[key] = animatorInfo return finished ? .infinity : time case let .finished(effect, version): - // Re-apply the stored final effect item.value = .effect(effect, displayList) item.version = version animatorInfo.deadline = time From 68aee6abe2c191ced135446cb41cbb1a7a517118 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 4 Apr 2026 12:50:00 +0800 Subject: [PATCH 4/8] Update CodableAnimation --- .../Animation/Codable/CodableAnimation.swift | 46 +++++++++---------- .../DisplayList/DisplayListAnimations.swift | 4 +- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableAnimation.swift index e87d84616..1d20470ca 100644 --- a/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableAnimation.swift +++ b/Sources/OpenSwiftUICore/Animation/Animation/Codable/CodableAnimation.swift @@ -3,11 +3,11 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: Complte +// Status: Complete // MARK: - CodableAnimation -package final class CodableAnimation: ProtobufMessage { +package struct CodableAnimation: ProtobufMessage { package struct Tag: ProtobufTag { package let rawValue: UInt @@ -16,59 +16,55 @@ package final class CodableAnimation: ProtobufMessage { } } - var base: any CustomAnimation + var base: Animation - init(base: any CustomAnimation) { + init(base: Animation) { self.base = base } package func encode(to encoder: inout ProtobufEncoder) throws { - let animation = base as? any EncodableAnimation ?? DefaultAnimation() + let animation = base.codableValue as? any EncodableAnimation ?? DefaultAnimation() try animation.encodeAnimation(to: &encoder) } package init(from decoder: inout ProtobufDecoder) throws { - var base: (any CustomAnimation)? + var animation: Animation? while let field = try decoder.nextField() { switch field.tag { case 1: - base = try decoder.messageField(field) as BezierAnimation + animation = Animation(try decoder.messageField(field) as BezierAnimation) case 2: - base = try decoder.messageField(field) as SpringAnimation + animation = Animation(try decoder.messageField(field) as SpringAnimation) case 3: - base = try decoder.messageField(field) as FluidSpringAnimation + animation = Animation(try decoder.messageField(field) as FluidSpringAnimation) case 4: - guard let existing = base else { continue } + guard let existing = animation else { continue } let delay = try decoder.doubleField(field) - base = Animation(existing) - .modifier(DelayAnimation(delay: delay)) - .codableValue + animation = existing.modifier(DelayAnimation(delay: delay)) case 5: let (repeatCount, autoreverses) = try decoder.messageField(field) { decoder in try Animation.decodeRepeatMessage(from: &decoder) } - guard let existing = base else { continue } - base = Animation(existing) - .modifier(RepeatAnimation(repeatCount: repeatCount, autoreverses: autoreverses)) - .codableValue + guard let existing = animation else { continue } + animation = existing.modifier( + RepeatAnimation(repeatCount: repeatCount, autoreverses: autoreverses) + ) case 6: - guard let existing = base else { continue } + guard let existing = animation else { continue } let speed = try decoder.doubleField(field) - base = Animation(existing) - .modifier(SpeedAnimation(speed: speed)) - .codableValue + animation = existing.modifier(SpeedAnimation(speed: speed)) case 7: - base = try decoder.messageField(field) { _ in - DefaultAnimation() + animation = try decoder.messageField(field) { _ in + Animation(DefaultAnimation()) } default: try decoder.skipField(field) } } - guard let base else { + guard let animation else { throw ProtobufDecoder.DecodingError.failed } - self.base = base + self.base = animation } } diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListAnimations.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListAnimations.swift index 1444c7a65..295a63870 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListAnimations.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListAnimations.swift @@ -48,7 +48,7 @@ extension EffectAnimation { func encode(to encoder: inout ProtobufEncoder) throws { try encoder.messageField(1, from) try encoder.messageField(2, to) - let codableAnimation = CodableAnimation(base: animation.codableValue) + let codableAnimation = CodableAnimation(base: animation) try encoder.messageField(3, codableAnimation) } @@ -64,7 +64,7 @@ extension EffectAnimation { toValue = try decoder.messageField(field) case 3: let codable: CodableAnimation = try decoder.messageField(field) - animationValue = Animation(codable.base) + animationValue = codable.base default: try decoder.skipField(field) } From d3fb167077a1c7c1dd4b8f25b71122c825abf880 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 4 Apr 2026 14:14:03 +0800 Subject: [PATCH 5/8] Add CodableAnimationDualTests --- .../CodableEffectAnimationTestsStub.c | 14 ++++ .../CodableEffectAnimationDualTests.swift | 80 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 Sources/OpenSwiftUISymbolDualTestsSupport/Animation/CodableEffectAnimationTestsStub.c create mode 100644 Tests/OpenSwiftUISymbolDualTests/Animation/CodableEffectAnimationDualTests.swift diff --git a/Sources/OpenSwiftUISymbolDualTestsSupport/Animation/CodableEffectAnimationTestsStub.c b/Sources/OpenSwiftUISymbolDualTestsSupport/Animation/CodableEffectAnimationTestsStub.c new file mode 100644 index 000000000..d126e9df7 --- /dev/null +++ b/Sources/OpenSwiftUISymbolDualTestsSupport/Animation/CodableEffectAnimationTestsStub.c @@ -0,0 +1,14 @@ +// +// CodableAnimationTestsStub.c +// OpenSwiftUISymbolDualTestsSupport + +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_DARWIN +#import + +// CodableEffectAnimation +DEFINE_SL_STUB_SLF(OpenSwiftUITestStub_CodableEffectAnimationEncode, SwiftUICore, $s7SwiftUI22CodableEffectAnimationV6encode2toyAA15ProtobufEncoderVz_tKF); +DEFINE_SL_STUB_SLF(OpenSwiftUITestStub_CodableEffectAnimationDecode, SwiftUICore, $s7SwiftUI22CodableEffectAnimationV4fromAcA15ProtobufDecoderVz_tKcfC); + +#endif diff --git a/Tests/OpenSwiftUISymbolDualTests/Animation/CodableEffectAnimationDualTests.swift b/Tests/OpenSwiftUISymbolDualTests/Animation/CodableEffectAnimationDualTests.swift new file mode 100644 index 000000000..78e993454 --- /dev/null +++ b/Tests/OpenSwiftUISymbolDualTests/Animation/CodableEffectAnimationDualTests.swift @@ -0,0 +1,80 @@ +// +// CodableAnimationDualTests.swift +// OpenSwiftUISymbolDualTests + +#if canImport(SwiftUI, _underlyingVersion: 6.5.4) +import Foundation +@testable import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +// MARK: - @_silgen_name declarations + +extension CodableEffectAnimation { + @_silgen_name("OpenSwiftUITestStub_CodableEffectAnimationEncode") + func swiftUI_encode(to encoder: inout ProtobufEncoder) throws + + @_silgen_name("OpenSwiftUITestStub_CodableEffectAnimationDecode") + init(swiftUI_from decoder: inout ProtobufDecoder) throws +} + +// MARK: - CodableEffectAnimation Dual Tests + +@Suite +struct CodableEffectAnimationDualTests { + @Test( + arguments: [ + ( + "opacity", + CodableEffectAnimation( + base: DisplayList.OpacityAnimation( + from: _OpacityEffect(opacity: 0.0), + to: _OpacityEffect(opacity: 1.0), + animation: Animation(DefaultAnimation()) + ) + ), + "220d0a050d0000000012001a023a00" + ), + ( + "offset", + CodableEffectAnimation( + base: DisplayList.OffsetAnimation( + from: _OffsetEffect(offset: .zero), + to: _OffsetEffect(offset: CGSize(width: 10, height: 20)), + animation: Animation(DefaultAnimation()) + ) + ), + "0a140a00120c0a0a0d00002041150000a0411a023a00" + ), + ] as [(String, CodableEffectAnimation, String)] + ) + func pbMessage(label: String, value: CodableEffectAnimation, hexString: String) throws { + try value.testPBEncoding(swiftUI_hexString: hexString) + try value.testPBDecoding(swiftUI_hexString: hexString) + } +} + +// MARK: - SwiftUI Dual Test Helpers + +extension CodableEffectAnimation { + func testPBEncoding(swiftUI_hexString expectedHexString: String) throws { + let data = try ProtobufEncoder.encoding { encoder in + try swiftUI_encode(to: &encoder) + } + #expect(data.hexString == expectedHexString) + } + + func testPBDecoding(swiftUI_hexString hexString: String) throws { + guard let data = Data(hexString: hexString) else { + throw ProtobufDecoder.DecodingError.failed + } + var decoder = ProtobufDecoder(data) + let decoded = try CodableEffectAnimation(swiftUI_from: &decoder) + let reEncoded = try ProtobufEncoder.encoding { encoder in + try decoded.swiftUI_encode(to: &encoder) + } + #expect(reEncoded.hexString == hexString) + } +} + +#endif From 50336fffc71d050a5e75a1bf414e5ad14d471449 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 4 Apr 2026 14:27:23 +0800 Subject: [PATCH 6/8] Fix testPBDecoding constraint issue --- .../Data/Protobuf/ProtobufTestHelper.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/OpenSwiftUITestsSupport/Data/Protobuf/ProtobufTestHelper.swift b/Sources/OpenSwiftUITestsSupport/Data/Protobuf/ProtobufTestHelper.swift index 0cc70a0d0..9f2b1daf2 100644 --- a/Sources/OpenSwiftUITestsSupport/Data/Protobuf/ProtobufTestHelper.swift +++ b/Sources/OpenSwiftUITestsSupport/Data/Protobuf/ProtobufTestHelper.swift @@ -454,11 +454,26 @@ extension ProtobufEncodableMessage { } } -extension ProtobufDecodableMessage where Self: Equatable { +extension ProtobufDecodableMessage { package func testPBDecoding(hexString: String) throws { let decodedValue = try hexString.decodePBHexString(Self.self) - #expect(decodedValue == self) + if let selfEquatable = self as? any Equatable, + let decodedEquatable = decodedValue as? any Equatable { + #expect(_pbIsEqual(selfEquatable, decodedEquatable)) + } else if let decodedEncodable = decodedValue as? any ProtobufEncodableMessage { + let reEncoded = try ProtobufEncoder.encoding { encoder in + try decodedEncodable.encode(to: &encoder) + } + #expect(reEncoded.hexString == hexString) + } + } +} + +private func _pbIsEqual(_ lhs: any Equatable, _ rhs: any Equatable) -> Bool { + func open(_ lhs: T) -> Bool { + lhs == (rhs as? T) } + return open(lhs) } #endif #endif From e15f3dc53e2c39452adee41314a324f8701602fb Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 4 Apr 2026 14:27:33 +0800 Subject: [PATCH 7/8] Add CodableAnimationTests --- .../Animation/CodableAnimationTests.swift | 34 +++++++++++++++ .../CodableEffectAnimationTests.swift | 43 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 Tests/OpenSwiftUICoreTests/Animation/Animation/CodableAnimationTests.swift create mode 100644 Tests/OpenSwiftUICoreTests/Animation/Animation/CodableEffectAnimationTests.swift diff --git a/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableAnimationTests.swift b/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableAnimationTests.swift new file mode 100644 index 000000000..faa59ef34 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableAnimationTests.swift @@ -0,0 +1,34 @@ +// +// CodableAnimationTests.swift +// OpenSwiftUICoreTests + +import Foundation +@testable import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +// MARK: - CodableAnimationTests + +struct CodableAnimationTests { + @Test( + arguments: [ + ("default", CodableAnimation(base: Animation(DefaultAnimation())), "3a00"), + ( + "spring", + CodableAnimation(base: Animation(SpringAnimation(mass: 1.0, stiffness: 100.0, damping: 10.0))), + "1209190000000000002440" + ), + ( + "fluidSpring", + CodableAnimation( + base: Animation(FluidSpringAnimation(response: 0.5, dampingFraction: 0.8, blendDuration: 0.0)) + ), + "1a1209000000000000e03f119a9999999999e93f" + ), + ] as [(String, CodableAnimation, String)] + ) + func pbMessage(label: String, animation: CodableAnimation, hexString: String) throws { + try animation.testPBEncoding(hexString: hexString) + try animation.testPBDecoding(hexString: hexString) + } +} diff --git a/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableEffectAnimationTests.swift b/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableEffectAnimationTests.swift new file mode 100644 index 000000000..5426e3ce1 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableEffectAnimationTests.swift @@ -0,0 +1,43 @@ +// +// CodableEffectAnimationTests.swift +// OpenSwiftUICoreTests + +import Foundation +@testable import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +// MARK: - CodableEffectAnimationTests + +struct CodableEffectAnimationTests { + @Test( + arguments: [ + ( + "opacity", + CodableEffectAnimation( + base: DisplayList.OpacityAnimation( + from: _OpacityEffect(opacity: 0.0), + to: _OpacityEffect(opacity: 1.0), + animation: Animation(DefaultAnimation()) + ) + ), + "220d0a050d0000000012001a023a00" + ), + ( + "offset", + CodableEffectAnimation( + base: DisplayList.OffsetAnimation( + from: _OffsetEffect(offset: .zero), + to: _OffsetEffect(offset: CGSize(width: 10, height: 20)), + animation: Animation(DefaultAnimation()) + ) + ), + "0a140a00120c0a0a0d00002041150000a0411a023a00" + ), + ] as [(String, CodableEffectAnimation, String)] + ) + func pbMessage(label: String, value: CodableEffectAnimation, hexString: String) throws { + try value.testPBEncoding(hexString: hexString) + try value.testPBDecoding(hexString: hexString) + } +} From 17994e8cafc5888ee635727c3afe036d11bb4ff7 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 4 Apr 2026 14:36:04 +0800 Subject: [PATCH 8/8] Update test case coverage --- .../Animation/CodableAnimationTests.swift | 33 +++++++++++++++ .../CodableEffectAnimationTests.swift | 42 ++++++++++++++----- .../CodableEffectAnimationDualTests.swift | 42 ++++++++++++++----- 3 files changed, 97 insertions(+), 20 deletions(-) diff --git a/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableAnimationTests.swift b/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableAnimationTests.swift index faa59ef34..b2fac2c59 100644 --- a/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableAnimationTests.swift +++ b/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableAnimationTests.swift @@ -13,6 +13,19 @@ struct CodableAnimationTests { @Test( arguments: [ ("default", CodableAnimation(base: Animation(DefaultAnimation())), "3a00"), + ( + "bezier", + CodableAnimation( + base: Animation(BezierAnimation( + curve: .init( + startControlPoint: UnitPoint(x: 0.42, y: 0.0), + endControlPoint: UnitPoint(x: 0.58, y: 1.0) + ), + duration: 0.3 + )) + ), + "0a2609333333333333d33f121b09e17a14ae47e1da3f198fc2f5285c8fe23f21000000000000f03f" + ), ( "spring", CodableAnimation(base: Animation(SpringAnimation(mass: 1.0, stiffness: 100.0, damping: 10.0))), @@ -25,6 +38,26 @@ struct CodableAnimationTests { ), "1a1209000000000000e03f119a9999999999e93f" ), + ( + "delay", + CodableAnimation(base: Animation(DefaultAnimation()).delay(0.5)), + "3a0021000000000000e03f" + ), + ( + "repeatCount", + CodableAnimation(base: Animation(DefaultAnimation()).repeatCount(3, autoreverses: true)), + "3a002a0408061001" + ), + ( + "repeatForever", + CodableAnimation(base: Animation(DefaultAnimation()).repeatForever(autoreverses: false)), + "3a002a00" + ), + ( + "speed", + CodableAnimation(base: Animation(DefaultAnimation()).speed(2.0)), + "3a00310000000000000040" + ), ] as [(String, CodableAnimation, String)] ) func pbMessage(label: String, animation: CodableAnimation, hexString: String) throws { diff --git a/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableEffectAnimationTests.swift b/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableEffectAnimationTests.swift index 5426e3ce1..fa3c563e5 100644 --- a/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableEffectAnimationTests.swift +++ b/Tests/OpenSwiftUICoreTests/Animation/Animation/CodableEffectAnimationTests.swift @@ -13,26 +13,48 @@ struct CodableEffectAnimationTests { @Test( arguments: [ ( - "opacity", + "offset", CodableEffectAnimation( - base: DisplayList.OpacityAnimation( - from: _OpacityEffect(opacity: 0.0), - to: _OpacityEffect(opacity: 1.0), + base: DisplayList.OffsetAnimation( + from: _OffsetEffect(offset: .zero), + to: _OffsetEffect(offset: CGSize(width: 10, height: 20)), animation: Animation(DefaultAnimation()) ) ), - "220d0a050d0000000012001a023a00" + "0a140a00120c0a0a0d00002041150000a0411a023a00" ), ( - "offset", + "scale", CodableEffectAnimation( - base: DisplayList.OffsetAnimation( - from: _OffsetEffect(offset: .zero), - to: _OffsetEffect(offset: CGSize(width: 10, height: 20)), + base: DisplayList.ScaleAnimation( + from: _ScaleEffect(scale: CGSize(width: 1, height: 1), anchor: .center), + to: _ScaleEffect(scale: CGSize(width: 2, height: 2), anchor: .center), animation: Animation(DefaultAnimation()) ) ), - "0a140a00120c0a0a0d00002041150000a0411a023a00" + "12140a00120c0a0a0d0000004015000000401a023a00" + ), + ( + "rotation", + CodableEffectAnimation( + base: DisplayList.RotationAnimation( + from: _RotationEffect(angle: .zero, anchor: .center), + to: _RotationEffect(angle: .degrees(90), anchor: .center), + animation: Animation(DefaultAnimation()) + ) + ), + "1a110a00120909182d4454fb21f93f1a023a00" + ), + ( + "opacity", + CodableEffectAnimation( + base: DisplayList.OpacityAnimation( + from: _OpacityEffect(opacity: 0.0), + to: _OpacityEffect(opacity: 1.0), + animation: Animation(DefaultAnimation()) + ) + ), + "220d0a050d0000000012001a023a00" ), ] as [(String, CodableEffectAnimation, String)] ) diff --git a/Tests/OpenSwiftUISymbolDualTests/Animation/CodableEffectAnimationDualTests.swift b/Tests/OpenSwiftUISymbolDualTests/Animation/CodableEffectAnimationDualTests.swift index 78e993454..f5c829cd6 100644 --- a/Tests/OpenSwiftUISymbolDualTests/Animation/CodableEffectAnimationDualTests.swift +++ b/Tests/OpenSwiftUISymbolDualTests/Animation/CodableEffectAnimationDualTests.swift @@ -25,26 +25,48 @@ struct CodableEffectAnimationDualTests { @Test( arguments: [ ( - "opacity", + "offset", CodableEffectAnimation( - base: DisplayList.OpacityAnimation( - from: _OpacityEffect(opacity: 0.0), - to: _OpacityEffect(opacity: 1.0), + base: DisplayList.OffsetAnimation( + from: _OffsetEffect(offset: .zero), + to: _OffsetEffect(offset: CGSize(width: 10, height: 20)), animation: Animation(DefaultAnimation()) ) ), - "220d0a050d0000000012001a023a00" + "0a140a00120c0a0a0d00002041150000a0411a023a00" ), ( - "offset", + "scale", CodableEffectAnimation( - base: DisplayList.OffsetAnimation( - from: _OffsetEffect(offset: .zero), - to: _OffsetEffect(offset: CGSize(width: 10, height: 20)), + base: DisplayList.ScaleAnimation( + from: _ScaleEffect(scale: CGSize(width: 1, height: 1), anchor: .center), + to: _ScaleEffect(scale: CGSize(width: 2, height: 2), anchor: .center), animation: Animation(DefaultAnimation()) ) ), - "0a140a00120c0a0a0d00002041150000a0411a023a00" + "12140a00120c0a0a0d0000004015000000401a023a00" + ), + ( + "rotation", + CodableEffectAnimation( + base: DisplayList.RotationAnimation( + from: _RotationEffect(angle: .zero, anchor: .center), + to: _RotationEffect(angle: .degrees(90), anchor: .center), + animation: Animation(DefaultAnimation()) + ) + ), + "1a110a00120909182d4454fb21f93f1a023a00" + ), + ( + "opacity", + CodableEffectAnimation( + base: DisplayList.OpacityAnimation( + from: _OpacityEffect(opacity: 0.0), + to: _OpacityEffect(opacity: 1.0), + animation: Animation(DefaultAnimation()) + ) + ), + "220d0a050d0000000012001a023a00" ), ] as [(String, CodableEffectAnimation, String)] )