From 737cce7d84f76301d0506eb0e9243525a3afe692 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 12 Jul 2025 23:19:11 +0800 Subject: [PATCH 1/9] Add AnimationState implementation --- .../Animation/Animation/AnimationState.swift | 276 ++++++++++++++++++ .../Animation/TransactionAnimation.swift | 2 +- 2 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 Sources/OpenSwiftUICore/View/Animation/Animation/AnimationState.swift diff --git a/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationState.swift b/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationState.swift new file mode 100644 index 000000000..3d2825266 --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationState.swift @@ -0,0 +1,276 @@ +// +// AnimationState.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +/// A container that stores the state for a custom animation. +/// +/// An ``AnimationContext`` uses this type to store state for a +/// ``CustomAnimation``. To retrieve the stored state of a context, you can +/// use the ``AnimationContext/state`` property. However, a more convenient +/// way to access the animation state is to define an ``AnimationStateKey`` +/// and extend ``AnimationContext`` with a computed property that gets +/// and sets the animation state, as shown in the following code: +/// +/// private struct PausableState: AnimationStateKey { +/// static var defaultValue: Self { .init() } +/// } +/// +/// extension AnimationContext { +/// fileprivate var pausableState: PausableState { +/// get { state[PausableState.self] } +/// set { state[PausableState.self] = newValue } +/// } +/// } +/// +/// When creating an ``AnimationStateKey``, it's convenient to define the +/// state values that your custom animation needs. For example, the following +/// code adds the properties `paused` and `pauseTime` to the `PausableState` +/// animation state key: +/// +/// private struct PausableState: AnimationStateKey { +/// var paused = false +/// var pauseTime: TimeInterval = 0.0 +/// +/// static var defaultValue: Self { .init() } +/// } +/// +/// To access the pausable state in a `PausableAnimation`, the follow code +/// calls `pausableState` instead of using the context's +/// ``AnimationContext/state`` property. And because the animation state key +/// `PausableState` defines properties for state values, the custom animation +/// can read and write those values. +/// +/// struct PausableAnimation: CustomAnimation { +/// let base: Animation +/// +/// func animate(value: V, time: TimeInterval, context: inout AnimationContext) -> V? where V : VectorArithmetic { +/// let paused = context.environment.animationPaused +/// +/// let pausableState = context.pausableState +/// var pauseTime = pausableState.pauseTime +/// if pausableState.paused != paused { +/// pauseTime = time - pauseTime +/// context.pausableState = PausableState(paused: paused, pauseTime: pauseTime) +/// } +/// +/// let effectiveTime = paused ? pauseTime : time - pauseTime +/// let result = base.animate(value: value, time: effectiveTime, context: &context) +/// return result +/// } +/// } +/// +/// ### Storing state for secondary animations +/// +/// A custom animation can also use `AnimationState` to store the state of a +/// secondary animation. For example, the following code creates an +/// ``AnimationStateKey`` that includes the property `secondaryState`, which a +/// custom animation can use to store other state: +/// +/// private struct TargetState: AnimationStateKey { +/// var timeDelta = 0.0 +/// var valueDelta = Value.zero +/// var secondaryState: AnimationState? = .init() +/// +/// static var defaultValue: Self { .init() } +/// } +/// +/// extension AnimationContext { +/// fileprivate var targetState: TargetState { +/// get { state[TargetState.self] } +/// set { state[TargetState.self] = newValue } +/// } +/// } +/// +/// The custom animation `TargetAnimation` uses `TargetState` to store state +/// data in `secondaryState` for another animation that runs as part of the +/// target animation. +/// +/// struct TargetAnimation: CustomAnimation { +/// var base: Animation +/// var secondary: Animation +/// +/// func animate(value: V, time: Double, context: inout AnimationContext) -> V? { +/// var targetValue = value +/// if let secondaryState = context.targetState.secondaryState { +/// var secondaryContext = context +/// secondaryContext.state = secondaryState +/// let secondaryValue = value - context.targetState.valueDelta +/// let result = secondary.animate( +/// value: secondaryValue, time: time - context.targetState.timeDelta, +/// context: &secondaryContext) +/// if let result = result { +/// context.targetState.secondaryState = secondaryContext.state +/// targetValue = result + context.targetState.valueDelta +/// } else { +/// context.targetState.secondaryState = nil +/// } +/// } +/// let result = base.animate(value: targetValue, time: time, context: &context) +/// if let result = result { +/// targetValue = result +/// } else if context.targetState.secondaryState == nil { +/// return nil +/// } +/// return targetValue +/// } +/// +/// func shouldMerge(previous: Animation, value: V, time: Double, context: inout AnimationContext) -> Bool { +/// guard let previous = previous.base as? Self else { return false } +/// var secondaryContext = context +/// if let secondaryState = context.targetState.secondaryState { +/// secondaryContext.state = secondaryState +/// context.targetState.valueDelta = secondary.animate( +/// value: value, time: time - context.targetState.timeDelta, +/// context: &secondaryContext) ?? value +/// } else { +/// context.targetState.valueDelta = value +/// } +/// // Reset the target each time a merge occurs. +/// context.targetState.secondaryState = .init() +/// context.targetState.timeDelta = time +/// return base.shouldMerge( +/// previous: previous.base, value: value, time: time, +/// context: &context) +/// } +/// } +@available(OpenSwiftUI_v5_0, *) +public struct AnimationState where Value: VectorArithmetic { + var storage: [ObjectIdentifier: Any] + + /// Create an empty state container. + /// + /// You don't typically create an instance of ``AnimationState`` directly. + /// Instead, the ``AnimationContext`` provides the animation state to an + /// instance of ``CustomAnimation``. + public init() { + self.storage = [:] + } + + /// Accesses the state for a custom key. + /// + /// Create a custom animation state value by defining a key that conforms + /// to the ``AnimationStateKey`` protocol and provide the + /// ``AnimationStateKey/defaultValue`` for the key. Also include properties + /// to read and write state values that your ``CustomAnimation`` uses. For + /// example, the following code defines a key named `PausableState` that + /// has two state values, `paused` and `pauseTime`: + /// + /// private struct PausableState: AnimationStateKey { + /// var paused = false + /// var pauseTime: TimeInterval = 0.0 + /// + /// static var defaultValue: Self { .init() } + /// } + /// + /// Use that key with the subscript operator of the ``AnimationState`` + /// structure to get and set a value for the key. For more convenient + /// access to the key value, extend ``AnimationContext`` with a computed + /// property that gets and sets the key's value. + /// + /// extension AnimationContext { + /// fileprivate var pausableState: PausableState { + /// get { state[PausableState.self] } + /// set { state[PausableState.self] = newValue } + /// } + /// } + /// + /// To access the state values in a ``CustomAnimation``, call the custom + /// computed property, then read and write the state values that the + /// custom ``AnimationStateKey`` provides. + /// + /// struct PausableAnimation: CustomAnimation { + /// let base: Animation + /// + /// func animate(value: V, time: TimeInterval, context: inout AnimationContext) -> V? where V : VectorArithmetic { + /// let paused = context.environment.animationPaused + /// + /// let pausableState = context.pausableState + /// var pauseTime = pausableState.pauseTime + /// if pausableState.paused != paused { + /// pauseTime = time - pauseTime + /// context.pausableState = PausableState(paused: paused, pauseTime: pauseTime) + /// } + /// + /// let effectiveTime = paused ? pauseTime : time - pauseTime + /// let result = base.animate(value: value, time: effectiveTime, context: &context) + /// return result + /// } + /// } + public subscript(key: K.Type) -> K.Value where K: AnimationStateKey { + get { + if let value = storage[ObjectIdentifier(key)] { + return value as! K.Value + } else { + return key.defaultValue + } + } + set { + storage[ObjectIdentifier(key)] = newValue + } + } +} + +@available(*, unavailable) +extension AnimationState: Sendable {} + +/// A key for accessing animation state values. +/// +/// To access animation state from an ``AnimationContext`` in a custom +/// animation, create an `AnimationStateKey`. For example, the following +/// code creates an animation state key named `PausableState` and sets the +/// value for the required ``defaultValue`` property. The code also defines +/// properties for state values that the custom animation needs when +/// calculating animation values. Keeping the state values in the animation +/// state key makes it more convenient to read and write those values in the +/// implementation of a ``CustomAnimation``. +/// +/// private struct PausableState: AnimationStateKey { +/// var paused = false +/// var pauseTime: TimeInterval = 0.0 +/// +/// static var defaultValue: Self { .init() } +/// } +/// +/// To make accessing the value of the animation state key more convenient, +/// define a property for it by extending ``AnimationContext``: +/// +/// extension AnimationContext { +/// fileprivate var pausableState: PausableState { +/// get { state[PausableState.self] } +/// set { state[PausableState.self] = newValue } +/// } +/// } +/// +/// Then, you can read and write your state in an instance of `CustomAnimation` +/// using the ``AnimationContext``: +/// +/// struct PausableAnimation: CustomAnimation { +/// let base: Animation +/// +/// func animate(value: V, time: TimeInterval, context: inout AnimationContext) -> V? where V : VectorArithmetic { +/// let paused = context.environment.animationPaused +/// +/// let pausableState = context.pausableState +/// var pauseTime = pausableState.pauseTime +/// if pausableState.paused != paused { +/// pauseTime = time - pauseTime +/// context.pausableState = PausableState(paused: paused, pauseTime: pauseTime) +/// } +/// +/// let effectiveTime = paused ? pauseTime : time - pauseTime +/// let result = base.animate(value: value, time: effectiveTime, context: &context) +/// return result +/// } +/// } +@available(OpenSwiftUI_v5_0, *) +public protocol AnimationStateKey { + /// The associated type representing the type of the animation state key's + /// value. + associatedtype Value + + /// The default value for the animation state key. + static var defaultValue: Self.Value { get } +} diff --git a/Sources/OpenSwiftUICore/View/Animation/Animation/TransactionAnimation.swift b/Sources/OpenSwiftUICore/View/Animation/Animation/TransactionAnimation.swift index acedaf4b8..6a18797b7 100644 --- a/Sources/OpenSwiftUICore/View/Animation/Animation/TransactionAnimation.swift +++ b/Sources/OpenSwiftUICore/View/Animation/Animation/TransactionAnimation.swift @@ -38,7 +38,7 @@ extension Transaction { } package var isAnimated: Bool { - guard let animation, + guard animation != nil, !disablesAnimations else { return false } From f2efb1dfbe528a4a6bd0ab7dc5d717e56295488a Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 13 Jul 2025 00:15:21 +0800 Subject: [PATCH 2/9] Add AnimationContext implementation --- .../Animation/AnimationContext.swift | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 Sources/OpenSwiftUICore/View/Animation/Animation/AnimationContext.swift diff --git a/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationContext.swift b/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationContext.swift new file mode 100644 index 000000000..8030b7d6d --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationContext.swift @@ -0,0 +1,252 @@ +// +// AnimationContext.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +package import OpenGraphShims + +/// Contextual values that a custom animation can use to manage state and +/// access a view's environment. +/// +/// The system provides an `AnimationContext` to a ``CustomAnimation`` instance +/// so that the animation can store and retrieve values in an instance of +/// ``AnimationState``. To access these values, use the context's +/// ``AnimationContext/state`` property. +/// +/// For more convenient access to state, create an ``AnimationStateKey`` and +/// extend `AnimationContext` to include a computed property that gets and +/// sets the ``AnimationState`` value. Then use this property instead of +/// ``AnimationContext/state`` to retrieve the state of a custom animation. For +/// example, the following code creates an animation state key named +/// `PausableState`. Then the code extends `AnimationContext` to include the +/// `pausableState` property: +/// +/// private struct PausableState: AnimationStateKey { +/// var paused = false +/// var pauseTime: TimeInterval = 0.0 +/// +/// static var defaultValue: Self { .init() } +/// } +/// +/// extension AnimationContext { +/// fileprivate var pausableState: PausableState { +/// get { state[PausableState.self] } +/// set { state[PausableState.self] = newValue } +/// } +/// } +/// +/// To access the pausable state, the custom animation `PausableAnimation` uses +/// the `pausableState` property instead of the ``AnimationContext/state`` +/// property: +/// +/// struct PausableAnimation: CustomAnimation { +/// let base: Animation +/// +/// func animate(value: V, time: TimeInterval, context: inout AnimationContext) -> V? where V : VectorArithmetic { +/// let paused = context.environment.animationPaused +/// +/// let pausableState = context.pausableState +/// var pauseTime = pausableState.pauseTime +/// if pausableState.paused != paused { +/// pauseTime = time - pauseTime +/// context.pausableState = PausableState(paused: paused, pauseTime: pauseTime) +/// } +/// +/// let effectiveTime = paused ? pauseTime : time - pauseTime +/// let result = base.animate(value: value, time: effectiveTime, context: &context) +/// return result +/// } +/// } +/// +/// The animation can also retrieve environment values of the view that created +/// the animation. To retrieve a view's environment value, use the context's +/// ``AnimationContext/environment`` property. For instance, the following code +/// creates a custom ``EnvironmentValues`` property named `animationPaused`, and the +/// view `PausableAnimationView` uses the property to store the paused state: +/// +/// extension EnvironmentValues { +/// @Entry var animationPaused: Bool = false +/// } +/// +/// struct PausableAnimationView: View { +/// @State private var paused = false +/// +/// var body: some View { +/// VStack { +/// ... +/// } +/// .environment(\.animationPaused, paused) +/// } +/// } +/// +/// Then the custom animation `PausableAnimation` retrieves the paused state +/// from the view's environment using the ``AnimationContext/environment`` +/// property: +/// +/// struct PausableAnimation: CustomAnimation { +/// func animate(value: V, time: TimeInterval, context: inout AnimationContext) -> V? where V : VectorArithmetic { +/// let paused = context.environment.animationPaused +/// ... +/// } +/// } +@available(OpenSwiftUI_v5_0, *) +public struct AnimationContext where Value: VectorArithmetic { + /// The current state of a custom animation. + /// + /// An instance of ``CustomAnimation`` uses this property to read and + /// write state values as the animation runs. + /// + /// An alternative to using the `state` property in a custom animation is + /// to create an ``AnimationStateKey`` type and extend ``AnimationContext`` + /// with a custom property that returns the state as a custom type. For + /// example, the following code creates a state key named `PausableState`. + /// It's convenient to store state values in the key type, so the + /// `PausableState` structure includes properties for the stored state + /// values `paused` and `pauseTime`. + /// + /// private struct PausableState: AnimationStateKey { + /// var paused = false + /// var pauseTime: TimeInterval = 0.0 + /// + /// static var defaultValue: Self { .init() } + /// } + /// + /// To provide access the pausable state, the following code extends + /// `AnimationContext` to include the `pausableState` property. This + /// property returns an instance of the custom `PausableState` structure + /// stored in ``AnimationContext/state``, and it can also store a new + /// `PausableState` instance in `state`. + /// + /// extension AnimationContext { + /// fileprivate var pausableState: PausableState { + /// get { state[PausableState.self] } + /// set { state[PausableState.self] = newValue } + /// } + /// } + /// + /// Now a custom animation can use the `pausableState` property instead of + /// the ``AnimationContext/state`` property as a convenient way to read and + /// write state values as the animation runs. + /// + /// struct PausableAnimation: CustomAnimation { + /// func animate(value: V, time: TimeInterval, context: inout AnimationContext) -> V? where V : VectorArithmetic { + /// let pausableState = context.pausableState + /// var pauseTime = pausableState.pauseTime + /// ... + /// } + /// } + /// + public var state: AnimationState + + private var _environment: WeakAttribute? + + /// Set this to `true` to indicate that an animation is logically complete. + /// + /// This controls when AnimationCompletionCriteria.logicallyComplete + /// completion callbacks are fired. This should be set to `true` at most + /// once in the life of an animation, changing back to `false` later will be + /// ignored. If this is never set to `true`, the behavior is equivalent to + /// if this had been set to `true` just as the animation finished (by + /// returning `nil`). + public var isLogicallyComplete: Bool + + /// The current environment of the view that created the custom animation. + /// + /// An instance of ``CustomAnimation`` uses this property to read + /// environment values from the view that created the animation. To learn + /// more about environment values including how to define custom + /// environment values, see ``EnvironmentValues``. + public var environment: EnvironmentValues { + _environment?.value ?? EnvironmentValues() + } + + package init( + state: AnimationState, + environment: WeakAttribute?, + isLogicallyComplete: Bool + ) { + self.state = state + self._environment = environment + self.isLogicallyComplete = isLogicallyComplete + } + + package init( + state: AnimationState, + environment: WeakAttribute? + ) { + self.state = state + self._environment = environment + self.isLogicallyComplete = false + } + + package init( + environment: WeakAttribute?, + isLogicallyComplete: Bool + ) { + self.state = .init() + self._environment = environment + self.isLogicallyComplete = isLogicallyComplete + } + + package init(environment: WeakAttribute?) { + self.state = .init() + self._environment = environment + self.isLogicallyComplete = false + } + + package init( + state: AnimationState = .init(), + environment: Attribute?, + isLogicallyComplete: Bool = false + ) { + self.state = state + self._environment = WeakAttribute(environment) + self.isLogicallyComplete = isLogicallyComplete + } + + package init( + state: AnimationState, + environment: Attribute? + ) { + self.state = state + self._environment = WeakAttribute(environment) + self.isLogicallyComplete = false + } + + package init( + environment: Attribute?, + isLogicallyComplete: Bool + ) { + self.state = .init() + self._environment = WeakAttribute(environment) + self.isLogicallyComplete = isLogicallyComplete + } + + package init(environment: Attribute?) { + self.state = .init() + self._environment = WeakAttribute(environment) + self.isLogicallyComplete = false + } + + /// Creates a new context from another one with a state that you provide. + /// + /// Use this method to create a new context that contains the state that + /// you provide and view environment values from the original context. + /// + /// - Parameter state: The initial state for the new context. + /// - Returns: A new context that contains the specified state. + public func withState( + _ state: AnimationState + ) -> AnimationContext where T: VectorArithmetic { + AnimationContext( + state: state, + environment: _environment, + isLogicallyComplete: isLogicallyComplete + ) + } +} + +@available(*, unavailable) +extension AnimationContext: Sendable {} From 6d8d1a67c364ad0c93476a98966ec6119143606b Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 13 Jul 2025 00:16:26 +0800 Subject: [PATCH 3/9] Add AnimationListener implementation --- .../Animation/AnimationListener.swift | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 Sources/OpenSwiftUICore/View/Animation/Animation/AnimationListener.swift diff --git a/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationListener.swift b/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationListener.swift new file mode 100644 index 000000000..3846e28ff --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Animation/Animation/AnimationListener.swift @@ -0,0 +1,227 @@ +// +// AnimationListener.swift +// OpenSwiftUICore +// +// Audited: 6.5.4 +// Status: Complete +// ID: 390609F81ACEBEAF00AD8179BD31E870 (SwiftUICore) + +import Dispatch + +// MARK: - AnimationListener + +@_spi(ForOpenSwiftUIOnly) +@available(OpenSwiftUI_v6_0, *) +open class AnimationListener: @unchecked Sendable { + public init() { + _openSwiftUIEmptyStub() + } + + open func animationWasAdded() { + _openSwiftUIEmptyStub() + } + + open func animationWasRemoved() { + _openSwiftUIEmptyStub() + } + + open func finalizeTransaction() { + _openSwiftUIEmptyStub() + } +} + +// MARK: - ListenerPair + +private final class ListenerPair: AnimationListener, @unchecked Sendable { + let first: AnimationListener + let second: AnimationListener + + init(first: AnimationListener, second: AnimationListener) { + self.first = first + self.second = second + super.init() + } + + override func animationWasAdded() { + first.animationWasAdded() + second.animationWasAdded() + } + + override func animationWasRemoved() { + first.animationWasAdded() + second.animationWasRemoved() + } +} + +// MARK: - AllFinishedListener + +private final class AllFinishedListener: AnimationListener, @unchecked Sendable { + let allFinished: (Transaction.AnimationCompletionInfo) -> Void + var count: Int + var maxCount: Int + var dispatched: Bool + + init(allFinished: @escaping (Transaction.AnimationCompletionInfo) -> Void) { + self.allFinished = allFinished + self.count = 0 + self.maxCount = 0 + self.dispatched = false + super.init() + } + + override func animationWasAdded() { + count += 1 + maxCount += 1 + } + + override func animationWasRemoved() { + count -= 1 + dispatchIfNeeded() + } + + override func finalizeTransaction() { + dispatchIfNeeded() + } + + @inline(__always) + func dispatchIfNeeded() { + guard count == 0, !dispatched else { + return + } + dispatched = true + allFinished(.init(completedCount: maxCount)) + } + + deinit { + dispatchIfNeeded() + } +} + +// MARK: - Transaction + AnimationListener + +extension Transaction { + private static var pendingListeners = AtomicBox(wrappedValue: PendingListeners()) + + private struct PendingListeners { + var pending: [WeakListener] = [] + var next: DispatchTime? + + struct WeakListener { + weak var listener: AnimationListener? + var time: DispatchTime + } + } + + private static func addPendingListener(_ listener: AnimationListener) { + pendingListeners.access { pendingListeners in + let time = DispatchTime.now() + 0.01 + pendingListeners.pending.append(.init(listener: listener, time: time)) + if pendingListeners.next == nil { + pendingListeners.next = time + DispatchQueue.main.asyncAfter(deadline: time) { + dispatchPending() + } + } + } + } + + private static func dispatchPending() { + let pendingListeners = pendingListeners.access { pendingListeners in + guard let next = pendingListeners.next else { + let pending = pendingListeners.pending + pendingListeners.pending = [] + return pending + } + pendingListeners.next = nil + let pending = pendingListeners.pending + let filtered = pending.filter { $0.time > next } + pendingListeners.pending = filtered + guard !filtered.isEmpty else { + return pending + } + DispatchQueue.main.asyncAfter(deadline: next) { + dispatchPending() + } + return pending.filter { $0.time <= next } + } + guard !pendingListeners.isEmpty else { + return + } + Update.ensure { + for pendingListener in pendingListeners { + guard let listener = pendingListener.listener else { + continue + } + listener.finalizeTransaction() + } + } + } + + private struct AnimationListenerKey: TransactionKey { + static var defaultValue: AnimationListener? { nil } + } + + package var animationListener: AnimationListener? { + self[AnimationListenerKey.self] + } + + package mutating func addAnimationListener(_ listener: AnimationListener) { + Self.addPendingListener(listener) + if let existing = self[AnimationListenerKey.self] { + self[AnimationListenerKey.self] = ListenerPair(first: existing, second: listener) + } else { + self[AnimationListenerKey.self] = listener + } + } + + package mutating func addAnimationListener(allFinished: @escaping () -> Void) { + addAnimationListener(AllFinishedListener(allFinished: { _ in allFinished() })) + } + + package mutating func addAnimationListener(allFinished: @escaping (Transaction.AnimationCompletionInfo) -> Void) { + addAnimationListener(AllFinishedListener(allFinished: allFinished)) + } + + private struct AnimationLogicalListenerKey: TransactionKey { + static var defaultValue: AnimationListener? { nil } + } + + package var animationLogicalListener: AnimationListener? { + self[AnimationLogicalListenerKey.self] + } + + package mutating func addAnimationLogicalListener(_ listener: AnimationListener) { + Self.addPendingListener(listener) + if let existing = self[AnimationLogicalListenerKey.self] { + self[AnimationLogicalListenerKey.self] = ListenerPair(first: existing, second: listener) + } else { + self[AnimationLogicalListenerKey.self] = listener + } + } + + package mutating func addAnimationLogicalListener(allFinished: @escaping () -> Void) { + addAnimationListener(AllFinishedListener(allFinished: { _ in allFinished() })) + } + + package mutating func addAnimationLogicalListener(allFinished: @escaping (Transaction.AnimationCompletionInfo) -> Void) { + addAnimationListener(AllFinishedListener(allFinished: allFinished)) + } + + package struct AnimationCompletionInfo { + package var completedCount: Int + + package init(completedCount: Int) { + self.completedCount = completedCount + } + } + + package var combinedAnimationListener: AnimationListener? { + let animationListener = animationListener + let animationLogicalListener = animationLogicalListener + if let animationListener, let animationLogicalListener { + return ListenerPair(first: animationListener, second: animationLogicalListener) + } else { + return animationListener ?? animationLogicalListener + } + } +} From 8d688fbdff779cbdd4872d92cdfebc4608d238f3 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 13 Jul 2025 13:19:22 +0800 Subject: [PATCH 4/9] Add Copilot generated MutableCollection+Extension --- .../MutableCollection+Extension.swift | 215 +++++++++++ .../MutableCollectionExtensionTests.swift | 349 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 Sources/OpenSwiftUICore/Util/Extension/MutableCollection+Extension.swift create mode 100644 Tests/OpenSwiftUICoreTests/Util/Extension/MutableCollectionExtensionTests.swift diff --git a/Sources/OpenSwiftUICore/Util/Extension/MutableCollection+Extension.swift b/Sources/OpenSwiftUICore/Util/Extension/MutableCollection+Extension.swift new file mode 100644 index 000000000..7b91aaf4b --- /dev/null +++ b/Sources/OpenSwiftUICore/Util/Extension/MutableCollection+Extension.swift @@ -0,0 +1,215 @@ +// +// MutableCollection+Extension.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Implmeneted by Copilot + +public import Foundation + +@available(OpenSwiftUI_v1_0, *) +extension RangeReplaceableCollection where Self: MutableCollection { + public mutating func remove(atOffsets offsets: IndexSet) { + guard !offsets.isEmpty else { return } + let partitionIndex = halfStablePartitionByOffset { offset in + offsets.contains(offset) + } + removeSubrange(partitionIndex...) + } +} + +extension RangeReplaceableCollection { + package mutating func _remove(atOffsets offsets: IndexSet) { + guard !offsets.isEmpty else { return } + + let ranges = offsets.rangeView.reversed() + + for range in ranges { + let startOffset = range.lowerBound + let endOffset = range.upperBound + + guard startOffset >= 0 && startOffset < count else { continue } + guard endOffset > startOffset && endOffset <= count else { continue } + + let startIndex = index(self.startIndex, offsetBy: startOffset) + let endIndex = index(self.startIndex, offsetBy: endOffset) + + removeSubrange(startIndex.. Bool) rethrows -> (Index, Int)? { + var offset = 0 + var currentIndex = startIndex + + while currentIndex != endIndex { + if try predicate(offset) { + return (currentIndex, offset) + } + formIndex(after: ¤tIndex) + offset += 1 + } + + return nil + } +} + +extension MutableCollection { + package mutating func halfStablePartitionByOffset(isSuffixElementAtOffset: (Int) throws -> Bool) rethrows -> Index { + guard !isEmpty else { return startIndex } + + var writeIndex = startIndex + var offset = 0 + + for readIndex in indices { + let shouldKeep = try !isSuffixElementAtOffset(offset) + if shouldKeep { + if writeIndex != readIndex { + swapAt(writeIndex, readIndex) + } + formIndex(after: &writeIndex) + } + offset += 1 + } + + return writeIndex + } +} + +@available(OpenSwiftUI_v1_0, *) +extension MutableCollection where Self: RangeReplaceableCollection { + public mutating func move(fromOffsets source: IndexSet, toOffset destination: Int) { + guard !source.isEmpty else { return } + guard destination >= 0 && destination <= count else { return } + + let sourceIndices = source.compactMap { offset -> Index? in + guard offset >= 0 && offset < count else { return nil } + return index(startIndex, offsetBy: offset) + } + + guard !sourceIndices.isEmpty else { return } + + let sourceElements = sourceIndices.map { self[$0] } + + var adjustedDestination = destination + for offset in source.sorted() { + if offset < destination { + adjustedDestination -= 1 + } + } + + for offset in source.sorted(by: >) { + guard offset >= 0 && offset < count else { continue } + let indexToRemove = index(startIndex, offsetBy: offset) + remove(at: indexToRemove) + } + + if adjustedDestination >= 0 && adjustedDestination <= count { + let insertionIndex = index(startIndex, offsetBy: adjustedDestination) + insert(contentsOf: sourceElements, at: insertionIndex) + } + } + + @discardableResult + package mutating func stablePartitionByOffset( + in range: Range, + startOffset: Int, + isSuffixElementAtOffset: (Int) throws -> Bool + ) rethrows -> Index { + guard range.lowerBound != range.upperBound else { return range.lowerBound } + + var writeIndex = range.lowerBound + var currentIndex = range.lowerBound + var offset = startOffset + + while currentIndex != range.upperBound { + let shouldKeep = try !isSuffixElementAtOffset(offset) + if shouldKeep { + if writeIndex != currentIndex { + swapAt(writeIndex, currentIndex) + } + formIndex(after: &writeIndex) + } + formIndex(after: ¤tIndex) + offset += 1 + } + + return writeIndex + } + + package mutating func stablePartitionByOffset( + in range: Range, + startOffset: Int, + count n: Int, + isSuffixElementAtOffset: (Int) throws -> Bool + ) rethrows -> Index { + guard n > 0 else { return range.lowerBound } + guard range.lowerBound != range.upperBound else { return range.lowerBound } + + let endIndex = index(range.lowerBound, offsetBy: Swift.min(n, distance(from: range.lowerBound, to: range.upperBound))) + let subRange = range.lowerBound.., + shiftingToStart middle: Index + ) -> Index { + guard range.lowerBound != range.upperBound else { return range.lowerBound } + guard middle != range.lowerBound && middle != range.upperBound else { return middle } + + let firstHalf = range.lowerBound.., + _ rhs: Range + ) -> (Index, Index) { + let lhsCount = distance(from: lhs.lowerBound, to: lhs.upperBound) + let rhsCount = distance(from: rhs.lowerBound, to: rhs.upperBound) + let swapCount = Swift.min(lhsCount, rhsCount) + + var lhsIndex = lhs.lowerBound + var rhsIndex = rhs.lowerBound + + for _ in 0.. Date: Sun, 13 Jul 2025 15:33:21 +0800 Subject: [PATCH 5/9] Add AnimatorState --- .../Animation/AnimatableAttribute.swift | 512 ++++++++++++++++++ .../View/Animation/Animation/Animation.swift | 19 + .../Animation/Animation/CustomAnimation.swift | 233 -------- .../Animation/TransactionAnimation.swift | 10 + 4 files changed, 541 insertions(+), 233 deletions(-) create mode 100644 Sources/OpenSwiftUICore/View/Animation/Animation/AnimatableAttribute.swift delete mode 100644 Sources/OpenSwiftUICore/View/Animation/Animation/CustomAnimation.swift diff --git a/Sources/OpenSwiftUICore/View/Animation/Animation/AnimatableAttribute.swift b/Sources/OpenSwiftUICore/View/Animation/Animation/AnimatableAttribute.swift new file mode 100644 index 000000000..bd1ea3c0d --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Animation/Animation/AnimatableAttribute.swift @@ -0,0 +1,512 @@ +// +// AnimatableAttribute.swift +// OpenSwiftUICore +// +// Status: WIP +// ID: 35ADF281214A25133F1A6DF28858952D (SwiftUICore) + +package import Foundation +package import OpenGraphShims + +// MARK: - AnimatableAttribute [6.4.41] [TODO] + +package struct AnimatableAttribute: StatefulRule, AsyncAttribute, ObservedAttribute, CustomStringConvertible where Value: Animatable { + @Attribute var source: Value + @Attribute var environment: EnvironmentValues + var helper: AnimatableAttributeHelper + + package init( + source: Attribute, + phase: Attribute<_GraphInputs.Phase>, + time: Attribute