From d06624dbb92276a19048d604af181253ced0485d Mon Sep 17 00:00:00 2001 From: hyonsoo_iscream Date: Mon, 16 Mar 2026 15:45:45 +0900 Subject: [PATCH 1/5] refactor: replace Thread+NSRecursiveLock with Actor + DisplayLink - Add AnimationEngine actor to manage animation list with actor isolation - Add DisplayLinkDriver protocol with platform-specific implementations: - CADisplayLinkDriver (iOS/tvOS) - CVDisplayLinkDriver (macOS) - TimerDriver (watchOS/fallback) - Remove manual Thread, NSRecursiveLock, Thread.sleep from ValueAnimator - Make ValueAnimatable Sendable - Mark ValueAnimator as @unchecked Sendable for actor boundary - Update minimum platforms to iOS 13 / macOS 10.15 / tvOS 13 / watchOS 6 - Deprecate frameRate (superseded by vsync-based drivers) --- Package.swift | 4 +- .../ValueAnimator/Core/AnimationEngine.swift | 205 ++++++++++++++ .../Core/CADisplayLinkDriver.swift | 42 +++ .../Core/CVDisplayLinkDriver.swift | 46 +++ .../Core/DisplayLinkDriver.swift | 35 +++ Sources/ValueAnimator/Core/TimerDriver.swift | 49 ++++ .../ValueAnimator/Core/ValueAnimatable.swift | 2 +- .../ValueAnimator/Core/ValueAnimator.swift | 268 ++++-------------- 8 files changed, 434 insertions(+), 217 deletions(-) create mode 100644 Sources/ValueAnimator/Core/AnimationEngine.swift create mode 100644 Sources/ValueAnimator/Core/CADisplayLinkDriver.swift create mode 100644 Sources/ValueAnimator/Core/CVDisplayLinkDriver.swift create mode 100644 Sources/ValueAnimator/Core/DisplayLinkDriver.swift create mode 100644 Sources/ValueAnimator/Core/TimerDriver.swift diff --git a/Package.swift b/Package.swift index 18a15a0..ba52c71 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.5 import PackageDescription let package = Package( name: "ValueAnimator", platforms: [ - .iOS(.v12), .tvOS(.v12), .macOS(.v11), .watchOS(.v7) + .iOS(.v13), .tvOS(.v13), .macOS(.v10_15), .watchOS(.v6) ], products: [ .library( diff --git a/Sources/ValueAnimator/Core/AnimationEngine.swift b/Sources/ValueAnimator/Core/AnimationEngine.swift new file mode 100644 index 0000000..9d2329d --- /dev/null +++ b/Sources/ValueAnimator/Core/AnimationEngine.swift @@ -0,0 +1,205 @@ +// +// AnimationEngine.swift +// ValueAnimator +// +// Created on 2026. 3. 16.. +// + +import Foundation + +/// Central actor that owns the animation list and drives frame updates. +/// Replaces the previous Thread + NSRecursiveLock approach with actor isolation. +actor AnimationEngine { + + static let shared = AnimationEngine() + + private var animations = Set() + private var driver: DisplayLinkDriver? + + var count: Int { + animations.count + } + + // MARK: - List management + + func add(_ ani: ValueAnimator) { + animations.insert(ani) + if ValueAnimator.debug { + print("ValueAnimator -----------: aniList added id: \(ani.hashValue)") + } + startDriverIfNeeded() + } + + func finishAll() { + let snapshot = animations + for ani in snapshot { + finishAnimation(ani) + } + } + + func disposeAll() { + let snapshot = animations + for ani in snapshot { + disposeAnimation(ani) + } + } + + func hasAnimation(_ prop: String) -> Bool { + animations.contains { $0.props.contains(prop) } + } + + // MARK: - Per-frame update + + /// Called by the DisplayLinkDriver on every frame. + nonisolated func handleTick(_ timestamp: TimeInterval) { + Task { await tick(timestamp) } + } + + private func tick(_ timestamp: TimeInterval) { + let snapshot = animations + if snapshot.isEmpty { + stopDriver() + return + } + for ani in snapshot { + update(ani, now: timestamp) + } + } + + // MARK: - Animation update (ported from the old static methods) + + private func update(_ ani: ValueAnimator, now: TimeInterval) { + if ani.isDisposed { + disposeAnimation(ani) + return + } + if ani.isFinished { + finishAnimation(ani) + return + } + if !ani.isAnimating { + ani.startTime = now - ani.covered + return + } + if ani.delay > 0 { + ani.delay -= (now - ani.startTime) + ani.startTime = now + return + } + // 시간 계산 + ani.covered = now - ani.startTime + // repeating + if ani.covered >= ani.duration { + if ani.yoyo { + if ani.repeatCount <= 0 || ani.repeatCount > ani.counted || ani.isInfinitely { + for p in ani.props { + if let initial = ani.initials[p], + let changeEnd = ani.changes[p] { + let changed = initial + changeEnd + notifyChange(ani, p, changed) + ani.initials[p] = changed + ani.changes[p]! *= -1.0 + } + } + ani.startTime = now + ani.counted += 1 + return + } + } + if ani.counted < ani.repeatCount || ani.isInfinitely { + for p in ani.props { + if let initial = ani.initials[p] { + notifyChange(ani, p, initial) + } + } + ani.startTime = now + ani.counted += 1 + return + } + + finishAnimation(ani) + } else { + // call updates in progress + for p in ani.props { + let value = ani.easing( + ani.covered, + ani.initials[p]!.value, + ani.changes[p]!.value, + ani.duration + ).toAnimatable() + notifyChange(ani, p, value) + } + } + } + + // MARK: - Callbacks + + private func notifyChange(_ ani: ValueAnimator, _ p: String, _ v: ValueAnimatable) { + if ani.callbackOnMainThread { + if Thread.isMainThread { + ani.changeCallback?(p, v) + } else { + DispatchQueue.main.async { + ani.changeCallback?(p, v) + } + } + } else { + ani.changeCallback?(p, v) + } + } + + // MARK: - Finish / Dispose + + private func finishAnimation(_ ani: ValueAnimator) { + animations.remove(ani) + for p in ani.props { + if let initial = ani.initials[p], + let amount = ani.changes[p] { + notifyChange(ani, p, initial + amount) + } + } + ani.isAnimating = false + ani.isFinished = true + if ani.callbackOnMainThread { + DispatchQueue.main.async { + ani.endCallback?() + } + } else { + ani.endCallback?() + } + stopDriverIfEmpty() + } + + private func disposeAnimation(_ ani: ValueAnimator) { + animations.remove(ani) + ani.isDisposed = true + ani.isAnimating = false + stopDriverIfEmpty() + } + + // MARK: - Driver lifecycle + + private func startDriverIfNeeded() { + guard driver == nil else { return } + let d = DisplayLinkDriverFactory.makeDriver() + d.onTick = { [weak self] timestamp in + self?.handleTick(timestamp) + } + d.start() + driver = d + } + + private func stopDriver() { + driver?.stop() + driver = nil + if ValueAnimator.debug { + print("ValueAnimator nothing to animate") + } + } + + private func stopDriverIfEmpty() { + if animations.isEmpty { + stopDriver() + } + } +} diff --git a/Sources/ValueAnimator/Core/CADisplayLinkDriver.swift b/Sources/ValueAnimator/Core/CADisplayLinkDriver.swift new file mode 100644 index 0000000..a8ea8a6 --- /dev/null +++ b/Sources/ValueAnimator/Core/CADisplayLinkDriver.swift @@ -0,0 +1,42 @@ +// +// CADisplayLinkDriver.swift +// ValueAnimator +// +// Created on 2026. 3. 16.. +// + +import Foundation +import QuartzCore + +// MARK: - iOS / tvOS (CADisplayLink via UIKit) + +#if canImport(UIKit) && !os(watchOS) + +final class CADisplayLinkDriver: DisplayLinkDriver { + var onTick: ((TimeInterval) -> Void)? + private var displayLink: CADisplayLink? + + func start() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(handleTick(_:))) + link.add(to: .main, forMode: .common) + displayLink = link + } + + func stop() { + displayLink?.invalidate() + displayLink = nil + } + + @objc private func handleTick(_ link: CADisplayLink) { + onTick?(link.timestamp) + } + + deinit { + stop() + } +} + +#endif + + diff --git a/Sources/ValueAnimator/Core/CVDisplayLinkDriver.swift b/Sources/ValueAnimator/Core/CVDisplayLinkDriver.swift new file mode 100644 index 0000000..e8ff87c --- /dev/null +++ b/Sources/ValueAnimator/Core/CVDisplayLinkDriver.swift @@ -0,0 +1,46 @@ +// +// CVDisplayLinkDriver.swift +// ValueAnimator +// +// Created on 2026. 3. 16.. +// + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation +import CoreVideo + +/// macOS < 14 frame driver using CVDisplayLink. +final class CVDisplayLinkDriver: DisplayLinkDriver { + var onTick: ((TimeInterval) -> Void)? + private var displayLink: CVDisplayLink? + + func start() { + guard displayLink == nil else { return } + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link = link else { return } + displayLink = link + + CVDisplayLinkSetOutputHandler(link) { [weak self] _, inNow, _, _, _ -> CVReturn in + let timestamp = Double(inNow.pointee.videoTime) / Double(inNow.pointee.videoTimeScale) + DispatchQueue.main.async { + self?.onTick?(timestamp) + } + return kCVReturnSuccess + } + CVDisplayLinkStart(link) + } + + func stop() { + guard let link = displayLink else { return } + CVDisplayLinkStop(link) + displayLink = nil + } + + deinit { + stop() + } +} + +#endif diff --git a/Sources/ValueAnimator/Core/DisplayLinkDriver.swift b/Sources/ValueAnimator/Core/DisplayLinkDriver.swift new file mode 100644 index 0000000..6d89d9a --- /dev/null +++ b/Sources/ValueAnimator/Core/DisplayLinkDriver.swift @@ -0,0 +1,35 @@ +// +// DisplayLinkDriver.swift +// ValueAnimator +// +// Created on 2026. 3. 16.. +// + +import Foundation + +/// Abstraction for a frame-rate driver that delivers periodic ticks. +/// Each platform provides a concrete implementation. +protocol DisplayLinkDriver: AnyObject { + /// Called on every frame with the current timestamp (seconds). + var onTick: ((TimeInterval) -> Void)? { get set } + /// Start delivering ticks. + func start() + /// Stop delivering ticks and release resources. + func stop() +} + +/// Factory that returns the best available driver for the current platform. +enum DisplayLinkDriverFactory { + static func makeDriver() -> DisplayLinkDriver { + #if canImport(UIKit) && !os(watchOS) + // iOS / tvOS — and macOS Catalyst + return CADisplayLinkDriver() + #elseif canImport(AppKit) + // macOS native — uses CVDisplayLink for vsync + return CVDisplayLinkDriver() + #else + // watchOS and any other platform + return TimerDriver() + #endif + } +} diff --git a/Sources/ValueAnimator/Core/TimerDriver.swift b/Sources/ValueAnimator/Core/TimerDriver.swift new file mode 100644 index 0000000..76d7e1f --- /dev/null +++ b/Sources/ValueAnimator/Core/TimerDriver.swift @@ -0,0 +1,49 @@ +// +// TimerDriver.swift +// ValueAnimator +// +// Created on 2026. 3. 16.. +// + +import Foundation + +/// Fallback frame driver using DispatchSourceTimer. +/// Used on watchOS and any platform without CADisplayLink / CVDisplayLink. +final class TimerDriver: DisplayLinkDriver { + var onTick: ((TimeInterval) -> Void)? + private var timer: DispatchSourceTimer? + private let interval: TimeInterval + + init(fps: Int = 60) { + self.interval = 1.0 / Double(fps) + } + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .main) + t.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(1)) + t.setEventHandler { [weak self] in + self?.onTick?(CACurrentMediaTime()) + } + t.resume() + timer = t + } + + func stop() { + timer?.cancel() + timer = nil + } + + deinit { + stop() + } +} + +/// Portable monotonic clock — uses QuartzCore when available, falls back to Foundation. +func CACurrentMediaTime() -> TimeInterval { + #if canImport(QuartzCore) + return QuartzCore.CACurrentMediaTime() + #else + return ProcessInfo.processInfo.systemUptime + #endif +} diff --git a/Sources/ValueAnimator/Core/ValueAnimatable.swift b/Sources/ValueAnimator/Core/ValueAnimatable.swift index c97c6ca..263a094 100644 --- a/Sources/ValueAnimator/Core/ValueAnimatable.swift +++ b/Sources/ValueAnimator/Core/ValueAnimatable.swift @@ -13,7 +13,7 @@ public protocol AnimatableValueType { func toAnimatable() -> ValueAnimatable } -public struct ValueAnimatable { +public struct ValueAnimatable: Sendable { public var value: Double public init(_ value: Int) { diff --git a/Sources/ValueAnimator/Core/ValueAnimator.swift b/Sources/ValueAnimator/Core/ValueAnimator.swift index 03e58aa..5478f83 100644 --- a/Sources/ValueAnimator/Core/ValueAnimator.swift +++ b/Sources/ValueAnimator/Core/ValueAnimator.swift @@ -4,13 +4,13 @@ import Foundation -public class ValueAnimator: Hashable { +public class ValueAnimator: Hashable, @unchecked Sendable { public struct Option { - let yoyo: Bool - let repeatCount: Int - let delay: TimeInterval - let repeatInfinitely: Bool + public let yoyo: Bool + public let repeatCount: Int + public let delay: TimeInterval + public let repeatInfinitely: Bool } public class OptionBuilder { @@ -52,42 +52,28 @@ public class ValueAnimator: Hashable { private lazy var objectIdentifier = ObjectIdentifier(self) - #if swift(>=5.0) public func hash(into hasher: inout Hasher) { hasher.combine(objectIdentifier) } - #elseif swift(>=4.2) - #if compiler(>=5.0) - public func hash(into hasher: inout Hasher) { - hasher.combine(objectIdentifier) - } - #else - var hashValue: Int { - return self.objectIdentifier.hashValue - } - #endif - #else - public var hashValue: Int { - return self.objectIdentifier.hashValue - } - #endif - private var props = [String]() - private var startTime: TimeInterval = 0 - private var initials = [String: ValueAnimatable]() - private var changes = [String: ValueAnimatable]() + // MARK: - Internal state (mutated only by AnimationEngine) + + internal var props = [String]() + internal var startTime: TimeInterval = 0 + internal var initials = [String: ValueAnimatable]() + internal var changes = [String: ValueAnimatable]() /// animation duration of one cycle in seconds - public private(set) var duration: TimeInterval = 1 - /// seconds in covered on timeline - public private(set) var covered: TimeInterval = 0 + public internal(set) var duration: TimeInterval = 1 + /// seconds covered on timeline + public internal(set) var covered: TimeInterval = 0 /// seconds to delay - public private(set) var delay: TimeInterval = 0 + public internal(set) var delay: TimeInterval = 0 /// yoyo animation - public private(set) var yoyo = false + public internal(set) var yoyo = false /// how many it repeat animation. - public private(set) var repeatCount: Int = 0 + public internal(set) var repeatCount: Int = 0 /// animated count - public private(set) var counted: Int = 0 { + public internal(set) var counted: Int = 0 { didSet { if ValueAnimator.debug { print("ValueAnimator- \(props)-- counted: \(counted)") @@ -107,35 +93,14 @@ public class ValueAnimator: Hashable { } /// whether if it repeat infinitely or not. /// if true, animation ignores repeatCount - public private(set) var isInfinitely = false - public private(set) var isAnimating = false - public private(set) var isFinished = false - public private(set) var isDisposed = false + public internal(set) var isInfinitely = false + public internal(set) var isAnimating = false + public internal(set) var isFinished = false + public internal(set) var isDisposed = false - /// callback for animation updates - @available(*, unavailable, renamed: "changeCallback") - public var changeFunction: ChangeFunction? { - set { - changeCallback = newValue - } - get { - return changeCallback - } - } /// callback for animation updates public var changeCallback: ChangeFunction? = nil - - /// callback for animation finishes - @available(*, unavailable, renamed: "endCallback") - public var endFunction: EndFunction? { - set { - endCallback = newValue - } - get { - return endCallback - } - } - + /// callback for animation finishes public var endCallback: EndFunction? = nil @@ -145,7 +110,7 @@ public class ValueAnimator: Hashable { return left.objectIdentifier == right.objectIdentifier } - private init() { + internal init() { } public func resume() { @@ -164,37 +129,43 @@ public class ValueAnimator: Hashable { isDisposed = true } - - // MARK: class values + // MARK: - Static API (delegates to AnimationEngine) static public var debug = false - static public var frameRate: Int = 40 { - didSet { - sleepTime = 1 / Double(frameRate) - } - } - static private var nowTime: TimeInterval = 0 - static private var renderer: Thread? = nil - static private var aniList = Set() - static private var sleepTime: TimeInterval = 0.02 - static private let lock = NSRecursiveLock() - + + @available(*, deprecated, message: "frameRate is ignored when using CADisplayLink. Only affects watchOS TimerDriver.") + static public var frameRate: Int = 60 + static public var count: Int { - return aniList.count + // Provide a synchronous best-effort read. + // For exact count use: await AnimationEngine.shared.count + var result = 0 + let semaphore = DispatchSemaphore(value: 0) + Task { + result = await AnimationEngine.shared.count + semaphore.signal() + } + semaphore.wait() + return result } static public func finishAll() { - aniList.forEach { - $0.finish() - } + Task { await AnimationEngine.shared.finishAll() } } static public func disposeAll() { - aniList.removeAll() + Task { await AnimationEngine.shared.disposeAll() } } static public func hasAnimation(_ prop: String) -> Bool { - return aniList.first{ $0.props.contains(prop) } != nil + var result = false + let semaphore = DispatchSemaphore(value: 0) + Task { + result = await AnimationEngine.shared.hasAnimation(prop) + semaphore.signal() + } + semaphore.wait() + return result } @discardableResult @@ -213,7 +184,7 @@ public class ValueAnimator: Hashable { option: nil, onEnd: nil) } - + @discardableResult static public func animate(_ prop: String, from: AnimatableValueType, @@ -229,7 +200,7 @@ public class ValueAnimator: Hashable { option: nil, onEnd: nil) } - + @discardableResult static public func animate(_ prop: String, from: AnimatableValueType, @@ -278,141 +249,10 @@ public class ValueAnimator: Hashable { ani.repeatCount *= 2 } ani.changeCallback = onChanged - ani.startTime = Date().timeIntervalSince1970 - - lock.lock() - aniList.insert(ani) - lock.unlock() - - if debug { - print("ValueAnimator -----------: aniList added id: \(ani.hashValue)") - } - // start runLoop if not running - if renderer == nil || renderer?.isFinished == true { - renderer = Thread(target: self, selector: #selector(onProgress), object: nil) - renderer?.name = "renderer" - renderer?.qualityOfService = .userInteractive - renderer?.start() - } + ani.startTime = CACurrentMediaTime() - return ani - } + Task { await AnimationEngine.shared.add(ani) } - @objc - static private func onProgress() { - - while renderer != nil && !renderer!.isFinished { - lock.lock() - let list = aniList - lock.unlock() - if list.isEmpty { - Thread.exit() - break - } - for ani in list { - update(ani) - } - Thread.sleep(forTimeInterval: sleepTime) - } - if debug { - print("ValueAnimator nothing to animate") - } - Thread.exit() - } - - static private func update(_ ani: ValueAnimator) { - if ani.isDisposed { - dispose(ani) - return - } - if ani.isFinished { - finish(ani) - return - } - nowTime = Date().timeIntervalSince1970 - if !ani.isAnimating { - ani.startTime = nowTime - ani.covered - return - } - if ani.delay > 0 { - ani.delay -= (nowTime - ani.startTime) - ani.startTime = nowTime - return - } - // 시간 계산 - ani.covered = nowTime - ani.startTime - // repeating - if ani.covered >= ani.duration { - if ani.yoyo { - if ani.repeatCount <= 0 || ani.repeatCount > ani.counted || ani.isInfinitely { - for p in ani.props { - if let initial = ani.initials[p], - let changeEnd = ani.changes[p] { - let changed = initial + changeEnd - change(ani, p, changed) - ani.initials[p] = changed - ani.changes[p]! *= -1.0 - } - } - ani.startTime = nowTime - ani.counted += 1 - return - } - } - if ani.counted < ani.repeatCount || ani.isInfinitely { - for p in ani.props { - if let initial = ani.initials[p] { - change(ani, p, initial) - } - } - ani.startTime = nowTime - ani.counted += 1 - return - } - - finish(ani) - } else { - // call updates in progress - for p in ani.props { - change(ani, p, ani.easing(ani.covered, ani.initials[p]!.value, ani.changes[p]!.value, ani.duration).toAnimatable()) - } - } - } - - static private func change(_ ani: ValueAnimator, _ p: String, _ v: ValueAnimatable) { - if ani.callbackOnMainThread { - DispatchQueue.main.async { - ani.changeCallback?(p, v) - } - } else { - ani.changeCallback?(p, v) - } - } - - /// finish animation and update value with target - static private func finish(_ ani: ValueAnimator) { - aniList.remove(ani) - for p in ani.props { - if let initial = ani.initials[p], - let amount = ani.changes[p] { - change(ani, p, initial + amount) - } - } - ani.isAnimating = false - ani.isFinished = true - if ani.callbackOnMainThread { - DispatchQueue.main.async { - ani.endCallback?() - } - } else { - ani.endCallback?() - } - } - - /// finish animation during animation - static private func dispose(_ ani: ValueAnimator) { - aniList.remove(ani) - ani.isDisposed = true - ani.isAnimating = false + return ani } } From ade29787f621cc39dd2ba265113d5ed2c9a87097 Mon Sep 17 00:00:00 2001 From: hyonsoo_iscream Date: Mon, 16 Mar 2026 16:16:11 +0900 Subject: [PATCH 2/5] test: add concurrent animation tests and fix headless environment support --- .../ValueAnimator/Core/AnimationEngine.swift | 6 + Sources/ValueAnimator/Core/AtomicInt.swift | 31 +++ .../Core/CVDisplayLinkDriver.swift | 23 +- Sources/ValueAnimator/Core/TimerDriver.swift | 19 +- .../ValueAnimator/Core/ValueAnimator.swift | 25 +- .../ConcurrentAnimationTests.swift | 240 ++++++++++++++++++ 6 files changed, 314 insertions(+), 30 deletions(-) create mode 100644 Sources/ValueAnimator/Core/AtomicInt.swift create mode 100644 Tests/ValueAnimatorTests/ConcurrentAnimationTests.swift diff --git a/Sources/ValueAnimator/Core/AnimationEngine.swift b/Sources/ValueAnimator/Core/AnimationEngine.swift index 9d2329d..8d766e1 100644 --- a/Sources/ValueAnimator/Core/AnimationEngine.swift +++ b/Sources/ValueAnimator/Core/AnimationEngine.swift @@ -16,6 +16,9 @@ actor AnimationEngine { private var animations = Set() private var driver: DisplayLinkDriver? + /// Thread-safe animation count — updated atomically on every add/remove. + nonisolated let animationCount = AtomicInt(0) + var count: Int { animations.count } @@ -24,6 +27,7 @@ actor AnimationEngine { func add(_ ani: ValueAnimator) { animations.insert(ani) + animationCount.store(animations.count) if ValueAnimator.debug { print("ValueAnimator -----------: aniList added id: \(ani.hashValue)") } @@ -152,6 +156,7 @@ actor AnimationEngine { private func finishAnimation(_ ani: ValueAnimator) { animations.remove(ani) + animationCount.store(animations.count) for p in ani.props { if let initial = ani.initials[p], let amount = ani.changes[p] { @@ -172,6 +177,7 @@ actor AnimationEngine { private func disposeAnimation(_ ani: ValueAnimator) { animations.remove(ani) + animationCount.store(animations.count) ani.isDisposed = true ani.isAnimating = false stopDriverIfEmpty() diff --git a/Sources/ValueAnimator/Core/AtomicInt.swift b/Sources/ValueAnimator/Core/AtomicInt.swift new file mode 100644 index 0000000..61bb4c4 --- /dev/null +++ b/Sources/ValueAnimator/Core/AtomicInt.swift @@ -0,0 +1,31 @@ +// +// AtomicInt.swift +// ValueAnimator +// +// Created on 2026. 3. 16.. +// + +import Foundation + +/// A simple lock-free atomic integer using os_unfair_lock for thread-safe reads/writes. +final class AtomicInt: @unchecked Sendable { + private var _value: Int + private var _lock = os_unfair_lock() + + init(_ value: Int) { + _value = value + } + + func load() -> Int { + os_unfair_lock_lock(&_lock) + let v = _value + os_unfair_lock_unlock(&_lock) + return v + } + + func store(_ newValue: Int) { + os_unfair_lock_lock(&_lock) + _value = newValue + os_unfair_lock_unlock(&_lock) + } +} diff --git a/Sources/ValueAnimator/Core/CVDisplayLinkDriver.swift b/Sources/ValueAnimator/Core/CVDisplayLinkDriver.swift index e8ff87c..88d0232 100644 --- a/Sources/ValueAnimator/Core/CVDisplayLinkDriver.swift +++ b/Sources/ValueAnimator/Core/CVDisplayLinkDriver.swift @@ -15,11 +15,21 @@ final class CVDisplayLinkDriver: DisplayLinkDriver { var onTick: ((TimeInterval) -> Void)? private var displayLink: CVDisplayLink? + /// Falls back to a timer if CVDisplayLink cannot be created (headless / CI). + private var fallback: TimerDriver? + func start() { - guard displayLink == nil else { return } + guard displayLink == nil, fallback == nil else { return } var link: CVDisplayLink? CVDisplayLinkCreateWithActiveCGDisplays(&link) - guard let link = link else { return } + guard let link = link else { + // No display available — use timer fallback + let timer = TimerDriver() + timer.onTick = { [weak self] ts in self?.onTick?(ts) } + timer.start() + fallback = timer + return + } displayLink = link CVDisplayLinkSetOutputHandler(link) { [weak self] _, inNow, _, _, _ -> CVReturn in @@ -33,9 +43,12 @@ final class CVDisplayLinkDriver: DisplayLinkDriver { } func stop() { - guard let link = displayLink else { return } - CVDisplayLinkStop(link) - displayLink = nil + if let link = displayLink { + CVDisplayLinkStop(link) + displayLink = nil + } + fallback?.stop() + fallback = nil } deinit { diff --git a/Sources/ValueAnimator/Core/TimerDriver.swift b/Sources/ValueAnimator/Core/TimerDriver.swift index 76d7e1f..984fc25 100644 --- a/Sources/ValueAnimator/Core/TimerDriver.swift +++ b/Sources/ValueAnimator/Core/TimerDriver.swift @@ -18,9 +18,14 @@ final class TimerDriver: DisplayLinkDriver { self.interval = 1.0 / Double(fps) } + private static let timerQueue = DispatchQueue( + label: "com.valueanimator.timer", + qos: .userInteractive + ) + func start() { guard timer == nil else { return } - let t = DispatchSource.makeTimerSource(queue: .main) + let t = DispatchSource.makeTimerSource(queue: TimerDriver.timerQueue) t.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(1)) t.setEventHandler { [weak self] in self?.onTick?(CACurrentMediaTime()) @@ -39,11 +44,13 @@ final class TimerDriver: DisplayLinkDriver { } } -/// Portable monotonic clock — uses QuartzCore when available, falls back to Foundation. +/// Portable monotonic clock. +/// On platforms with QuartzCore, `QuartzCore.CACurrentMediaTime()` is available globally. +/// On other platforms (e.g. watchOS without QuartzCore), provide a fallback. +#if canImport(QuartzCore) +import QuartzCore +#else func CACurrentMediaTime() -> TimeInterval { - #if canImport(QuartzCore) - return QuartzCore.CACurrentMediaTime() - #else return ProcessInfo.processInfo.systemUptime - #endif } +#endif diff --git a/Sources/ValueAnimator/Core/ValueAnimator.swift b/Sources/ValueAnimator/Core/ValueAnimator.swift index 5478f83..a5b90eb 100644 --- a/Sources/ValueAnimator/Core/ValueAnimator.swift +++ b/Sources/ValueAnimator/Core/ValueAnimator.swift @@ -3,6 +3,9 @@ // import Foundation +#if canImport(QuartzCore) +import QuartzCore +#endif public class ValueAnimator: Hashable, @unchecked Sendable { @@ -137,16 +140,7 @@ public class ValueAnimator: Hashable, @unchecked Sendable { static public var frameRate: Int = 60 static public var count: Int { - // Provide a synchronous best-effort read. - // For exact count use: await AnimationEngine.shared.count - var result = 0 - let semaphore = DispatchSemaphore(value: 0) - Task { - result = await AnimationEngine.shared.count - semaphore.signal() - } - semaphore.wait() - return result + AnimationEngine.shared.animationCount.load() } static public func finishAll() { @@ -157,15 +151,8 @@ public class ValueAnimator: Hashable, @unchecked Sendable { Task { await AnimationEngine.shared.disposeAll() } } - static public func hasAnimation(_ prop: String) -> Bool { - var result = false - let semaphore = DispatchSemaphore(value: 0) - Task { - result = await AnimationEngine.shared.hasAnimation(prop) - semaphore.signal() - } - semaphore.wait() - return result + static public func hasAnimation(_ prop: String) async -> Bool { + await AnimationEngine.shared.hasAnimation(prop) } @discardableResult diff --git a/Tests/ValueAnimatorTests/ConcurrentAnimationTests.swift b/Tests/ValueAnimatorTests/ConcurrentAnimationTests.swift new file mode 100644 index 0000000..2c81679 --- /dev/null +++ b/Tests/ValueAnimatorTests/ConcurrentAnimationTests.swift @@ -0,0 +1,240 @@ +import XCTest +@testable import ValueAnimator + +class ConcurrentAnimationTests: XCTestCase { + + override func tearDown() { + ValueAnimator.disposeAll() + super.tearDown() + } + + // MARK: - Multiple animations running simultaneously + + func testMultipleAnimationsAllFinish() { + let count = 5 + let duration: TimeInterval = 0.5 + let expectations = (0.. Date: Mon, 16 Mar 2026 22:20:23 +0900 Subject: [PATCH 3/5] changed Timer name --- Sources/ValueAnimator/Core/TimerDriver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ValueAnimator/Core/TimerDriver.swift b/Sources/ValueAnimator/Core/TimerDriver.swift index 984fc25..2666e95 100644 --- a/Sources/ValueAnimator/Core/TimerDriver.swift +++ b/Sources/ValueAnimator/Core/TimerDriver.swift @@ -19,7 +19,7 @@ final class TimerDriver: DisplayLinkDriver { } private static let timerQueue = DispatchQueue( - label: "com.valueanimator.timer", + label: "com.hansoolabs.valueanimator.timer", qos: .userInteractive ) From 3b7a5bb0f946c0ce1ea0c3d7a62b0e0904358664 Mon Sep 17 00:00:00 2001 From: brownsoo Date: Mon, 16 Mar 2026 22:24:00 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Local=20package=20=EB=A7=8C=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=98=88=EC=A0=9C=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라이브러리 프로젝트 제거 --- .../xcschemes/ValueAnimator-iOS.xcscheme | 2 +- .../xcschemes/ValueAnimator-macOS.xcscheme | 2 +- .../xcschemes/ValueAnimator-tvOS.xcscheme | 2 +- .../xcschemes/ValueAnimator-watchOS.xcscheme | 2 +- .../contents.xcworkspacedata | 3 -- .../xcshareddata/swiftpm/Package.resolved | 14 ------- .../project.pbxproj | 38 +++++++++---------- .../ValueAnimatorExample/ViewController.swift | 21 ++++++++-- 8 files changed, 40 insertions(+), 44 deletions(-) delete mode 100644 ValueAnimator.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/ValueAnimator.xcodeproj/xcshareddata/xcschemes/ValueAnimator-iOS.xcscheme b/ValueAnimator.xcodeproj/xcshareddata/xcschemes/ValueAnimator-iOS.xcscheme index ab2863b..48b89a9 100644 --- a/ValueAnimator.xcodeproj/xcshareddata/xcschemes/ValueAnimator-iOS.xcscheme +++ b/ValueAnimator.xcodeproj/xcshareddata/xcschemes/ValueAnimator-iOS.xcscheme @@ -1,6 +1,6 @@ - - diff --git a/ValueAnimator.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ValueAnimator.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 2c8c271..0000000 --- a/ValueAnimator.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "valueanimator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/brownsoo/ValueAnimator", - "state" : { - "revision" : "0c2fc0cea49592577c95c47cc6a714a294b67c32", - "version" : "0.6.8" - } - } - ], - "version" : 2 -} diff --git a/ValueAnimatorExample/ValueAnimatorExample.xcodeproj/project.pbxproj b/ValueAnimatorExample/ValueAnimatorExample.xcodeproj/project.pbxproj index 52acc46..c6ff461 100644 --- a/ValueAnimatorExample/ValueAnimatorExample.xcodeproj/project.pbxproj +++ b/ValueAnimatorExample/ValueAnimatorExample.xcodeproj/project.pbxproj @@ -3,11 +3,11 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ - 1740C8332943A9CF00E550DF /* ValueAnimator in Frameworks */ = {isa = PBXBuildFile; productRef = 1740C8322943A9CF00E550DF /* ValueAnimator */; }; + 1748DA0E2F6839E400723F0C /* ValueAnimator in Frameworks */ = {isa = PBXBuildFile; productRef = 1748DA0D2F6839E400723F0C /* ValueAnimator */; }; 178F2225209AD54800F5D4D8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178F2224209AD54800F5D4D8 /* AppDelegate.swift */; }; 178F2227209AD54800F5D4D8 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178F2226209AD54800F5D4D8 /* ViewController.swift */; }; 178F222A209AD54800F5D4D8 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 178F2228209AD54800F5D4D8 /* Main.storyboard */; }; @@ -30,7 +30,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1740C8332943A9CF00E550DF /* ValueAnimator in Frameworks */, + 1748DA0E2F6839E400723F0C /* ValueAnimator in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -91,7 +91,7 @@ ); name = ValueAnimatorExample; packageProductDependencies = ( - 1740C8322943A9CF00E550DF /* ValueAnimator */, + 1748DA0D2F6839E400723F0C /* ValueAnimator */, ); productName = ValueAnimatorExample; productReference = 178F2221209AD54800F5D4D8 /* ValueAnimatorExample.app */; @@ -103,8 +103,9 @@ 178F2219209AD54800F5D4D8 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0930; - LastUpgradeCheck = 1220; + LastUpgradeCheck = 2630; ORGANIZATIONNAME = Hansoolabs; TargetAttributes = { 178F2220209AD54800F5D4D8 = { @@ -122,7 +123,7 @@ ); mainGroup = 178F2218209AD54800F5D4D8; packageReferences = ( - 1740C8312943A9CF00E550DF /* XCRemoteSwiftPackageReference "ValueAnimator" */, + 1748DA0C2F6839E400723F0C /* XCLocalSwiftPackageReference "../../ValueAnimator" */, ); productRefGroup = 178F2222209AD54800F5D4D8 /* Products */; projectDirPath = ""; @@ -216,6 +217,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -234,6 +236,7 @@ MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -277,6 +280,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -288,6 +292,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; @@ -304,7 +309,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = ValueAnimatorExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -329,7 +334,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = ValueAnimatorExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -367,21 +372,16 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 1740C8312943A9CF00E550DF /* XCRemoteSwiftPackageReference "ValueAnimator" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/brownsoo/ValueAnimator"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.6.8; - }; +/* Begin XCLocalSwiftPackageReference section */ + 1748DA0C2F6839E400723F0C /* XCLocalSwiftPackageReference "../../ValueAnimator" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../ValueAnimator; }; -/* End XCRemoteSwiftPackageReference section */ +/* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 1740C8322943A9CF00E550DF /* ValueAnimator */ = { + 1748DA0D2F6839E400723F0C /* ValueAnimator */ = { isa = XCSwiftPackageProductDependency; - package = 1740C8312943A9CF00E550DF /* XCRemoteSwiftPackageReference "ValueAnimator" */; productName = ValueAnimator; }; /* End XCSwiftPackageProductDependency section */ diff --git a/ValueAnimatorExample/ValueAnimatorExample/ViewController.swift b/ValueAnimatorExample/ValueAnimatorExample/ViewController.swift index f5c8187..79eb75b 100644 --- a/ValueAnimatorExample/ValueAnimatorExample/ViewController.swift +++ b/ValueAnimatorExample/ValueAnimatorExample/ViewController.swift @@ -67,8 +67,15 @@ class ViewController: UIViewController { let controlBt = UIButton(type: .roundedRect) controlBt.setTitle("Pause", for: .normal) controlBt.titleLabel?.font = UIFont.systemFont(ofSize: 14) - controlBt.frame = CGRect(x: 24, y: 170, width: 100, height: 20) - controlBt.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + + if #available(iOS 15.0, *) { + var config = UIButton.Configuration.plain() + config.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) + controlBt.configuration = config + } else { + controlBt.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + } + controlBt.frame.origin = .init(x: 24, y: 170) controlBt.setNeedsLayout() controlBt.layer.borderColor = UIColor.blue.cgColor controlBt.layer.borderWidth = 1.0 @@ -97,9 +104,15 @@ class ViewController: UIViewController { let newBt = UIButton(type: .system) parent.addSubview(newBt) newBt.setTitle("New Animator", for: .normal) - newBt.frame = CGRect(x: 24, y: 280, width: 30, height: 20) - newBt.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + if #available(iOS 15.0, *) { + var config = UIButton.Configuration.plain() + config.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) + newBt.configuration = config + } else { + newBt.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + } newBt.sizeToFit() + newBt.frame.origin = CGPoint(x: 24, y: 280) newBt.layer.borderColor = UIColor.blue.cgColor newBt.layer.borderWidth = 1.0 newBt.addTarget(self, action: #selector(onClickNew), for: .touchUpInside) From 1b65f0f530afb6ac4aa80c780edfff83c03fe08d Mon Sep 17 00:00:00 2001 From: brownsoo Date: Mon, 16 Mar 2026 22:33:17 +0900 Subject: [PATCH 5/5] docs: refresh README and mark CocoaPods as legacy --- README.md | 126 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 91 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 303356e..8acb575 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,12 @@ [![Version](https://img.shields.io/cocoapods/v/ValueAnimator.svg?style=flat)](http://cocoapods.org/pods/ValueAnimator) [![License](https://img.shields.io/cocoapods/l/ValueAnimator.svg?style=flat)](http://cocoapods.org/pods/ValueAnimator) [![Platform](https://img.shields.io/cocoapods/p/ValueAnimator.svg?style=flat)](http://cocoapods.org/pods/ValueAnimator) -[![codebeat badge](https://codebeat.co/badges/ffe86bc0-24d9-4c1d-993c-274d8f803eb5)](https://codebeat.co/projects/github-com-brownsoo-valueanimator-master) ![actions workflow](https://github.com/brownsoo/ValueAnimator/actions/workflows/swift.yml/badge.svg) -- [ ] Multiple properties in funcion 'onChanged' +Lightweight value interpolation with easing functions for Apple platforms. + +ValueAnimator transitions a value from `from` to `to` over time and reports each frame through a callback. +You can animate a single property or multiple properties simultaneously. ## Demo @@ -14,78 +16,132 @@ ## Usage -ValueAnimator makes the transition from initial value to target value using easing functions. - -#### Simple animation +### Simple animation ```swift -let animator = ValueAnimator.animate("some", from: 0, to: 360, duration: 1.0, - onChanged: { p, v in - print("property \(p): \(v.value)") - }, - easing: EaseCircular.easeIn()) +let animator = ValueAnimator.animate( + "rotation", + from: 0, + to: 360, + duration: 1.0, + easing: EaseCircular.easeIn(), + onChanged: { property, value in + print("property \(property): \(value.value)") + } +) + animator.resume() ``` -#### Yoyo annimation with 2 values +### Multiple properties + yoyo ```swift -let yoyoAni = ValueAnimator.animate( - props: ["h", "w"], - from: [20, 30], - to: [5, 150], - duration: 1.4, - easing: EaseSine.easeInOut(), - onChanged: { p, v in - if p == "h" { +let animator = ValueAnimator.animate( + props: ["h", "w"], + from: [20, 30], + to: [5, 150], + duration: 1.4, + easing: EaseSine.easeInOut(), + onChanged: { property, value in + if property == "h" { let width = self.rect1.bounds.width - self.rect1.frame = CGRect(x: 24, y: 140, width: width, height:v.cg) + self.rect1.frame = CGRect(x: 24, y: 140, width: width, height: value.cg) } else { let height = self.rect1.bounds.height - self.rect1.frame = CGRect(x: 24, y: 140, width:v.cg, height: height) + self.rect1.frame = CGRect(x: 24, y: 140, width: value.cg, height: height) } - }, + }, option: ValueAnimator.OptionBuilder() .setYoyo(true) + .setRepeatCount(2) .build()) -yoyoAni.resume() + +animator.resume() ``` +### Completion callback + +```swift +let animator = ValueAnimator.animate( + props: ["alpha"], + from: [0.0], + to: [1.0], + duration: 0.5, + onEnd: { + print("animation completed") + } +) + +animator.resume() +``` -#### Thread +### Callback thread -ValueAnimator uses its own work-thread. But the callback `changeCallback`, `endCallback` is called in main-thread. if you wanna it called in work-thread, you just set the 'callbackOnMainThread' property is false. +By default, `changeCallback` and `endCallback` are dispatched on the main thread. +If you need callbacks on the animation engine thread, set `callbackOnMainThread = false`. ```swift -let someView: UIView! -let animator = ValueAnimator.animate("some", from: 0, to: 1, duration: 1.0, - onChanged: { p, v in - // called in ValueAnimator's work-thread +let animator = ValueAnimator.animate( + "opacity", + from: 0, + to: 1, + duration: 1.0, + easing: EaseCircular.easeIn(), + onChanged: { _, _ in + // called on ValueAnimator engine thread }, - easing: EaseCircular.easeIn()) +) animator.callbackOnMainThread = false animator.resume() ``` +### Static controls + +```swift +ValueAnimator.finishAll() +ValueAnimator.disposeAll() + +Task { + let isAnimating = await ValueAnimator.hasAnimation("opacity") + print(isAnimating) +} +``` + ## Example To run the example project, clone the repo, and run **open ValueAnimator.xcworkspace**. You can see ValueAnimatorExample project. +## Requirements + +- iOS 13+ +- tvOS 13+ +- macOS 10.15+ +- watchOS 6+ + ## Installation ### [Swift Package Manager](https://swift.org/package-manager/) -* Open Xcode -* Go to File > Swift Packages > Add Package Dependency... -* Paste this Github Repo URL (https://github.com/brownsoo/ValueAnimator) into the search bar. -* Select the ValueAnimator repo from the search results. +- Open Xcode +- Go to File > Add Package Dependencies... +- Paste this repository URL: + +```text +https://github.com/brownsoo/ValueAnimator +``` + +- Select the `ValueAnimator` package and add it to your target. ### [CocoaPods](http://cocoapods.org) -Simply add the following line to your Podfile: +> [!WARNING] +> CocoaPods support is now legacy and no longer actively updated. +> Please use Swift Package Manager for the latest updates. + +Previously, you could add: ```ruby pod 'ValueAnimator'