diff --git a/README.md b/README.md index b741970..5418ed6 100644 --- a/README.md +++ b/README.md @@ -129,15 +129,29 @@ struct ContentView: View { ## Animation Example -The default animation preserves the original rolling spring behavior. Pass -`animation` only when you want to control the rolling speed or timing curve. -Use `.easeIn(duration:)` to speed up over time, `.easeOut(duration:)` to slow -down over time, or `.linear(duration:)` for constant speed. +The default animation uses `.smooth(duration:)`, a critically damped spring that +accelerates then decelerates while preserving in-flight velocity. Pass +`animation` only when you want to control the smooth rolling duration or use +the reel style. ```swift AnimateNumberText(value: $value, textColor: $textColor, - animation: .easeOut(duration: 0.8)) + animation: .smooth(duration: 0.5)) +``` + +Use `.reel(spinningDuration:settleDuration:revolutions:)` when each digit should spin through +0...9 before settling. Digit columns start together, alternate direction from +left to right, and stop sequentially by the settle duration. `spinningDuration` is the +time the first digit spins before settling. + +```swift +AnimateNumberText(value: $value, + textColor: $textColor, + stringFormatter: "%@ ms", + animation: .reel(spinningDuration: 1.5, + settleDuration: 0.25, + revolutions: 1)) ``` For values updated at high frequency, such as drag gestures or timers, throttle diff --git a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md index df0adbf..1fb1fdb 100644 --- a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md +++ b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md @@ -126,11 +126,10 @@ struct LatencyView: View { ## Animation Timing -The default animation preserves the original rolling spring behavior. Pass an -``AnimateNumberTextAnimation`` value when the rolling speed or timing curve -needs to be customized. Use `.easeIn(duration:)` to speed up over time, -`.easeOut(duration:)` to slow down over time, or `.linear(duration:)` for -constant speed. +The default animation uses `.smooth(duration:)`, a critically damped spring that +accelerates then decelerates while preserving in-flight velocity. Pass an +``AnimateNumberTextAnimation`` value when the smooth rolling duration needs to +be customized or when a rolling digit animation is preferred. ```swift import SwiftUI @@ -144,12 +143,28 @@ struct ScoreView: View { AnimateNumberText( value: $value, textColor: $textColor, - animation: .easeOut(duration: 0.8) + animation: .smooth(duration: 0.5) ) } } ``` +Use `.reel(spinningDuration:settleDuration:revolutions:)` to make each digit spin through 0...9 +before settling. Digit columns start together, alternate direction from left to +right, and stop sequentially by the settle duration. `spinningDuration` is the +time the first digit spins before settling. + +```swift +AnimateNumberText( + value: $value, + textColor: $textColor, + stringFormatter: "%@ ms", + animation: .reel(spinningDuration: 1.5, + settleDuration: 0.25, + revolutions: 1) +) +``` + For values updated at high frequency, such as drag gestures or timers, throttle or debounce the bound value at the call site when every intermediate value does not need to be rendered. diff --git a/Sources/AnimateNumberText/Private/TextType.swift b/Sources/AnimateNumberText/Private/TextType.swift index 8698616..f9de879 100644 --- a/Sources/AnimateNumberText/Private/TextType.swift +++ b/Sources/AnimateNumberText/Private/TextType.swift @@ -12,7 +12,7 @@ enum TextType: Equatable { case number(Int) init(_ value: Character) { - if let number = value.wholeNumberValue { + if let number = value.asciiDigitValue { self = .number(number) } else { self = .string(String(value)) @@ -27,6 +27,15 @@ enum TextType: Equatable { return false } } + + var digitValue: Int? { + switch self { + case .number(let number): + return number + case .string: + return nil + } + } } struct TextColumn: Identifiable, Equatable { @@ -39,7 +48,7 @@ struct TextColumn: Identifiable, Equatable { } init(placeholderFor value: Character) { - if value.wholeNumberValue != nil { + if value.asciiDigitValue != nil { self.init(value: .number(0)) } else { self.init(value: TextType(value)) @@ -49,16 +58,61 @@ struct TextColumn: Identifiable, Equatable { extension Array where Element == TextColumn { mutating func resizeForAnimation(to string: String) { - let extra = string.count - count - guard extra != 0 else { return } + let targetCharacters = Swift.Array(string) + // Equal-length updates keep their columns; settingAnimationRange handles value changes. + guard targetCharacters.count != count else { return } - if extra > 0 { - append(contentsOf: string.suffix(extra).map { - TextColumn(placeholderFor: $0) - }) - } else { - removeLast(-extra) + let currentColumns = self + var preservedDigitColumns = currentColumns.filter(\.value.isNumber) + var preservedTextColumns = currentColumns.filter { !$0.value.isNumber } + var targetColumns = targetCharacters.map { TextColumn(placeholderFor: $0) } + + for targetIndex in targetCharacters.indices.reversed() { + guard targetColumns[targetIndex].value.isNumber else { continue } + guard let preservedColumn = preservedDigitColumns.popLast() else { break } + + targetColumns[targetIndex] = preservedColumn + } + + for targetIndex in targetCharacters.indices.reversed() { + let targetValue = TextType(targetCharacters[targetIndex]) + guard !targetValue.isNumber else { continue } + + // Reverse iteration + lastIndex keeps trailing duplicate separators aligned first. + if let currentIndex = preservedTextColumns.lastIndex(where: { $0.value == targetValue }) { + targetColumns[targetIndex] = preservedTextColumns.remove(at: currentIndex) + } else { + targetColumns[targetIndex] = TextColumn(value: targetValue) + } + } + + self = targetColumns + } + + var digitCount: Int { + filter(\.value.isNumber).count + } + + func preservingReelPositions(_ positions: [UUID: Double]) -> [UUID: Double] { + var nextPositions: [UUID: Double] = [:] + + for column in self { + guard let digit = column.value.digitValue else { continue } + nextPositions[column.id] = positions[column.id] ?? Double(digit) } + + return nextPositions + } + + func currentDigitPositions() -> [UUID: Double] { + var nextPositions: [UUID: Double] = [:] + + for column in self { + guard let digit = column.value.digitValue else { continue } + nextPositions[column.id] = Double(digit) + } + + return nextPositions } func canAnimateDigitChange(to value: Character, index: Int) -> Bool { @@ -67,6 +121,18 @@ extension Array where Element == TextColumn { return self[index].value.isNumber && TextType(value).isNumber } + func needsUpdate(to value: Character, index: Int) -> Bool { + guard indices.contains(index) else { return false } + + return self[index].value != TextType(value) + } + + func digitOrdinal(at index: Int) -> Int? { + guard indices.contains(index), self[index].value.isNumber else { return nil } + + return self[..= 48, + scalar.value <= 57 else { + return nil + } + + return Int(scalar.value - 48) + } +} diff --git a/Sources/AnimateNumberText/Public/AnimateNumberText.swift b/Sources/AnimateNumberText/Public/AnimateNumberText.swift index fe789ab..e0e0222 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberText.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberText.swift @@ -30,7 +30,12 @@ public struct AnimateNumberText: View { @State private var displayedString: String? - + + /// Continuous reel positions keyed by digit column id. Used by `.reel` + /// animations so each digit can spin through multiple 0–9 turns. + @State + private var reelPositions: [UUID: Double] = [:] + /// Creates an animated number text view. /// /// - Parameters: @@ -49,7 +54,7 @@ public struct AnimateNumberText: View { textColor: Binding, numberFormatter: NumberFormatter? = nil, stringFormatter: String? = nil, - animation: AnimateNumberTextAnimation = .default + animation: AnimateNumberTextAnimation = .smooth() ) { self.font = font self.weight = weight @@ -81,7 +86,7 @@ public struct AnimateNumberText: View { textColor: Color = .primary, numberFormatter: NumberFormatter? = nil, stringFormatter: String? = nil, - animation: AnimateNumberTextAnimation = .default + animation: AnimateNumberTextAnimation = .smooth() ) { self.init(font: font, weight: weight, @@ -94,17 +99,20 @@ public struct AnimateNumberText: View { public var body: some View { let stringValue = formatter.string(from: value) + let columns = renderedColumns HStack(spacing: 0) { - ForEach(animationRange) { column in - switch column.value { + ForEach(columns) { renderedColumn in + switch renderedColumn.column.value { case .string(let string): Text(string) .font(font) .fontWeight(weight) .foregroundColor(textColor) case .number: - digitColumn(for: column.value) + digitColumn(for: renderedColumn.column, + digitOrdinal: renderedColumn.digitOrdinal, + digitCount: renderedColumn.digitCount) } } } @@ -112,25 +120,18 @@ public struct AnimateNumberText: View { initializeAnimationRangeIfNeeded(for: stringValue) } .task(id: stringValue) { - await scheduleAnimationUpdateIfNeeded(for: stringValue) + scheduleAnimationUpdateIfNeeded(for: stringValue) } } @MainActor - private func scheduleAnimationUpdateIfNeeded(for stringValue: String) async { + private func scheduleAnimationUpdateIfNeeded(for stringValue: String) { if initializeAnimationRangeIfNeeded(for: stringValue) { return } guard displayedString != stringValue || animationRange.count != stringValue.count else { return } - resizeAnimationRange(to: stringValue, - animation: animation.resizeAnimation) - - do { - try await Task.sleep(nanoseconds: animation.resizeDelayNanoseconds) - } catch { - return - } + resizeAnimationRange(to: stringValue) guard animationRange.count == stringValue.count else { return } @@ -144,26 +145,55 @@ public struct AnimateNumberText: View { guard displayedString == nil else { return false } animationRange = stringValue.map { TextColumn(value: TextType($0)) } + synchronizeReelPositions() displayedString = stringValue return true } @MainActor - private func resizeAnimationRange(to stringValue: String, animation: Animation?) { - let update = { + private func resizeAnimationRange(to stringValue: String) { + withoutAnimation { animationRange.resizeForAnimation(to: stringValue) + synchronizeReelPositions() } + } + + private var renderedColumns: [RenderedTextColumn] { + var digitOrdinal = 0 + let digitCount = animationRange.digitCount - if let animation { - withAnimation(animation) { - update() + return animationRange.map { column in + let ordinal = column.value.isNumber ? digitOrdinal : nil + + if column.value.isNumber { + digitOrdinal += 1 } + + return RenderedTextColumn(column: column, + digitOrdinal: ordinal, + digitCount: digitCount) + } + } + + @ViewBuilder + private func digitColumn(for column: TextColumn, digitOrdinal: Int?, digitCount: Int) -> some View { + if animation.isReel { + let number = column.value.digitValue ?? 0 + let position = reelPositions[column.id] ?? Double(number) + let ordinal = digitOrdinal ?? 0 + + ReelDigitColumn(position: position, + font: font, + weight: weight, + textColor: textColor) + .animation(animation.digitAnimation(at: ordinal, + digitCount: digitCount), value: position) } else { - withoutAnimation(update) + smoothDigitColumn(for: column.value) } } - private func digitColumn(for textType: TextType) -> some View { + private func smoothDigitColumn(for textType: TextType) -> some View { // Measure every digit so proportional fonts use the widest glyph without horizontal bleed. ZStack { ForEach(0...9, id: \.self) { number in @@ -198,21 +228,89 @@ public struct AnimateNumberText: View { @MainActor private func settingAnimationRange(_ string: String, isAnimate: Bool) { - for (index, value) in string.enumerated() { - // IF First Value = 1 - // Then Offset will be Applied for -1 - // So the text will move up to show 1 Value - - if isAnimate && animationRange.canAnimateDigitChange(to: value, index: index) { - withAnimation(animation.digitAnimation(at: index)) { - animationRange.set(value, index: index) - } - } else { - withoutAnimation { + let characters = Swift.Array(string) + let immediateUpdates = characters.enumerated().filter { index, value in + animationRange.needsUpdate(to: value, index: index) + && (!isAnimate || !animationRange.canAnimateDigitChange(to: value, index: index)) + } + + if !immediateUpdates.isEmpty { + withoutAnimation { + for (index, value) in immediateUpdates { animationRange.set(value, index: index) } } } + + guard isAnimate else { return } + + if animation.isReel { + settingReelAnimationRange(characters) + } else { + settingSmoothAnimationRange(characters) + } + } + + @MainActor + private func settingSmoothAnimationRange(_ characters: [Character]) { + let digitCount = animationRange.digitCount + + for (index, value) in characters.enumerated() + where animationRange.needsUpdate(to: value, index: index) + && animationRange.canAnimateDigitChange(to: value, index: index) { + let digitOrdinal = animationRange.digitOrdinal(at: index) ?? index + + withAnimation(animation.digitAnimation(at: digitOrdinal, + digitCount: digitCount)) { + animationRange.set(value, index: index) + } + } + + synchronizeReelPositionsToCurrentDigits() + } + + @MainActor + private func settingReelAnimationRange(_ characters: [Character]) { + synchronizeReelPositions() + var nextPositions = reelPositions + + for (index, value) in characters.enumerated() + where animationRange.canAnimateDigitChange(to: value, index: index) { + guard let digit = TextType(value).digitValue, + let digitOrdinal = animationRange.digitOrdinal(at: index) else { + continue + } + + let columnID = animationRange[index].id + let currentPosition = reelPositions[columnID] + ?? Double(animationRange[index].value.digitValue ?? digit) + let targetPosition = animation.reelTargetPosition(from: currentPosition, + to: digit, + ordinal: digitOrdinal) + + nextPositions[columnID] = targetPosition + animationRange.set(value, index: index) + } + + reelPositions = nextPositions + } + + @MainActor + private func synchronizeReelPositions() { + guard animation.isReel else { return } + + let nextPositions = animationRange.preservingReelPositions(reelPositions) + guard reelPositions != nextPositions else { return } + + reelPositions = nextPositions + } + + @MainActor + private func synchronizeReelPositionsToCurrentDigits() { + let nextPositions = animationRange.currentDigitPositions() + guard reelPositions != nextPositions else { return } + + reelPositions = nextPositions } private func withoutAnimation(_ updates: () -> Void) { @@ -234,3 +332,69 @@ public struct AnimateNumberText: View { } } } + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct RenderedTextColumn: Identifiable { + let column: TextColumn + let digitOrdinal: Int? + let digitCount: Int + + var id: UUID { + column.id + } +} + +@MainActor +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct ReelDigitColumn: View, @preconcurrency Animatable { + var position: Double + + let font: Font + let weight: Font.Weight + let textColor: Color + + var animatableData: Double { + get { position } + set { position = newValue } + } + + var body: some View { + ZStack { + ForEach(0...9, id: \.self) { number in + digitText(number) + .hidden() + } + } + .overlay { + GeometryReader { proxy in + let size = proxy.size + let basePosition = floor(position) + let baseDigit = Int(basePosition) + let fraction = position - basePosition + + VStack(spacing: 0) { + ForEach(-1...1, id: \.self) { offset in + digitText(wrappedDigit(baseDigit + offset)) + .frame(width: size.width, + height: size.height, + alignment: .center) + } + } + .offset(y: -(CGFloat(fraction) + 1) * size.height) + } + .clipped() + } + } + + private func digitText(_ number: Int) -> some View { + Text("\(number)") + .font(font) + .fontWeight(weight) + .foregroundColor(textColor) + } + + private func wrappedDigit(_ value: Int) -> Int { + let remainder = value % 10 + return remainder >= 0 ? remainder : remainder + 10 + } +} diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index b0c6e47..9dd11ff 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -11,129 +11,112 @@ import SwiftUI /// Animation configuration for ``AnimateNumberText`` digit updates. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct AnimateNumberTextAnimation: Equatable, Sendable { - /// The timing curve used when numeric digits roll to a new value. - public enum DigitTiming: Equatable, Sendable { - /// The original per-index spring timing used by AnimateNumberText. - case defaultSpring - /// A constant-speed animation. - case linear(duration: TimeInterval) - /// An animation that starts slowly and speeds up. - case easeIn(duration: TimeInterval) - /// An animation that starts quickly and slows down. - case easeOut(duration: TimeInterval) - /// An animation that eases both at the start and end. - case easeInOut(duration: TimeInterval) - /// A custom interactive spring animation. - case interactiveSpring(response: TimeInterval, - dampingFraction: Double, - blendDuration: TimeInterval) + enum Style: Equatable, Sendable { + case smooth(duration: TimeInterval) + case reel(spinningDuration: TimeInterval, settleDuration: TimeInterval, revolutions: Int) } - /// The default animation, preserving the original AnimateNumberText behavior. - public static let `default` = AnimateNumberTextAnimation() + let style: Style - /// Creates a constant-speed digit animation. - public static func linear(duration: TimeInterval) -> AnimateNumberTextAnimation { - AnimateNumberTextAnimation(digitTiming: .linear(duration: duration)) - } - - /// Creates a digit animation that starts slowly and speeds up. - public static func easeIn(duration: TimeInterval) -> AnimateNumberTextAnimation { - AnimateNumberTextAnimation(digitTiming: .easeIn(duration: duration)) - } - - /// Creates a digit animation that starts quickly and slows down. - public static func easeOut(duration: TimeInterval) -> AnimateNumberTextAnimation { - AnimateNumberTextAnimation(digitTiming: .easeOut(duration: duration)) - } - - /// Creates a digit animation that eases both at the start and end. - public static func easeInOut(duration: TimeInterval) -> AnimateNumberTextAnimation { - AnimateNumberTextAnimation(digitTiming: .easeInOut(duration: duration)) - } - - /// Creates a custom interactive spring digit animation. - public static func interactiveSpring( - response: TimeInterval = 0.45, - dampingFraction: Double = 1, - blendDuration: TimeInterval = 1 - ) -> AnimateNumberTextAnimation { - AnimateNumberTextAnimation(digitTiming: .interactiveSpring(response: response, - dampingFraction: dampingFraction, - blendDuration: blendDuration)) + /// Creates a smooth digit animation that accelerates then decelerates. + /// + /// The animation uses a critically damped spring so interrupted rolls keep + /// their in-flight velocity instead of restarting from a standstill. + public static func smooth(duration: TimeInterval = 0.5) -> AnimateNumberTextAnimation { + AnimateNumberTextAnimation(style: .smooth(duration: duration)) } - /// The timing curve used when numeric digits roll to a new value. - public let digitTiming: DigitTiming - - /// The animation duration used when the formatted string grows or shrinks. + /// Creates a slot-machine reel animation. /// - /// Defaults to `0` so digit rolling is not visually mixed with horizontal - /// layout resizing. - public let resizeDuration: TimeInterval - - /// The delay between resizing the character range and rolling digits. - public let resizeDelay: TimeInterval - - /// Creates an animation configuration. + /// Every digit spins through 0–9, neighbouring places spin in opposite + /// directions, and the places come to rest one after another from left to + /// right, each decelerating to a stop. /// /// - Parameters: - /// - digitTiming: The timing curve used when numeric digits roll to a new value. - /// - resizeDuration: The animation duration used when the formatted string grows or shrinks. - /// - resizeDelay: The delay between resizing the character range and rolling digits. - public init( - digitTiming: DigitTiming = .defaultSpring, - resizeDuration: TimeInterval = 0, - resizeDelay: TimeInterval = 0.05 - ) { - self.digitTiming = digitTiming - self.resizeDuration = resizeDuration - self.resizeDelay = resizeDelay + /// - spinningDuration: The time the first (leftmost) digit spins before + /// landing on its value. + /// - settleDuration: The time between each digit settling. With + /// `spinningDuration: 1.5` and `settleDuration: 0.25`, a value like + /// `301.9` settles as `3`, then `0`, then `1`, then `9` at quarter-second + /// intervals. + /// - revolutions: The number of full 0–9 turns each digit makes before + /// landing on its value. + public static func reel(spinningDuration: TimeInterval = 0.9, + settleDuration: TimeInterval = 0.25, + revolutions: Int = 1) -> AnimateNumberTextAnimation { + AnimateNumberTextAnimation(style: .reel(spinningDuration: spinningDuration, + settleDuration: settleDuration, + revolutions: Swift.max(0, revolutions))) + } + + private init(style: Style) { + self.style = style } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension AnimateNumberTextAnimation { - var resizeAnimation: Animation? { - let duration = sanitized(resizeDuration) - guard duration > 0 else { return nil } + /// Whether digits should render as spinning reels. + var isReel: Bool { + if case .reel = style { return true } + return false + } - return .easeIn(duration: duration) + /// The number of full 0–9 turns each reel makes before landing. + var reelRevolutions: Int { + if case .reel(_, _, let revolutions) = style { return revolutions } + return 0 } - var resizeDelayNanoseconds: UInt64 { - let seconds = sanitized(resizeDelay) - guard seconds < TimeConversion.maximumDelaySeconds else { - return UInt64.max + /// Returns the continuous reel position that lands on `digit`. + /// + /// Even ordinals move upward through increasing digits, while odd ordinals + /// move downward through decreasing digits. The value includes the configured + /// full revolutions so unchanged digits still spin before settling. + func reelTargetPosition(from currentPosition: Double, to digit: Int, ordinal: Int) -> Double { + guard isReel else { return Double(digit) } + guard currentPosition.isFinite else { return Double(digit) } + + let currentDigit = positiveRemainder(currentPosition, dividedBy: 10) + let targetDigit = Double(Swift.max(0, Swift.min(9, digit))) + let distance = reelDistance(from: currentDigit, to: targetDigit, ordinal: ordinal) + let revolutions = Double(reelRevolutions) * 10 + + if ordinal.isMultiple(of: 2) { + return currentPosition + distance + revolutions + } else { + return currentPosition - distance - revolutions } - - return UInt64((seconds * TimeConversion.nanosecondsPerSecond).rounded(.down)) } - func digitAnimation(at index: Int) -> Animation { - switch digitTiming { - case .defaultSpring: - let fraction = Swift.min(Double(index) * 0.15, 0.5) - return .interactiveSpring(response: 0.45, - dampingFraction: 1 + fraction, - blendDuration: 1 + fraction) - - case .linear(let duration): - return .linear(duration: sanitized(duration)) - - case .easeIn(let duration): - return .easeIn(duration: sanitized(duration)) - - case .easeOut(let duration): - return .easeOut(duration: sanitized(duration)) - - case .easeInOut(let duration): - return .easeInOut(duration: sanitized(duration)) + /// The animation applied when the digit at `ordinal` rolls to its value. + /// + /// `ordinal` is the zero-based position among digit columns. In reel mode it + /// adds the settle interval so places come to rest one after another. + func digitAnimation(at ordinal: Int, digitCount: Int) -> Animation { + switch style { + case .smooth(let duration): + return .interactiveSpring(response: sanitized(duration), + dampingFraction: 1, + blendDuration: 0) + + case .reel(let spinningDuration, let settleDuration, _): + return .easeOut(duration: digitDuration(spinningDuration, + settleDuration: settleDuration, + ordinal: ordinal, + digitCount: digitCount)) + } + } - case .interactiveSpring(let response, let dampingFraction, let blendDuration): - return .interactiveSpring(response: sanitized(response), - dampingFraction: dampingFraction, - blendDuration: sanitized(blendDuration)) + func digitDuration(at ordinal: Int, digitCount: Int) -> TimeInterval { + switch style { + case .smooth(let duration): + return sanitized(duration) + case .reel(let spinningDuration, let settleDuration, _): + return digitDuration(spinningDuration, + settleDuration: settleDuration, + ordinal: ordinal, + digitCount: digitCount) } } @@ -142,8 +125,28 @@ extension AnimateNumberTextAnimation { return Swift.max(0, value) } - private enum TimeConversion { - static let nanosecondsPerSecond: TimeInterval = 1_000_000_000 - static let maximumDelaySeconds = TimeInterval(UInt64.max / 1_000_000_000) + private func digitDuration(_ spinningDuration: TimeInterval, + settleDuration: TimeInterval, + ordinal: Int, + digitCount: Int) -> TimeInterval { + let firstDigitDuration = sanitized(spinningDuration) + let interval = sanitized(settleDuration) + let lastOrdinal = Swift.max(0, digitCount - 1) + let clampedOrdinal = Swift.max(0, Swift.min(ordinal, lastOrdinal)) + + return firstDigitDuration + Double(clampedOrdinal) * interval + } + + private func reelDistance(from currentDigit: Double, to targetDigit: Double, ordinal: Int) -> Double { + if ordinal.isMultiple(of: 2) { + return positiveRemainder(targetDigit - currentDigit, dividedBy: 10) + } else { + return positiveRemainder(currentDigit - targetDigit, dividedBy: 10) + } + } + + private func positiveRemainder(_ value: Double, dividedBy divisor: Double) -> Double { + let remainder = value.truncatingRemainder(dividingBy: divisor) + return remainder >= 0 ? remainder : remainder + divisor } } diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 2d01369..068b619 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -166,23 +166,31 @@ struct AnimateNumberTextTests { textColor: .green, numberFormatter: numberFormatter, stringFormatter: "%@ ms", - animation: .easeOut(duration: 0.8)) + animation: .smooth(duration: 0.5)) #expect(String(describing: type(of: view)) == "AnimateNumberText") } @Test - func defaultAnimationConfiguration() { - let animation = AnimateNumberTextAnimation.default + @MainActor + func readOnlyValueInitializerAcceptsReelAnimation() { + let view = AnimateNumberText(value: 301.9, + animation: .reel(spinningDuration: 0.9, + settleDuration: 0.25, + revolutions: 1)) - #expect(animation.digitTiming == .defaultSpring) - #expect(animation.resizeDuration == 0) - #expect(animation.resizeDelay == 0.05) + #expect(String(describing: type(of: view)) == "AnimateNumberText") } @Test func resizeForAnimationUsesStablePlaceholders() { - var columns = [TextColumn(value: .number(0))] + var columns = [ + TextColumn(value: .number(0)), + TextColumn(value: .string(" ")), + TextColumn(value: .string("m")), + TextColumn(value: .string("s")) + ] + let suffixIDs = columns.suffix(3).map(\.id) columns.resizeForAnimation(to: "306.26 ms") @@ -197,6 +205,63 @@ struct AnimateNumberTextTests { .string("m"), .string("s") ]) + #expect(columns.suffix(3).map(\.id) == suffixIDs) + } + + @Test + func resizeForAnimationKeepsFormattedSuffixAligned() { + var columns = [ + TextColumn(value: .number(9)), + TextColumn(value: .string(" ")), + TextColumn(value: .string("m")), + TextColumn(value: .string("s")) + ] + let suffixIDs = columns.suffix(3).map(\.id) + + columns.resizeForAnimation(to: "10 ms") + + #expect(columns.map(\.value) == [ + .number(0), + .number(9), + .string(" "), + .string("m"), + .string("s") + ]) + #expect(columns.suffix(3).map(\.id) == suffixIDs) + } + + @Test + func resizeForAnimationKeepsFormattedSuffixAlignedWhenShrinking() { + var columns = [ + TextColumn(value: .number(3)), + TextColumn(value: .number(0)), + TextColumn(value: .number(6)), + TextColumn(value: .string(".")), + TextColumn(value: .number(2)), + TextColumn(value: .number(6)), + TextColumn(value: .string(" ")), + TextColumn(value: .string("m")), + TextColumn(value: .string("s")) + ] + let suffixIDs = columns.suffix(3).map(\.id) + + columns.resizeForAnimation(to: "0 ms") + + #expect(columns.map(\.value) == [ + .number(6), + .string(" "), + .string("m"), + .string("s") + ]) + #expect(columns.suffix(3).map(\.id) == suffixIDs) + } + + @Test + func textTypeOnlyTreatsASCIIDigitsAsAnimatedNumbers() { + #expect(TextType("3") == .number(3)) + #expect(TextType("٣") == .string("٣")) + #expect(TextType("Ⅻ") == .string("Ⅻ")) + #expect(TextColumn(placeholderFor: "٣").value == .string("٣")) } @Test @@ -212,46 +277,117 @@ struct AnimateNumberTextTests { } @Test - func durationBasedAnimationConfigurations() { - #expect(AnimateNumberTextAnimation.linear(duration: 0.2).digitTiming == .linear(duration: 0.2)) - #expect(AnimateNumberTextAnimation.easeIn(duration: 0.3).digitTiming == .easeIn(duration: 0.3)) - #expect(AnimateNumberTextAnimation.easeOut(duration: 0.4).digitTiming == .easeOut(duration: 0.4)) - #expect(AnimateNumberTextAnimation.easeInOut(duration: 0.5).digitTiming == .easeInOut(duration: 0.5)) - - let spring = AnimateNumberTextAnimation.interactiveSpring(response: 0.6, - dampingFraction: 0.8, - blendDuration: 0.2) - #expect(spring.digitTiming == .interactiveSpring(response: 0.6, - dampingFraction: 0.8, - blendDuration: 0.2)) + func digitOrdinalSkipsFormattedCharacters() { + let columns = [ + TextColumn(value: .number(3)), + TextColumn(value: .number(0)), + TextColumn(value: .number(1)), + TextColumn(value: .string(".")), + TextColumn(value: .number(9)), + TextColumn(value: .string(" ")), + TextColumn(value: .string("m")), + TextColumn(value: .string("s")) + ] + + #expect(columns.digitOrdinal(at: 0) == 0) + #expect(columns.digitOrdinal(at: 1) == 1) + #expect(columns.digitOrdinal(at: 2) == 2) + #expect(columns.digitOrdinal(at: 3) == nil) + #expect(columns.digitOrdinal(at: 4) == 3) + } + + @Test + func unchangedFormattedSuffixDoesNotNeedUpdate() { + let columns = [ + TextColumn(value: .number(0)), + TextColumn(value: .string(" ")), + TextColumn(value: .string("m")), + TextColumn(value: .string("s")) + ] + + #expect(columns.needsUpdate(to: "1", index: 0)) + #expect(!columns.needsUpdate(to: " ", index: 1)) + #expect(!columns.needsUpdate(to: "m", index: 2)) + #expect(!columns.needsUpdate(to: "s", index: 3)) + } + + @Test + func preservingReelPositionsKeepsExistingContinuousOffsets() { + let columns = [ + TextColumn(value: .number(3)), + TextColumn(value: .string(".")), + TextColumn(value: .number(9)) + ] + let positions = [ + columns[0].id: 13.0, + columns[2].id: -11.0 + ] + + #expect(columns.preservingReelPositions(positions) == positions) } @Test - func customAnimationConfiguration() { - let animation = AnimateNumberTextAnimation( - digitTiming: .interactiveSpring(response: 0.6, - dampingFraction: 0.8, - blendDuration: 0.2), - resizeDuration: 0.1, - resizeDelay: 0.1 - ) - - #expect(animation.digitTiming == .interactiveSpring(response: 0.6, - dampingFraction: 0.8, - blendDuration: 0.2)) - #expect(animation.resizeDuration == 0.1) - #expect(animation.resizeDelay == 0.1) + func currentDigitPositionsDropsStaleContinuousOffsets() { + let columns = [ + TextColumn(value: .number(4)), + TextColumn(value: .string(".")), + TextColumn(value: .number(2)) + ] + let positions = columns.currentDigitPositions() + + #expect(positions[columns[0].id] == 4) + #expect(positions[columns[2].id] == 2) + #expect(positions.count == 2) + } + + @Test + func smoothAnimationConfiguration() { + #expect(AnimateNumberTextAnimation.smooth() == .smooth(duration: 0.5)) + #expect(AnimateNumberTextAnimation.smooth(duration: 0.3) != .smooth(duration: 0.5)) + } + + @Test + func reelAnimationConfiguration() { + #expect(AnimateNumberTextAnimation.reel() == .reel(spinningDuration: 0.9, + settleDuration: 0.25, + revolutions: 1)) + #expect(AnimateNumberTextAnimation.reel(spinningDuration: 1.5) == .reel(spinningDuration: 1.5, + settleDuration: 0.25, + revolutions: 1)) + #expect(AnimateNumberTextAnimation.reel(revolutions: -1) == .reel(revolutions: 0)) + #expect(AnimateNumberTextAnimation.reel(spinningDuration: 0.9) != .smooth(duration: 0.9)) + } + + @Test + func reelDigitDurationsTreatSpinningDurationAsFirstSettleTime() { + let animation = AnimateNumberTextAnimation.reel(spinningDuration: 1.5, + settleDuration: 0.25, + revolutions: 1) + + #expect(abs(animation.digitDuration(at: 0, digitCount: 4) - 1.5) < 0.000_001) + #expect(abs(animation.digitDuration(at: 1, digitCount: 4) - 1.75) < 0.000_001) + #expect(abs(animation.digitDuration(at: 2, digitCount: 4) - 2.0) < 0.000_001) + #expect(abs(animation.digitDuration(at: 3, digitCount: 4) - 2.25) < 0.000_001) + } + + @Test + func reelDigitDurationsClampOrdinalToAvailableDigits() { + let animation = AnimateNumberTextAnimation.reel(spinningDuration: 1.5, + settleDuration: 0.25, + revolutions: 1) + + #expect(abs(animation.digitDuration(at: 10, digitCount: 4) - 2.25) < 0.000_001) } @Test - func resizeDelayNanosecondsClampsExtremeValues() { - let maximumWholeSeconds = TimeInterval(UInt64.max / 1_000_000_000) - let nearMaximum = AnimateNumberTextAnimation(resizeDelay: maximumWholeSeconds - 1) - let maximum = AnimateNumberTextAnimation(resizeDelay: maximumWholeSeconds) - - #expect(nearMaximum.resizeDelayNanoseconds < UInt64.max) - #expect(maximum.resizeDelayNanoseconds == UInt64.max) - #expect(AnimateNumberTextAnimation(resizeDelay: .infinity).resizeDelayNanoseconds == 0) - #expect(AnimateNumberTextAnimation(resizeDelay: -1).resizeDelayNanoseconds == 0) + func reelTargetPositionAlternatesDirectionAndSpinsUnchangedDigits() { + let animation = AnimateNumberTextAnimation.reel(spinningDuration: 0.9, + settleDuration: 0.25, + revolutions: 1) + + #expect(animation.reelTargetPosition(from: 0, to: 3, ordinal: 0) == 13) + #expect(animation.reelTargetPosition(from: 0, to: 3, ordinal: 1) == -17) + #expect(animation.reelTargetPosition(from: 5, to: 5, ordinal: 0) == 15) + #expect(animation.reelTargetPosition(from: 5, to: 5, ordinal: 1) == -5) } }