From 5ffc7538ba36cdd1bac3cc035f5ffb06cd7a053d Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 08:54:46 +0900 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20stringFormatter=20suffix=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stringFormatter로 ms 같은 suffix를 붙인 값에서 길이 변경 시 suffix가 깜빡이고 easing 효과가 약하게 보이는 문제를 보정했습니다. - 문자열 길이가 바뀔 때 기존 digit 컬럼을 오른쪽 기준으로 보존하고 새 digit은 0 placeholder로 채웁니다. - 숫자가 아닌 suffix, 공백, 구분자는 목표 문자열 위치에 즉시 고정해 ms가 흔들리지 않도록 했습니다. - 변경이 없는 suffix는 state set 대상에서 제외해 불필요한 non-animation transaction을 만들지 않습니다. - non-digit 업데이트를 먼저 처리하고 실제 변경된 digit만 마지막에 animation transaction으로 적용해 easeOut 같은 느려지는 옵션이 숫자 컬럼에 반영되도록 했습니다. 검증: - swift test - DOCS_OUTPUT_PATH=.build/docc-site DOCC_ARCHIVE_PATH=.build/AnimateNumberText.doccarchive ./GeneratingDocumentationSite - git diff --check --- .../AnimateNumberText/Private/TextType.swift | 37 ++++++++++++---- .../Public/AnimateNumberText.swift | 31 +++++++++----- .../AnimateNumberTextTests.swift | 42 ++++++++++++++++++- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/Sources/AnimateNumberText/Private/TextType.swift b/Sources/AnimateNumberText/Private/TextType.swift index 8698616..bb62e1e 100644 --- a/Sources/AnimateNumberText/Private/TextType.swift +++ b/Sources/AnimateNumberText/Private/TextType.swift @@ -49,16 +49,31 @@ 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 = string.map { $0 } + 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 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 (index, character) in targetCharacters.enumerated() where !TextType(character).isNumber { + let targetValue = TextType(character) + if currentColumns.indices.contains(index), + currentColumns[index].value == targetValue { + targetColumns[index] = currentColumns[index] + } else { + targetColumns[index] = TextColumn(value: targetValue) + } } + + self = targetColumns } func canAnimateDigitChange(to value: Character, index: Int) -> Bool { @@ -67,6 +82,12 @@ 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) + } + mutating func set(_ value: Character, index: Int) { set(TextType(value), index: index) } diff --git a/Sources/AnimateNumberText/Public/AnimateNumberText.swift b/Sources/AnimateNumberText/Public/AnimateNumberText.swift index fe789ab..f6b5000 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberText.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberText.swift @@ -198,19 +198,30 @@ public struct AnimateNumberText: View { @MainActor private func settingAnimationRange(_ string: String, isAnimate: Bool) { - for (index, value) in string.enumerated() { + let characters = 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 } + + for (index, value) in characters.enumerated() + where animationRange.needsUpdate(to: value, index: index) + && animationRange.canAnimateDigitChange(to: value, index: index) { // 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 { - animationRange.set(value, index: index) - } + withAnimation(animation.digitAnimation(at: index)) { + animationRange.set(value, index: index) } } } diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 2d01369..7dcc6af 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -182,7 +182,12 @@ struct AnimateNumberTextTests { @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")) + ] columns.resizeForAnimation(to: "306.26 ms") @@ -199,6 +204,26 @@ struct AnimateNumberTextTests { ]) } + @Test + func resizeForAnimationKeepsFormattedSuffixAligned() { + var columns = [ + TextColumn(value: .number(9)), + TextColumn(value: .string(" ")), + TextColumn(value: .string("m")), + TextColumn(value: .string("s")) + ] + + columns.resizeForAnimation(to: "10 ms") + + #expect(columns.map(\.value) == [ + .number(0), + .number(9), + .string(" "), + .string("m"), + .string("s") + ]) + } + @Test func digitAnimationOnlyAppliesToDigitColumns() { let columns = [ @@ -211,6 +236,21 @@ struct AnimateNumberTextTests { #expect(!columns.canAnimateDigitChange(to: "6", index: 1)) } + @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 durationBasedAnimationConfigurations() { #expect(AnimateNumberTextAnimation.linear(duration: 0.2).digitTiming == .linear(duration: 0.2)) From cb62b3f692ed69a052224a94a630f262aca370ed Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 09:00:00 +0900 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20digit=20=ED=8C=90=EB=B3=84?= =?UTF-8?q?=EA=B3=BC=20=EC=B6=95=EC=86=8C=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 지적을 반영해 TextType의 digit 판별 범위를 ASCII 0-9로 좁히고, formatted suffix 축소 경로를 테스트했습니다. - Character.wholeNumberValue가 로마 숫자나 아라비아-인도 숫자 등을 number로 오분류하지 않도록 ASCII digit만 animated number로 처리합니다. - placeholder 생성도 동일한 ASCII digit 기준을 사용합니다. - resizeForAnimation에서 TextType 중복 생성을 제거했습니다. - 같은 길이 업데이트는 settingAnimationRange가 처리한다는 의도를 주석으로 남겼습니다. - 306.26 ms -> 0 ms 축소 케이스와 유니코드 숫자 폴백 테스트를 추가했습니다. 검증: - swift test - DOCS_OUTPUT_PATH=.build/docc-site DOCC_ARCHIVE_PATH=.build/AnimateNumberText.doccarchive ./GeneratingDocumentationSite - git diff --check --- .../AnimateNumberText/Private/TextType.swift | 24 +++++++++++--- .../AnimateNumberTextTests.swift | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Sources/AnimateNumberText/Private/TextType.swift b/Sources/AnimateNumberText/Private/TextType.swift index bb62e1e..1557fde 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)) @@ -39,7 +39,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,7 +49,8 @@ struct TextColumn: Identifiable, Equatable { extension Array where Element == TextColumn { mutating func resizeForAnimation(to string: String) { - let targetCharacters = string.map { $0 } + let targetCharacters = Swift.Array(string) + // Equal-length updates keep their columns; settingAnimationRange handles value changes. guard targetCharacters.count != count else { return } let currentColumns = self @@ -63,8 +64,10 @@ extension Array where Element == TextColumn { targetColumns[targetIndex] = preservedColumn } - for (index, character) in targetCharacters.enumerated() where !TextType(character).isNumber { + for (index, character) in targetCharacters.enumerated() { let targetValue = TextType(character) + guard !targetValue.isNumber else { continue } + if currentColumns.indices.contains(index), currentColumns[index].value == targetValue { targetColumns[index] = currentColumns[index] @@ -98,3 +101,16 @@ extension Array where Element == TextColumn { self[index].value = value } } + +private extension Character { + var asciiDigitValue: Int? { + guard unicodeScalars.count == 1, + let scalar = unicodeScalars.first, + scalar.value >= 48, + scalar.value <= 57 else { + return nil + } + + return Int(scalar.value - 48) + } +} diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 7dcc6af..9a544b9 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -224,6 +224,38 @@ struct AnimateNumberTextTests { ]) } + @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")) + ] + + columns.resizeForAnimation(to: "0 ms") + + #expect(columns.map(\.value) == [ + .number(6), + .string(" "), + .string("m"), + .string("s") + ]) + } + + @Test + func textTypeOnlyTreatsASCIIDigitsAsAnimatedNumbers() { + #expect(TextType("3") == .number(3)) + #expect(TextType("٣") == .string("٣")) + #expect(TextType("Ⅻ") == .string("Ⅻ")) + #expect(TextColumn(placeholderFor: "٣").value == .string("٣")) + } + @Test func digitAnimationOnlyAppliesToDigitColumns() { let columns = [ From 3f9f3b156ade5f4845f9ed176ac193b8376d794c Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 09:02:19 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20resize=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stringFormatter suffix가 placeholder 상태로 보이는 시간을 줄이기 위해 기본 resizeDelay를 0으로 변경했습니다. - 기본 설정에서는 resize 후 즉시 digit rolling을 시작합니다. - 명시적으로 resizeDelay를 넘긴 경우에만 Task.sleep을 수행합니다. - 기본 animation configuration 테스트를 갱신했습니다. 검증: - swift test - DOCS_OUTPUT_PATH=.build/docc-site DOCC_ARCHIVE_PATH=.build/AnimateNumberText.doccarchive ./GeneratingDocumentationSite - git diff --check --- .../AnimateNumberText/Public/AnimateNumberText.swift | 11 +++++++---- .../Public/AnimateNumberTextAnimation.swift | 5 ++++- .../AnimateNumberTextTests.swift | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/AnimateNumberText/Public/AnimateNumberText.swift b/Sources/AnimateNumberText/Public/AnimateNumberText.swift index f6b5000..7296122 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberText.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberText.swift @@ -126,10 +126,13 @@ public struct AnimateNumberText: View { resizeAnimationRange(to: stringValue, animation: animation.resizeAnimation) - do { - try await Task.sleep(nanoseconds: animation.resizeDelayNanoseconds) - } catch { - return + let resizeDelayNanoseconds = animation.resizeDelayNanoseconds + if resizeDelayNanoseconds > 0 { + do { + try await Task.sleep(nanoseconds: resizeDelayNanoseconds) + } catch { + return + } } guard animationRange.count == stringValue.count else { return } diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index b0c6e47..750b6fe 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -73,6 +73,9 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { public let resizeDuration: TimeInterval /// The delay between resizing the character range and rolling digits. + /// + /// Defaults to `0` so formatted suffixes do not remain in a placeholder + /// state before digit rolling begins. public let resizeDelay: TimeInterval /// Creates an animation configuration. @@ -84,7 +87,7 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { public init( digitTiming: DigitTiming = .defaultSpring, resizeDuration: TimeInterval = 0, - resizeDelay: TimeInterval = 0.05 + resizeDelay: TimeInterval = 0 ) { self.digitTiming = digitTiming self.resizeDuration = resizeDuration diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 9a544b9..3b12ac0 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -177,7 +177,7 @@ struct AnimateNumberTextTests { #expect(animation.digitTiming == .defaultSpring) #expect(animation.resizeDuration == 0) - #expect(animation.resizeDelay == 0.05) + #expect(animation.resizeDelay == 0) } @Test From b04a6fb448c4c23d989b4fecc515d60b14d1ea19 Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 09:05:08 +0900 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20formatted=20suffix=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 지적을 반영해 문자열 길이가 바뀌어도 동일한 suffix 컬럼 UUID가 재사용되도록 보강했습니다. - non-digit 컬럼도 오른쪽 기준으로 같은 문자 컬럼을 보존합니다. - 0 ms -> 306.26 ms, 9 ms -> 10 ms, 306.26 ms -> 0 ms 테스트에서 suffix UUID 보존을 검증합니다. 검증: - swift test - DOCS_OUTPUT_PATH=.build/docc-site DOCC_ARCHIVE_PATH=.build/AnimateNumberText.doccarchive ./GeneratingDocumentationSite - git diff --check --- Sources/AnimateNumberText/Private/TextType.swift | 12 ++++++------ .../AnimateNumberTextTests.swift | 6 ++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/AnimateNumberText/Private/TextType.swift b/Sources/AnimateNumberText/Private/TextType.swift index 1557fde..fd15e7b 100644 --- a/Sources/AnimateNumberText/Private/TextType.swift +++ b/Sources/AnimateNumberText/Private/TextType.swift @@ -55,6 +55,7 @@ extension Array where Element == TextColumn { 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() { @@ -64,15 +65,14 @@ extension Array where Element == TextColumn { targetColumns[targetIndex] = preservedColumn } - for (index, character) in targetCharacters.enumerated() { - let targetValue = TextType(character) + for targetIndex in targetCharacters.indices.reversed() { + let targetValue = TextType(targetCharacters[targetIndex]) guard !targetValue.isNumber else { continue } - if currentColumns.indices.contains(index), - currentColumns[index].value == targetValue { - targetColumns[index] = currentColumns[index] + if let currentIndex = preservedTextColumns.lastIndex(where: { $0.value == targetValue }) { + targetColumns[targetIndex] = preservedTextColumns.remove(at: currentIndex) } else { - targetColumns[index] = TextColumn(value: targetValue) + targetColumns[targetIndex] = TextColumn(value: targetValue) } } diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 3b12ac0..98130a9 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -188,6 +188,7 @@ struct AnimateNumberTextTests { TextColumn(value: .string("m")), TextColumn(value: .string("s")) ] + let suffixIDs = columns.suffix(3).map(\.id) columns.resizeForAnimation(to: "306.26 ms") @@ -202,6 +203,7 @@ struct AnimateNumberTextTests { .string("m"), .string("s") ]) + #expect(columns.suffix(3).map(\.id) == suffixIDs) } @Test @@ -212,6 +214,7 @@ struct AnimateNumberTextTests { TextColumn(value: .string("m")), TextColumn(value: .string("s")) ] + let suffixIDs = columns.suffix(3).map(\.id) columns.resizeForAnimation(to: "10 ms") @@ -222,6 +225,7 @@ struct AnimateNumberTextTests { .string("m"), .string("s") ]) + #expect(columns.suffix(3).map(\.id) == suffixIDs) } @Test @@ -237,6 +241,7 @@ struct AnimateNumberTextTests { TextColumn(value: .string("m")), TextColumn(value: .string("s")) ] + let suffixIDs = columns.suffix(3).map(\.id) columns.resizeForAnimation(to: "0 ms") @@ -246,6 +251,7 @@ struct AnimateNumberTextTests { .string("m"), .string("s") ]) + #expect(columns.suffix(3).map(\.id) == suffixIDs) } @Test From ed44ebff2957057de0b3d7febbf0cb86286fa034 Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 09:46:20 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20animation=20API=EB=A5=BC=20smooth?= =?UTF-8?q?=EB=A1=9C=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 외부에 노출되는 AnimateNumberTextAnimation 옵션을 smooth(duration:) 하나로 정리했습니다. - linear, easeIn, easeOut, easeInOut, interactiveSpring factory를 제거했습니다. - public DigitTiming, digitTiming, resizeDuration, resizeDelay 설정을 제거했습니다. - 기본 animation 파라미터를 .smooth()로 변경했습니다. - resize animation/delay 경로를 제거하고 length resize는 비애니메이션으로 즉시 처리합니다. - README와 DocC 예제를 smooth(duration:) 기준으로 정리했습니다. - animation 테스트를 smooth 설정 검증으로 단순화했습니다. 검증: - swift test - DOCS_OUTPUT_PATH=.build/docc-site DOCC_ARCHIVE_PATH=.build/AnimateNumberText.doccarchive ./GeneratingDocumentationSite - clean DocC 생성 후 제거된 symbol 페이지 검색 - git diff --check --- README.md | 9 +- .../AnimateNumberText.md | 11 +- .../Public/AnimateNumberText.swift | 28 +--- .../Public/AnimateNumberTextAnimation.swift | 135 ++---------------- .../AnimateNumberTextTests.swift | 55 +------ 5 files changed, 30 insertions(+), 208 deletions(-) diff --git a/README.md b/README.md index b741970..fbb77f0 100644 --- a/README.md +++ b/README.md @@ -129,15 +129,14 @@ 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. ```swift AnimateNumberText(value: $value, textColor: $textColor, - animation: .easeOut(duration: 0.8)) + animation: .smooth(duration: 0.5)) ``` 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..1f759df 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. ```swift import SwiftUI @@ -144,7 +143,7 @@ struct ScoreView: View { AnimateNumberText( value: $value, textColor: $textColor, - animation: .easeOut(duration: 0.8) + animation: .smooth(duration: 0.5) ) } } diff --git a/Sources/AnimateNumberText/Public/AnimateNumberText.swift b/Sources/AnimateNumberText/Public/AnimateNumberText.swift index 7296122..098af30 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberText.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberText.swift @@ -49,7 +49,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 +81,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, @@ -123,17 +123,7 @@ public struct AnimateNumberText: View { } guard displayedString != stringValue || animationRange.count != stringValue.count else { return } - resizeAnimationRange(to: stringValue, - animation: animation.resizeAnimation) - - let resizeDelayNanoseconds = animation.resizeDelayNanoseconds - if resizeDelayNanoseconds > 0 { - do { - try await Task.sleep(nanoseconds: resizeDelayNanoseconds) - } catch { - return - } - } + resizeAnimationRange(to: stringValue) guard animationRange.count == stringValue.count else { return } @@ -152,18 +142,10 @@ public struct AnimateNumberText: View { } @MainActor - private func resizeAnimationRange(to stringValue: String, animation: Animation?) { - let update = { + private func resizeAnimationRange(to stringValue: String) { + withoutAnimation { animationRange.resizeForAnimation(to: stringValue) } - - if let animation { - withAnimation(animation) { - update() - } - } else { - withoutAnimation(update) - } } private func digitColumn(for textType: TextType) -> some View { diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index 750b6fe..8bfc128 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -11,142 +11,31 @@ 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) - } - - /// The default animation, preserving the original AnimateNumberText behavior. - public static let `default` = AnimateNumberTextAnimation() - - /// 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)) - } - - /// 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. - /// - /// Defaults to `0` so digit rolling is not visually mixed with horizontal - /// layout resizing. - public let resizeDuration: TimeInterval + private let duration: TimeInterval - /// The delay between resizing the character range and rolling digits. + /// Creates a smooth digit animation that accelerates then decelerates. /// - /// Defaults to `0` so formatted suffixes do not remain in a placeholder - /// state before digit rolling begins. - public let resizeDelay: TimeInterval + /// 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(duration: duration) + } - /// Creates an animation configuration. - /// - /// - 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 - ) { - self.digitTiming = digitTiming - self.resizeDuration = resizeDuration - self.resizeDelay = resizeDelay + private init(duration: TimeInterval = 0.5) { + self.duration = duration } } @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 } - - return .easeIn(duration: duration) - } - - var resizeDelayNanoseconds: UInt64 { - let seconds = sanitized(resizeDelay) - guard seconds < TimeConversion.maximumDelaySeconds else { - return UInt64.max - } - - 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)) - - case .interactiveSpring(let response, let dampingFraction, let blendDuration): - return .interactiveSpring(response: sanitized(response), - dampingFraction: dampingFraction, - blendDuration: sanitized(blendDuration)) - } + .interactiveSpring(response: sanitized(duration), + dampingFraction: 1, + blendDuration: 0) } private func sanitized(_ value: TimeInterval) -> TimeInterval { guard value.isFinite else { return 0 } 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) - } } diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 98130a9..48ac0b5 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -166,20 +166,11 @@ 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 - - #expect(animation.digitTiming == .defaultSpring) - #expect(animation.resizeDuration == 0) - #expect(animation.resizeDelay == 0) - } - @Test func resizeForAnimationUsesStablePlaceholders() { var columns = [ @@ -290,46 +281,8 @@ 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)) - } - - @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) - } - - @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 smoothAnimationConfiguration() { + #expect(AnimateNumberTextAnimation.smooth() == .smooth(duration: 0.5)) + #expect(AnimateNumberTextAnimation.smooth(duration: 0.3) != .smooth(duration: 0.5)) } } From 18668834c5ebe736bbfefdbab27f26249b6c95da Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 10:51:21 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20reel=20=EC=88=AB=EC=9E=90=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 숫자 컬럼을 Animatable 기반 ReelDigitColumn으로 렌더링하도록 추가했습니다. reel 모드는 연속 position을 보간해 0...9 트랙을 프레임마다 올바르게 표시하고, 왼쪽부터 digit ordinal 기준으로 방향을 교대합니다. 모든 숫자 컬럼은 함께 회전하며 duration과 stagger를 합산한 easeOut 타이밍으로 왼쪽부터 순차적으로 멈춥니다. 자릿수가 늘어날 때는 기존 resize placeholder와 widest digit 측정을 유지해 suffix 흔들림과 가로 늘어짐을 줄였습니다. README와 DocC 예제에 .reel(duration:revolutions:stagger:) 사용법을 추가하고 관련 Swift Testing 검증을 보강했습니다. --- README.md | 16 +- .../AnimateNumberText.md | 17 ++- .../AnimateNumberText/Private/TextType.swift | 15 ++ .../Public/AnimateNumberText.swift | 140 +++++++++++++++++- .../Public/AnimateNumberTextAnimation.swift | 98 +++++++++++- .../AnimateNumberTextTests.swift | 52 +++++++ 6 files changed, 320 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index fbb77f0..bf64d1e 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,8 @@ struct ContentView: View { 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. +`animation` only when you want to control the smooth rolling duration or use +the reel style. ```swift AnimateNumberText(value: $value, @@ -139,6 +140,19 @@ AnimateNumberText(value: $value, animation: .smooth(duration: 0.5)) ``` +Use `.reel(duration:revolutions:stagger:)` when each digit should spin through +0...9 before settling. Digit columns start together, alternate direction from +left to right, and stop sequentially by adding the stagger time per digit. + +```swift +AnimateNumberText(value: $value, + textColor: $textColor, + stringFormatter: "%@ ms", + animation: .reel(duration: 0.9, + revolutions: 1, + stagger: 0.18)) +``` + 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/AnimateNumberText.docc/AnimateNumberText.md b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md index 1f759df..99aae9a 100644 --- a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md +++ b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md @@ -129,7 +129,7 @@ struct LatencyView: View { 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. +be customized or when a reel animation is preferred. ```swift import SwiftUI @@ -149,6 +149,21 @@ struct ScoreView: View { } ``` +Use `.reel(duration:revolutions:stagger:)` to make each digit spin through +0...9 before settling. Digit columns start together, alternate direction from +left to right, and stop sequentially by adding the stagger time per digit. + +```swift +AnimateNumberText( + value: $value, + textColor: $textColor, + stringFormatter: "%@ ms", + animation: .reel(duration: 0.9, + revolutions: 1, + stagger: 0.18) +) +``` + 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 fd15e7b..7d51609 100644 --- a/Sources/AnimateNumberText/Private/TextType.swift +++ b/Sources/AnimateNumberText/Private/TextType.swift @@ -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 { @@ -91,6 +100,12 @@ extension Array where Element == TextColumn { 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[.. some View { + @ViewBuilder + private func digitColumn(for column: TextColumn) -> some View { + if animation.isReel { + let number = column.value.digitValue ?? 0 + let position = reelPositions[column.id] ?? Double(number) + + ReelDigitColumn(position: position, + font: font, + weight: weight, + textColor: textColor) + } else { + smoothDigitColumn(for: column.value) + } + } + + 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 @@ -183,7 +205,7 @@ public struct AnimateNumberText: View { @MainActor private func settingAnimationRange(_ string: String, isAnimate: Bool) { - let characters = Array(string) + 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)) @@ -199,18 +221,65 @@ public struct AnimateNumberText: View { guard isAnimate else { return } + if animation.isReel { + settingReelAnimationRange(characters) + } else { + settingSmoothAnimationRange(characters) + } + } + + @MainActor + private func settingSmoothAnimationRange(_ characters: [Character]) { for (index, value) in characters.enumerated() where animationRange.needsUpdate(to: value, index: index) && animationRange.canAnimateDigitChange(to: value, index: index) { - // IF First Value = 1 - // Then Offset will be Applied for -1 - // So the text will move up to show 1 Value - withAnimation(animation.digitAnimation(at: index)) { + let digitOrdinal = animationRange.digitOrdinal(at: index) ?? index + + withAnimation(animation.digitAnimation(at: digitOrdinal)) { + animationRange.set(value, index: index) + } + } + } + + @MainActor + private func settingReelAnimationRange(_ characters: [Character]) { + synchronizeReelPositions() + + 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) + + withAnimation(animation.digitAnimation(at: digitOrdinal)) { + reelPositions[columnID] = targetPosition animationRange.set(value, index: index) } } } + @MainActor + private func synchronizeReelPositions() { + guard animation.isReel else { return } + + var nextPositions: [UUID: Double] = [:] + + for column in animationRange { + guard let digit = column.value.digitValue else { continue } + nextPositions[column.id] = reelPositions[column.id] ?? Double(digit) + } + + reelPositions = nextPositions + } + private func withoutAnimation(_ updates: () -> Void) { var transaction = Transaction(animation: nil) transaction.disablesAnimations = true @@ -230,3 +299,58 @@ public struct AnimateNumberText: View { } } } + +@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 8bfc128..7d966db 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -11,31 +11,113 @@ 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 { - private let duration: TimeInterval + enum Style: Equatable, Sendable { + case smooth(duration: TimeInterval) + case reel(duration: TimeInterval, revolutions: Int, stagger: TimeInterval) + } + + let style: Style /// 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(duration: duration) + AnimateNumberTextAnimation(style: .smooth(duration: duration)) + } + + /// Creates a slot-machine reel animation. + /// + /// 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: + /// - duration: The time the first (leftmost) digit takes to settle. + /// - revolutions: The number of full 0–9 turns each digit makes before + /// landing on its value. + /// - stagger: The extra settle time added per digit so places stop in + /// sequence rather than all at once. + public static func reel(duration: TimeInterval = 0.9, + revolutions: Int = 1, + stagger: TimeInterval = 0.18) -> AnimateNumberTextAnimation { + AnimateNumberTextAnimation(style: .reel(duration: duration, + revolutions: Swift.max(0, revolutions), + stagger: stagger)) } - private init(duration: TimeInterval = 0.5) { - self.duration = duration + private init(style: Style) { + self.style = style } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension AnimateNumberTextAnimation { - func digitAnimation(at index: Int) -> Animation { - .interactiveSpring(response: sanitized(duration), - dampingFraction: 1, - blendDuration: 0) + /// Whether digits should render as spinning reels. + var isReel: Bool { + if case .reel = style { return true } + return false + } + + /// 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 + } + + /// 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 + } + } + + /// 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 + /// staggers the settle time so places come to rest one after another. + func digitAnimation(at ordinal: Int) -> Animation { + switch style { + case .smooth(let duration): + return .interactiveSpring(response: sanitized(duration), + dampingFraction: 1, + blendDuration: 0) + + case .reel(let duration, _, let stagger): + let settle = sanitized(duration) + Double(Swift.max(0, ordinal)) * sanitized(stagger) + return .easeOut(duration: settle) + } } private func sanitized(_ value: TimeInterval) -> TimeInterval { guard value.isFinite else { return 0 } return Swift.max(0, value) } + + 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 48ac0b5..1b7f1e1 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -171,6 +171,17 @@ struct AnimateNumberTextTests { #expect(String(describing: type(of: view)) == "AnimateNumberText") } + @Test + @MainActor + func readOnlyValueInitializerAcceptsReelAnimation() { + let view = AnimateNumberText(value: 301.9, + animation: .reel(duration: 0.9, + revolutions: 1, + stagger: 0.18)) + + #expect(String(describing: type(of: view)) == "AnimateNumberText") + } + @Test func resizeForAnimationUsesStablePlaceholders() { var columns = [ @@ -265,6 +276,26 @@ struct AnimateNumberTextTests { #expect(!columns.canAnimateDigitChange(to: "6", index: 1)) } + @Test + 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 = [ @@ -285,4 +316,25 @@ struct AnimateNumberTextTests { #expect(AnimateNumberTextAnimation.smooth() == .smooth(duration: 0.5)) #expect(AnimateNumberTextAnimation.smooth(duration: 0.3) != .smooth(duration: 0.5)) } + + @Test + func reelAnimationConfiguration() { + #expect(AnimateNumberTextAnimation.reel() == .reel(duration: 0.9, + revolutions: 1, + stagger: 0.18)) + #expect(AnimateNumberTextAnimation.reel(revolutions: -1) == .reel(revolutions: 0)) + #expect(AnimateNumberTextAnimation.reel(duration: 0.9) != .smooth(duration: 0.9)) + } + + @Test + func reelTargetPositionAlternatesDirectionAndSpinsUnchangedDigits() { + let animation = AnimateNumberTextAnimation.reel(duration: 0.9, + revolutions: 1, + stagger: 0.18) + + #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) + } } From ad01a22917eeaa2e3bf9ddb03f05414804769f12 Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 10:59:37 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat.=20roll=20=EC=88=AB=EC=9E=90=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 숫자 롤링 애니메이션을 호출할 수 있는 roll API를 추가합니다. - roll(_ duration:revolutions:stagger:)를 public API로 제공합니다. - 기존 reel(duration:revolutions:stagger:)는 호환성을 위해 roll을 호출하는 alias로 유지합니다. - roll(2.5) 호출 케이스를 테스트에 추가하고 문서 예제를 roll 기준으로 갱신했습니다. 검증: - swift test - git diff --check --- .../AnimateNumberText.docc/AnimateNumberText.md | 10 +++++----- .../Public/AnimateNumberTextAnimation.swift | 15 +++++++++++++-- .../AnimateNumberTextTests.swift | 12 ++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md index 99aae9a..c4b735d 100644 --- a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md +++ b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md @@ -129,7 +129,7 @@ struct LatencyView: View { 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 reel animation is preferred. +be customized or when a rolling digit animation is preferred. ```swift import SwiftUI @@ -149,16 +149,16 @@ struct ScoreView: View { } ``` -Use `.reel(duration:revolutions:stagger:)` to make each digit spin through -0...9 before settling. Digit columns start together, alternate direction from -left to right, and stop sequentially by adding the stagger time per digit. +Use `.roll(_:revolutions:stagger:)` to make each digit spin through 0...9 +before settling. Digit columns start together, alternate direction from left to +right, and stop sequentially by adding the stagger time per digit. ```swift AnimateNumberText( value: $value, textColor: $textColor, stringFormatter: "%@ ms", - animation: .reel(duration: 0.9, + animation: .roll(0.9, revolutions: 1, stagger: 0.18) ) diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index 7d966db..c411cb5 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -26,7 +26,7 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { AnimateNumberTextAnimation(style: .smooth(duration: duration)) } - /// Creates a slot-machine reel animation. + /// Creates a rolling digit animation. /// /// Every digit spins through 0–9, neighbouring places spin in opposite /// directions, and the places come to rest one after another from left to @@ -38,7 +38,7 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { /// landing on its value. /// - stagger: The extra settle time added per digit so places stop in /// sequence rather than all at once. - public static func reel(duration: TimeInterval = 0.9, + public static func roll(_ duration: TimeInterval = 0.9, revolutions: Int = 1, stagger: TimeInterval = 0.18) -> AnimateNumberTextAnimation { AnimateNumberTextAnimation(style: .reel(duration: duration, @@ -46,6 +46,17 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { stagger: stagger)) } + /// Creates a slot-machine reel animation. + /// + /// Prefer ``roll(_:revolutions:stagger:)`` at new call sites. + public static func reel(duration: TimeInterval = 0.9, + revolutions: Int = 1, + stagger: TimeInterval = 0.18) -> AnimateNumberTextAnimation { + roll(duration, + revolutions: revolutions, + stagger: stagger) + } + private init(style: Style) { self.style = style } diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 1b7f1e1..8c6a086 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -317,6 +317,18 @@ struct AnimateNumberTextTests { #expect(AnimateNumberTextAnimation.smooth(duration: 0.3) != .smooth(duration: 0.5)) } + @Test + func rollAnimationConfiguration() { + #expect(AnimateNumberTextAnimation.roll() == .reel(duration: 0.9, + revolutions: 1, + stagger: 0.18)) + #expect(AnimateNumberTextAnimation.roll(2.5) == .reel(duration: 2.5, + revolutions: 1, + stagger: 0.18)) + #expect(AnimateNumberTextAnimation.roll(revolutions: -1) == .reel(revolutions: 0)) + #expect(AnimateNumberTextAnimation.roll(0.9) != .smooth(duration: 0.9)) + } + @Test func reelAnimationConfiguration() { #expect(AnimateNumberTextAnimation.reel() == .reel(duration: 0.9, From 3919256cfbf9c534bf8e1351baafb61d4a466b81 Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 11:18:58 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20roll=20=EC=9E=90=EB=A6=AC=EB=B3=84?= =?UTF-8?q?=20=EC=88=9C=EC=B0=A8=20=EC=A0=95=EC=A7=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roll/reel 애니메이션에서 각 숫자 컬럼이 같은 시점에 돌기 시작하되 자리별 duration을 별도로 적용하도록 변경했습니다. 기존에는 하나의 reelPositions Dictionary 상태를 여러 withAnimation으로 갱신해 SwiftUI transaction이 합쳐지면서 자리별 duration 차이가 안정적으로 반영되지 않았습니다. 각 ReelDigitColumn에 digit ordinal 기반 animation을 직접 붙이고, position 상태 갱신은 한 번에 처리해 301.9 기준 3, 0, 1, 9가 0.25초 간격으로 순차 정지하게 했습니다. 기본 stagger를 0.25초로 조정하고, duration 계산 테스트와 문서 예제를 함께 갱신했습니다. --- README.md | 2 +- .../AnimateNumberText.md | 2 +- .../Public/AnimateNumberText.swift | 46 +++++++++++++++---- .../Public/AnimateNumberTextAnimation.swift | 31 ++++++++++--- .../AnimateNumberTextTests.swift | 22 +++++++-- 5 files changed, 82 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bf64d1e..5e54c39 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ AnimateNumberText(value: $value, stringFormatter: "%@ ms", animation: .reel(duration: 0.9, revolutions: 1, - stagger: 0.18)) + stagger: 0.25)) ``` 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 c4b735d..f108ccc 100644 --- a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md +++ b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md @@ -160,7 +160,7 @@ AnimateNumberText( stringFormatter: "%@ ms", animation: .roll(0.9, revolutions: 1, - stagger: 0.18) + stagger: 0.25) ) ``` diff --git a/Sources/AnimateNumberText/Public/AnimateNumberText.swift b/Sources/AnimateNumberText/Public/AnimateNumberText.swift index 0504045..ab6387a 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberText.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberText.swift @@ -99,17 +99,19 @@ 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) + digitColumn(for: renderedColumn.column, + digitOrdinal: renderedColumn.digitOrdinal) } } } @@ -155,16 +157,33 @@ public struct AnimateNumberText: View { } } + private var renderedColumns: [RenderedTextColumn] { + var digitOrdinal = 0 + + return animationRange.map { column in + let ordinal = column.value.isNumber ? digitOrdinal : nil + + if column.value.isNumber { + digitOrdinal += 1 + } + + return RenderedTextColumn(column: column, + digitOrdinal: ordinal) + } + } + @ViewBuilder - private func digitColumn(for column: TextColumn) -> some View { + private func digitColumn(for column: TextColumn, digitOrdinal: 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), value: position) } else { smoothDigitColumn(for: column.value) } @@ -244,6 +263,7 @@ public struct AnimateNumberText: View { @MainActor private func settingReelAnimationRange(_ characters: [Character]) { synchronizeReelPositions() + var nextPositions = reelPositions for (index, value) in characters.enumerated() where animationRange.canAnimateDigitChange(to: value, index: index) { @@ -259,11 +279,11 @@ public struct AnimateNumberText: View { to: digit, ordinal: digitOrdinal) - withAnimation(animation.digitAnimation(at: digitOrdinal)) { - reelPositions[columnID] = targetPosition - animationRange.set(value, index: index) - } + nextPositions[columnID] = targetPosition + animationRange.set(value, index: index) } + + reelPositions = nextPositions } @MainActor @@ -300,6 +320,16 @@ 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? + + 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 { diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index c411cb5..986a028 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -36,11 +36,12 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { /// - duration: The time the first (leftmost) digit takes to settle. /// - revolutions: The number of full 0–9 turns each digit makes before /// landing on its value. - /// - stagger: The extra settle time added per digit so places stop in - /// sequence rather than all at once. + /// - stagger: The extra settle time added per digit. With the default + /// `0.25`, a value like `301.9` settles as `3`, then `0`, then `1`, + /// then `9` at quarter-second intervals. public static func roll(_ duration: TimeInterval = 0.9, revolutions: Int = 1, - stagger: TimeInterval = 0.18) -> AnimateNumberTextAnimation { + stagger: TimeInterval = 0.25) -> AnimateNumberTextAnimation { AnimateNumberTextAnimation(style: .reel(duration: duration, revolutions: Swift.max(0, revolutions), stagger: stagger)) @@ -51,7 +52,7 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { /// Prefer ``roll(_:revolutions:stagger:)`` at new call sites. public static func reel(duration: TimeInterval = 0.9, revolutions: Int = 1, - stagger: TimeInterval = 0.18) -> AnimateNumberTextAnimation { + stagger: TimeInterval = 0.25) -> AnimateNumberTextAnimation { roll(duration, revolutions: revolutions, stagger: stagger) @@ -109,8 +110,20 @@ extension AnimateNumberTextAnimation { blendDuration: 0) case .reel(let duration, _, let stagger): - let settle = sanitized(duration) + Double(Swift.max(0, ordinal)) * sanitized(stagger) - return .easeOut(duration: settle) + return .easeOut(duration: digitDuration(duration, + stagger: stagger, + ordinal: ordinal)) + } + } + + func digitDuration(at ordinal: Int) -> TimeInterval { + switch style { + case .smooth(let duration): + return sanitized(duration) + case .reel(let duration, _, let stagger): + return digitDuration(duration, + stagger: stagger, + ordinal: ordinal) } } @@ -119,6 +132,12 @@ extension AnimateNumberTextAnimation { return Swift.max(0, value) } + private func digitDuration(_ duration: TimeInterval, + stagger: TimeInterval, + ordinal: Int) -> TimeInterval { + sanitized(duration) + Double(Swift.max(0, ordinal)) * sanitized(stagger) + } + private func reelDistance(from currentDigit: Double, to targetDigit: Double, ordinal: Int) -> Double { if ordinal.isMultiple(of: 2) { return positiveRemainder(targetDigit - currentDigit, dividedBy: 10) diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 8c6a086..ef72513 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -177,7 +177,7 @@ struct AnimateNumberTextTests { let view = AnimateNumberText(value: 301.9, animation: .reel(duration: 0.9, revolutions: 1, - stagger: 0.18)) + stagger: 0.25)) #expect(String(describing: type(of: view)) == "AnimateNumberText") } @@ -321,10 +321,10 @@ struct AnimateNumberTextTests { func rollAnimationConfiguration() { #expect(AnimateNumberTextAnimation.roll() == .reel(duration: 0.9, revolutions: 1, - stagger: 0.18)) + stagger: 0.25)) #expect(AnimateNumberTextAnimation.roll(2.5) == .reel(duration: 2.5, revolutions: 1, - stagger: 0.18)) + stagger: 0.25)) #expect(AnimateNumberTextAnimation.roll(revolutions: -1) == .reel(revolutions: 0)) #expect(AnimateNumberTextAnimation.roll(0.9) != .smooth(duration: 0.9)) } @@ -333,16 +333,28 @@ struct AnimateNumberTextTests { func reelAnimationConfiguration() { #expect(AnimateNumberTextAnimation.reel() == .reel(duration: 0.9, revolutions: 1, - stagger: 0.18)) + stagger: 0.25)) #expect(AnimateNumberTextAnimation.reel(revolutions: -1) == .reel(revolutions: 0)) #expect(AnimateNumberTextAnimation.reel(duration: 0.9) != .smooth(duration: 0.9)) } + @Test + func reelDigitDurationsStopAtQuarterSecondIntervals() { + let animation = AnimateNumberTextAnimation.reel(duration: 0.9, + revolutions: 1, + stagger: 0.25) + + #expect(abs(animation.digitDuration(at: 0) - 0.9) < 0.000_001) + #expect(abs(animation.digitDuration(at: 1) - 1.15) < 0.000_001) + #expect(abs(animation.digitDuration(at: 2) - 1.4) < 0.000_001) + #expect(abs(animation.digitDuration(at: 3) - 1.65) < 0.000_001) + } + @Test func reelTargetPositionAlternatesDirectionAndSpinsUnchangedDigits() { let animation = AnimateNumberTextAnimation.reel(duration: 0.9, revolutions: 1, - stagger: 0.18) + stagger: 0.25) #expect(animation.reelTargetPosition(from: 0, to: 3, ordinal: 0) == 13) #expect(animation.reelTargetPosition(from: 0, to: 3, ordinal: 1) == -17) From cfe5018d403d7dac516a4a1bff3660d6852ed20f Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 11:30:12 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20roll=20=EC=A0=95=EC=A7=80=20?= =?UTF-8?q?=EA=B0=84=EA=B2=A9=20API=20=EC=9D=B4=EB=A6=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자리별 정지 간격을 사용자가 조정할 수 있는 옵션 이름을 stagger에서 settleInterval로 변경했습니다. 라이브러리는 기본값 0.25초와 자리별 duration 계산을 책임지고, 호출자는 필요할 때 settleInterval만 조정하도록 공개 API 의미를 명확히 했습니다. README, DocC, Swift Testing 호출부를 새 이름에 맞춰 갱신했습니다. --- README.md | 6 ++-- .../AnimateNumberText.md | 6 ++-- .../Public/AnimateNumberTextAnimation.swift | 28 +++++++++---------- .../AnimateNumberTextTests.swift | 12 ++++---- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 5e54c39..006ba22 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,9 @@ AnimateNumberText(value: $value, animation: .smooth(duration: 0.5)) ``` -Use `.reel(duration:revolutions:stagger:)` when each digit should spin through +Use `.reel(duration:revolutions:settleInterval:)` when each digit should spin through 0...9 before settling. Digit columns start together, alternate direction from -left to right, and stop sequentially by adding the stagger time per digit. +left to right, and stop sequentially by adding the settle interval per digit. ```swift AnimateNumberText(value: $value, @@ -150,7 +150,7 @@ AnimateNumberText(value: $value, stringFormatter: "%@ ms", animation: .reel(duration: 0.9, revolutions: 1, - stagger: 0.25)) + settleInterval: 0.25)) ``` 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 f108ccc..0872892 100644 --- a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md +++ b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md @@ -149,9 +149,9 @@ struct ScoreView: View { } ``` -Use `.roll(_:revolutions:stagger:)` to make each digit spin through 0...9 +Use `.roll(_:revolutions:settleInterval:)` to make each digit spin through 0...9 before settling. Digit columns start together, alternate direction from left to -right, and stop sequentially by adding the stagger time per digit. +right, and stop sequentially by adding the settle interval per digit. ```swift AnimateNumberText( @@ -160,7 +160,7 @@ AnimateNumberText( stringFormatter: "%@ ms", animation: .roll(0.9, revolutions: 1, - stagger: 0.25) + settleInterval: 0.25) ) ``` diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index 986a028..8209ada 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -13,7 +13,7 @@ import SwiftUI public struct AnimateNumberTextAnimation: Equatable, Sendable { enum Style: Equatable, Sendable { case smooth(duration: TimeInterval) - case reel(duration: TimeInterval, revolutions: Int, stagger: TimeInterval) + case reel(duration: TimeInterval, revolutions: Int, settleInterval: TimeInterval) } let style: Style @@ -36,26 +36,26 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { /// - duration: The time the first (leftmost) digit takes to settle. /// - revolutions: The number of full 0–9 turns each digit makes before /// landing on its value. - /// - stagger: The extra settle time added per digit. With the default + /// - settleInterval: The extra settle time added per digit. With the default /// `0.25`, a value like `301.9` settles as `3`, then `0`, then `1`, /// then `9` at quarter-second intervals. public static func roll(_ duration: TimeInterval = 0.9, revolutions: Int = 1, - stagger: TimeInterval = 0.25) -> AnimateNumberTextAnimation { + settleInterval: TimeInterval = 0.25) -> AnimateNumberTextAnimation { AnimateNumberTextAnimation(style: .reel(duration: duration, revolutions: Swift.max(0, revolutions), - stagger: stagger)) + settleInterval: settleInterval)) } /// Creates a slot-machine reel animation. /// - /// Prefer ``roll(_:revolutions:stagger:)`` at new call sites. + /// Prefer ``roll(_:revolutions:settleInterval:)`` at new call sites. public static func reel(duration: TimeInterval = 0.9, revolutions: Int = 1, - stagger: TimeInterval = 0.25) -> AnimateNumberTextAnimation { + settleInterval: TimeInterval = 0.25) -> AnimateNumberTextAnimation { roll(duration, revolutions: revolutions, - stagger: stagger) + settleInterval: settleInterval) } private init(style: Style) { @@ -101,7 +101,7 @@ extension AnimateNumberTextAnimation { /// 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 - /// staggers the settle time so places come to rest one after another. + /// adds the settle interval so places come to rest one after another. func digitAnimation(at ordinal: Int) -> Animation { switch style { case .smooth(let duration): @@ -109,9 +109,9 @@ extension AnimateNumberTextAnimation { dampingFraction: 1, blendDuration: 0) - case .reel(let duration, _, let stagger): + case .reel(let duration, _, let settleInterval): return .easeOut(duration: digitDuration(duration, - stagger: stagger, + settleInterval: settleInterval, ordinal: ordinal)) } } @@ -120,9 +120,9 @@ extension AnimateNumberTextAnimation { switch style { case .smooth(let duration): return sanitized(duration) - case .reel(let duration, _, let stagger): + case .reel(let duration, _, let settleInterval): return digitDuration(duration, - stagger: stagger, + settleInterval: settleInterval, ordinal: ordinal) } } @@ -133,9 +133,9 @@ extension AnimateNumberTextAnimation { } private func digitDuration(_ duration: TimeInterval, - stagger: TimeInterval, + settleInterval: TimeInterval, ordinal: Int) -> TimeInterval { - sanitized(duration) + Double(Swift.max(0, ordinal)) * sanitized(stagger) + sanitized(duration) + Double(Swift.max(0, ordinal)) * sanitized(settleInterval) } private func reelDistance(from currentDigit: Double, to targetDigit: Double, ordinal: Int) -> Double { diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index ef72513..5a3e374 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -177,7 +177,7 @@ struct AnimateNumberTextTests { let view = AnimateNumberText(value: 301.9, animation: .reel(duration: 0.9, revolutions: 1, - stagger: 0.25)) + settleInterval: 0.25)) #expect(String(describing: type(of: view)) == "AnimateNumberText") } @@ -321,10 +321,10 @@ struct AnimateNumberTextTests { func rollAnimationConfiguration() { #expect(AnimateNumberTextAnimation.roll() == .reel(duration: 0.9, revolutions: 1, - stagger: 0.25)) + settleInterval: 0.25)) #expect(AnimateNumberTextAnimation.roll(2.5) == .reel(duration: 2.5, revolutions: 1, - stagger: 0.25)) + settleInterval: 0.25)) #expect(AnimateNumberTextAnimation.roll(revolutions: -1) == .reel(revolutions: 0)) #expect(AnimateNumberTextAnimation.roll(0.9) != .smooth(duration: 0.9)) } @@ -333,7 +333,7 @@ struct AnimateNumberTextTests { func reelAnimationConfiguration() { #expect(AnimateNumberTextAnimation.reel() == .reel(duration: 0.9, revolutions: 1, - stagger: 0.25)) + settleInterval: 0.25)) #expect(AnimateNumberTextAnimation.reel(revolutions: -1) == .reel(revolutions: 0)) #expect(AnimateNumberTextAnimation.reel(duration: 0.9) != .smooth(duration: 0.9)) } @@ -342,7 +342,7 @@ struct AnimateNumberTextTests { func reelDigitDurationsStopAtQuarterSecondIntervals() { let animation = AnimateNumberTextAnimation.reel(duration: 0.9, revolutions: 1, - stagger: 0.25) + settleInterval: 0.25) #expect(abs(animation.digitDuration(at: 0) - 0.9) < 0.000_001) #expect(abs(animation.digitDuration(at: 1) - 1.15) < 0.000_001) @@ -354,7 +354,7 @@ struct AnimateNumberTextTests { func reelTargetPositionAlternatesDirectionAndSpinsUnchangedDigits() { let animation = AnimateNumberTextAnimation.reel(duration: 0.9, revolutions: 1, - stagger: 0.25) + settleInterval: 0.25) #expect(animation.reelTargetPosition(from: 0, to: 3, ordinal: 0) == 13) #expect(animation.reelTargetPosition(from: 0, to: 3, ordinal: 1) == -17) From 995b0915ab4b66f63045044cf588353b3a10000e Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 11:34:38 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20reel=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20API=20=EB=8B=A8=EC=9D=BC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roll alias를 제거하고 자리별 릴 애니메이션 공개 API를 reel 하나로 정리했습니다. DocC 예제와 Swift Testing 검증도 reel(duration:revolutions:settleInterval:) 기준으로 갱신했습니다. 불필요한 중복 API를 줄여 호출자가 선택해야 하는 애니메이션 이름을 명확히 했습니다. --- .../AnimateNumberText.docc/AnimateNumberText.md | 4 ++-- .../Public/AnimateNumberTextAnimation.swift | 15 ++------------- .../AnimateNumberTextTests.swift | 15 +++------------ 3 files changed, 7 insertions(+), 27 deletions(-) diff --git a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md index 0872892..edd60b3 100644 --- a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md +++ b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md @@ -149,7 +149,7 @@ struct ScoreView: View { } ``` -Use `.roll(_:revolutions:settleInterval:)` to make each digit spin through 0...9 +Use `.reel(duration:revolutions:settleInterval:)` to make each digit spin through 0...9 before settling. Digit columns start together, alternate direction from left to right, and stop sequentially by adding the settle interval per digit. @@ -158,7 +158,7 @@ AnimateNumberText( value: $value, textColor: $textColor, stringFormatter: "%@ ms", - animation: .roll(0.9, + animation: .reel(duration: 0.9, revolutions: 1, settleInterval: 0.25) ) diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index 8209ada..7bd7146 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -26,7 +26,7 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { AnimateNumberTextAnimation(style: .smooth(duration: duration)) } - /// Creates a rolling digit animation. + /// Creates a slot-machine reel animation. /// /// Every digit spins through 0–9, neighbouring places spin in opposite /// directions, and the places come to rest one after another from left to @@ -39,7 +39,7 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { /// - settleInterval: The extra settle time added per digit. With the default /// `0.25`, a value like `301.9` settles as `3`, then `0`, then `1`, /// then `9` at quarter-second intervals. - public static func roll(_ duration: TimeInterval = 0.9, + public static func reel(duration: TimeInterval = 0.9, revolutions: Int = 1, settleInterval: TimeInterval = 0.25) -> AnimateNumberTextAnimation { AnimateNumberTextAnimation(style: .reel(duration: duration, @@ -47,17 +47,6 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { settleInterval: settleInterval)) } - /// Creates a slot-machine reel animation. - /// - /// Prefer ``roll(_:revolutions:settleInterval:)`` at new call sites. - public static func reel(duration: TimeInterval = 0.9, - revolutions: Int = 1, - settleInterval: TimeInterval = 0.25) -> AnimateNumberTextAnimation { - roll(duration, - revolutions: revolutions, - settleInterval: settleInterval) - } - private init(style: Style) { self.style = style } diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 5a3e374..bb168ac 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -317,23 +317,14 @@ struct AnimateNumberTextTests { #expect(AnimateNumberTextAnimation.smooth(duration: 0.3) != .smooth(duration: 0.5)) } - @Test - func rollAnimationConfiguration() { - #expect(AnimateNumberTextAnimation.roll() == .reel(duration: 0.9, - revolutions: 1, - settleInterval: 0.25)) - #expect(AnimateNumberTextAnimation.roll(2.5) == .reel(duration: 2.5, - revolutions: 1, - settleInterval: 0.25)) - #expect(AnimateNumberTextAnimation.roll(revolutions: -1) == .reel(revolutions: 0)) - #expect(AnimateNumberTextAnimation.roll(0.9) != .smooth(duration: 0.9)) - } - @Test func reelAnimationConfiguration() { #expect(AnimateNumberTextAnimation.reel() == .reel(duration: 0.9, revolutions: 1, settleInterval: 0.25)) + #expect(AnimateNumberTextAnimation.reel(duration: 2.5) == .reel(duration: 2.5, + revolutions: 1, + settleInterval: 0.25)) #expect(AnimateNumberTextAnimation.reel(revolutions: -1) == .reel(revolutions: 0)) #expect(AnimateNumberTextAnimation.reel(duration: 0.9) != .smooth(duration: 0.9)) } From 324c6261ade0a8ed956c506776ea33576a4772fc Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 12:45:52 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20reel=20duration=EC=9D=84=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=A0=95=EC=A7=80=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reel 애니메이션의 duration 의미를 마지막 숫자가 완전히 멈추는 전체 시간으로 수정했습니다. 기존에는 첫 번째 숫자의 정지 시간에 ordinal * settleInterval을 더해 duration보다 뒤에 마지막 숫자가 멈추는 문제가 있었습니다. digit count를 기준으로 각 자리의 duration을 duration - remainingDigits * settleInterval로 계산해 301.9에서 3, 0, 1, 9가 순차 정지하고 마지막 숫자가 지정한 duration에 멈추게 했습니다. duration이 settleInterval 총합보다 짧을 때 음수 duration이 나오지 않도록 0으로 보정하는 테스트도 추가했습니다. --- README.md | 5 ++-- .../AnimateNumberText.md | 5 ++-- .../Public/AnimateNumberText.swift | 13 ++++++--- .../Public/AnimateNumberTextAnimation.swift | 29 ++++++++++++------- .../AnimateNumberTextTests.swift | 24 +++++++++++---- 5 files changed, 52 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 006ba22..70d32ca 100644 --- a/README.md +++ b/README.md @@ -142,13 +142,14 @@ AnimateNumberText(value: $value, Use `.reel(duration:revolutions:settleInterval:)` when each digit should spin through 0...9 before settling. Digit columns start together, alternate direction from -left to right, and stop sequentially by adding the settle interval per digit. +left to right, and stop sequentially by the settle interval. `duration` is the +total time until the last digit settles. ```swift AnimateNumberText(value: $value, textColor: $textColor, stringFormatter: "%@ ms", - animation: .reel(duration: 0.9, + animation: .reel(duration: 2.5, revolutions: 1, settleInterval: 0.25)) ``` diff --git a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md index edd60b3..160dbf3 100644 --- a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md +++ b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md @@ -151,14 +151,15 @@ struct ScoreView: View { Use `.reel(duration:revolutions:settleInterval:)` to make each digit spin through 0...9 before settling. Digit columns start together, alternate direction from left to -right, and stop sequentially by adding the settle interval per digit. +right, and stop sequentially by the settle interval. `duration` is the total +time until the last digit settles. ```swift AnimateNumberText( value: $value, textColor: $textColor, stringFormatter: "%@ ms", - animation: .reel(duration: 0.9, + animation: .reel(duration: 2.5, revolutions: 1, settleInterval: 0.25) ) diff --git a/Sources/AnimateNumberText/Public/AnimateNumberText.swift b/Sources/AnimateNumberText/Public/AnimateNumberText.swift index ab6387a..528eece 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberText.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberText.swift @@ -111,7 +111,8 @@ public struct AnimateNumberText: View { .foregroundColor(textColor) case .number: digitColumn(for: renderedColumn.column, - digitOrdinal: renderedColumn.digitOrdinal) + digitOrdinal: renderedColumn.digitOrdinal, + digitCount: renderedColumn.digitCount) } } } @@ -159,6 +160,7 @@ public struct AnimateNumberText: View { private var renderedColumns: [RenderedTextColumn] { var digitOrdinal = 0 + let digitCount = animationRange.filter(\.value.isNumber).count return animationRange.map { column in let ordinal = column.value.isNumber ? digitOrdinal : nil @@ -168,12 +170,13 @@ public struct AnimateNumberText: View { } return RenderedTextColumn(column: column, - digitOrdinal: ordinal) + digitOrdinal: ordinal, + digitCount: digitCount) } } @ViewBuilder - private func digitColumn(for column: TextColumn, digitOrdinal: Int?) -> some View { + 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) @@ -183,7 +186,8 @@ public struct AnimateNumberText: View { font: font, weight: weight, textColor: textColor) - .animation(animation.digitAnimation(at: ordinal), value: position) + .animation(animation.digitAnimation(at: ordinal, + digitCount: digitCount), value: position) } else { smoothDigitColumn(for: column.value) } @@ -324,6 +328,7 @@ public struct AnimateNumberText: View { private struct RenderedTextColumn: Identifiable { let column: TextColumn let digitOrdinal: Int? + let digitCount: Int var id: UUID { column.id diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index 7bd7146..4f04d94 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -33,12 +33,12 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { /// right, each decelerating to a stop. /// /// - Parameters: - /// - duration: The time the first (leftmost) digit takes to settle. + /// - duration: The total time until the last digit settles. /// - revolutions: The number of full 0–9 turns each digit makes before /// landing on its value. - /// - settleInterval: The extra settle time added per digit. With the default - /// `0.25`, a value like `301.9` settles as `3`, then `0`, then `1`, - /// then `9` at quarter-second intervals. + /// - settleInterval: The time between each digit settling. With + /// `duration: 2.5` and `settleInterval: 0.25`, a value like `301.9` + /// settles as `3`, then `0`, then `1`, then `9`, ending at 2.5 seconds. public static func reel(duration: TimeInterval = 0.9, revolutions: Int = 1, settleInterval: TimeInterval = 0.25) -> AnimateNumberTextAnimation { @@ -91,7 +91,7 @@ extension AnimateNumberTextAnimation { /// /// `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) -> Animation { + func digitAnimation(at ordinal: Int, digitCount: Int = 1) -> Animation { switch style { case .smooth(let duration): return .interactiveSpring(response: sanitized(duration), @@ -101,18 +101,20 @@ extension AnimateNumberTextAnimation { case .reel(let duration, _, let settleInterval): return .easeOut(duration: digitDuration(duration, settleInterval: settleInterval, - ordinal: ordinal)) + ordinal: ordinal, + digitCount: digitCount)) } } - func digitDuration(at ordinal: Int) -> TimeInterval { + func digitDuration(at ordinal: Int, digitCount: Int = 1) -> TimeInterval { switch style { case .smooth(let duration): return sanitized(duration) case .reel(let duration, _, let settleInterval): return digitDuration(duration, settleInterval: settleInterval, - ordinal: ordinal) + ordinal: ordinal, + digitCount: digitCount) } } @@ -123,8 +125,15 @@ extension AnimateNumberTextAnimation { private func digitDuration(_ duration: TimeInterval, settleInterval: TimeInterval, - ordinal: Int) -> TimeInterval { - sanitized(duration) + Double(Swift.max(0, ordinal)) * sanitized(settleInterval) + ordinal: Int, + digitCount: Int) -> TimeInterval { + let totalDuration = sanitized(duration) + let interval = sanitized(settleInterval) + let lastOrdinal = Swift.max(0, digitCount - 1) + let clampedOrdinal = Swift.max(0, Swift.min(ordinal, lastOrdinal)) + let remainingDigits = lastOrdinal - clampedOrdinal + + return Swift.max(0, totalDuration - Double(remainingDigits) * interval) } private func reelDistance(from currentDigit: Double, to targetDigit: Double, ordinal: Int) -> Double { diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index bb168ac..56d7da8 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -330,15 +330,27 @@ struct AnimateNumberTextTests { } @Test - func reelDigitDurationsStopAtQuarterSecondIntervals() { - let animation = AnimateNumberTextAnimation.reel(duration: 0.9, + func reelDigitDurationsTreatDurationAsTotalSettleTime() { + let animation = AnimateNumberTextAnimation.reel(duration: 2.5, + revolutions: 1, + settleInterval: 0.25) + + #expect(abs(animation.digitDuration(at: 0, digitCount: 4) - 1.75) < 0.000_001) + #expect(abs(animation.digitDuration(at: 1, digitCount: 4) - 2.0) < 0.000_001) + #expect(abs(animation.digitDuration(at: 2, digitCount: 4) - 2.25) < 0.000_001) + #expect(abs(animation.digitDuration(at: 3, digitCount: 4) - 2.5) < 0.000_001) + } + + @Test + func reelDigitDurationsClampToTotalDurationWhenIntervalIsTooLarge() { + let animation = AnimateNumberTextAnimation.reel(duration: 0.5, revolutions: 1, settleInterval: 0.25) - #expect(abs(animation.digitDuration(at: 0) - 0.9) < 0.000_001) - #expect(abs(animation.digitDuration(at: 1) - 1.15) < 0.000_001) - #expect(abs(animation.digitDuration(at: 2) - 1.4) < 0.000_001) - #expect(abs(animation.digitDuration(at: 3) - 1.65) < 0.000_001) + #expect(abs(animation.digitDuration(at: 0, digitCount: 4) - 0.0) < 0.000_001) + #expect(abs(animation.digitDuration(at: 1, digitCount: 4) - 0.0) < 0.000_001) + #expect(abs(animation.digitDuration(at: 2, digitCount: 4) - 0.25) < 0.000_001) + #expect(abs(animation.digitDuration(at: 3, digitCount: 4) - 0.5) < 0.000_001) } @Test From 3bef6a1a1dc74efc7d57834f274fa57ccb3d46c4 Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 13:18:10 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix.=20reel=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20API=20=EB=9D=BC=EB=B2=A8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reel 애니메이션의 공개 API를 spinningDuration, settleDuration, revolutions 라벨로 정리합니다. spinningDuration은 첫 번째 숫자 컬럼이 멈추는 시간을 의미하고, settleDuration은 다음 숫자 컬럼이 순차적으로 멈추는 간격을 의미하도록 구현했습니다. 예를 들어 301.9는 첫 자리 3이 spinningDuration 시점에 멈춘 뒤 0, 1, 9가 settleDuration 간격으로 멈춥니다. README와 DocC 예제, Swift Testing 테스트를 새 API 기준으로 갱신했습니다. 검증: - git diff --check - swift test --- README.md | 12 ++-- .../AnimateNumberText.md | 12 ++-- .../Public/AnimateNumberTextAnimation.swift | 49 +++++++-------- .../AnimateNumberTextTests.swift | 59 +++++++++---------- 4 files changed, 65 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 70d32ca..5418ed6 100644 --- a/README.md +++ b/README.md @@ -140,18 +140,18 @@ AnimateNumberText(value: $value, animation: .smooth(duration: 0.5)) ``` -Use `.reel(duration:revolutions:settleInterval:)` when each digit should spin through +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 interval. `duration` is the -total time until the last digit settles. +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(duration: 2.5, - revolutions: 1, - settleInterval: 0.25)) + 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 160dbf3..1fb1fdb 100644 --- a/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md +++ b/Sources/AnimateNumberText/AnimateNumberText.docc/AnimateNumberText.md @@ -149,19 +149,19 @@ struct ScoreView: View { } ``` -Use `.reel(duration:revolutions:settleInterval:)` to make each digit spin through 0...9 +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 interval. `duration` is the total -time until the last digit settles. +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(duration: 2.5, - revolutions: 1, - settleInterval: 0.25) + animation: .reel(spinningDuration: 1.5, + settleDuration: 0.25, + revolutions: 1) ) ``` diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index 4f04d94..78c3e35 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -13,7 +13,7 @@ import SwiftUI public struct AnimateNumberTextAnimation: Equatable, Sendable { enum Style: Equatable, Sendable { case smooth(duration: TimeInterval) - case reel(duration: TimeInterval, revolutions: Int, settleInterval: TimeInterval) + case reel(spinningDuration: TimeInterval, settleDuration: TimeInterval, revolutions: Int) } let style: Style @@ -33,18 +33,20 @@ public struct AnimateNumberTextAnimation: Equatable, Sendable { /// right, each decelerating to a stop. /// /// - Parameters: - /// - duration: The total time until the last digit settles. + /// - 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. - /// - settleInterval: The time between each digit settling. With - /// `duration: 2.5` and `settleInterval: 0.25`, a value like `301.9` - /// settles as `3`, then `0`, then `1`, then `9`, ending at 2.5 seconds. - public static func reel(duration: TimeInterval = 0.9, - revolutions: Int = 1, - settleInterval: TimeInterval = 0.25) -> AnimateNumberTextAnimation { - AnimateNumberTextAnimation(style: .reel(duration: duration, - revolutions: Swift.max(0, revolutions), - settleInterval: settleInterval)) + 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) { @@ -62,7 +64,7 @@ extension AnimateNumberTextAnimation { /// The number of full 0–9 turns each reel makes before landing. var reelRevolutions: Int { - if case .reel(_, let revolutions, _) = style { return revolutions } + if case .reel(_, _, let revolutions) = style { return revolutions } return 0 } @@ -98,9 +100,9 @@ extension AnimateNumberTextAnimation { dampingFraction: 1, blendDuration: 0) - case .reel(let duration, _, let settleInterval): - return .easeOut(duration: digitDuration(duration, - settleInterval: settleInterval, + case .reel(let spinningDuration, let settleDuration, _): + return .easeOut(duration: digitDuration(spinningDuration, + settleDuration: settleDuration, ordinal: ordinal, digitCount: digitCount)) } @@ -110,9 +112,9 @@ extension AnimateNumberTextAnimation { switch style { case .smooth(let duration): return sanitized(duration) - case .reel(let duration, _, let settleInterval): - return digitDuration(duration, - settleInterval: settleInterval, + case .reel(let spinningDuration, let settleDuration, _): + return digitDuration(spinningDuration, + settleDuration: settleDuration, ordinal: ordinal, digitCount: digitCount) } @@ -123,17 +125,16 @@ extension AnimateNumberTextAnimation { return Swift.max(0, value) } - private func digitDuration(_ duration: TimeInterval, - settleInterval: TimeInterval, + private func digitDuration(_ spinningDuration: TimeInterval, + settleDuration: TimeInterval, ordinal: Int, digitCount: Int) -> TimeInterval { - let totalDuration = sanitized(duration) - let interval = sanitized(settleInterval) + let firstDigitDuration = sanitized(spinningDuration) + let interval = sanitized(settleDuration) let lastOrdinal = Swift.max(0, digitCount - 1) let clampedOrdinal = Swift.max(0, Swift.min(ordinal, lastOrdinal)) - let remainingDigits = lastOrdinal - clampedOrdinal - return Swift.max(0, totalDuration - Double(remainingDigits) * interval) + return firstDigitDuration + Double(clampedOrdinal) * interval } private func reelDistance(from currentDigit: Double, to targetDigit: Double, ordinal: Int) -> Double { diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 56d7da8..120ba86 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -175,9 +175,9 @@ struct AnimateNumberTextTests { @MainActor func readOnlyValueInitializerAcceptsReelAnimation() { let view = AnimateNumberText(value: 301.9, - animation: .reel(duration: 0.9, - revolutions: 1, - settleInterval: 0.25)) + animation: .reel(spinningDuration: 0.9, + settleDuration: 0.25, + revolutions: 1)) #expect(String(describing: type(of: view)) == "AnimateNumberText") } @@ -319,45 +319,42 @@ struct AnimateNumberTextTests { @Test func reelAnimationConfiguration() { - #expect(AnimateNumberTextAnimation.reel() == .reel(duration: 0.9, - revolutions: 1, - settleInterval: 0.25)) - #expect(AnimateNumberTextAnimation.reel(duration: 2.5) == .reel(duration: 2.5, - revolutions: 1, - settleInterval: 0.25)) + #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(duration: 0.9) != .smooth(duration: 0.9)) + #expect(AnimateNumberTextAnimation.reel(spinningDuration: 0.9) != .smooth(duration: 0.9)) } @Test - func reelDigitDurationsTreatDurationAsTotalSettleTime() { - let animation = AnimateNumberTextAnimation.reel(duration: 2.5, - revolutions: 1, - settleInterval: 0.25) - - #expect(abs(animation.digitDuration(at: 0, digitCount: 4) - 1.75) < 0.000_001) - #expect(abs(animation.digitDuration(at: 1, digitCount: 4) - 2.0) < 0.000_001) - #expect(abs(animation.digitDuration(at: 2, digitCount: 4) - 2.25) < 0.000_001) - #expect(abs(animation.digitDuration(at: 3, digitCount: 4) - 2.5) < 0.000_001) + 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 reelDigitDurationsClampToTotalDurationWhenIntervalIsTooLarge() { - let animation = AnimateNumberTextAnimation.reel(duration: 0.5, - revolutions: 1, - settleInterval: 0.25) - - #expect(abs(animation.digitDuration(at: 0, digitCount: 4) - 0.0) < 0.000_001) - #expect(abs(animation.digitDuration(at: 1, digitCount: 4) - 0.0) < 0.000_001) - #expect(abs(animation.digitDuration(at: 2, digitCount: 4) - 0.25) < 0.000_001) - #expect(abs(animation.digitDuration(at: 3, digitCount: 4) - 0.5) < 0.000_001) + 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 reelTargetPositionAlternatesDirectionAndSpinsUnchangedDigits() { - let animation = AnimateNumberTextAnimation.reel(duration: 0.9, - revolutions: 1, - settleInterval: 0.25) + 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) From b0afed831fcbe9be8a9397b42fd70e2eb502f9e0 Mon Sep 17 00:00:00 2001 From: swift-man Date: Sat, 27 Jun 2026 13:39:37 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20reel=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=EC=99=80=20digitCount=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드리뷰에서 지적된 smooth/reel 전환 시 reelPositions 상태 불일치를 수정했습니다. smooth 경로에서 값이 바뀐 뒤에도 현재 digit 값으로 reelPositions를 동기화하여 다시 reel 스타일로 전환해도 오래된 continuous position이 표시되지 않게 했습니다. digitAnimation과 digitDuration의 digitCount 기본값을 제거하고 호출부에서 실제 digit count를 명시하도록 변경했습니다. scheduleAnimationUpdateIfNeeded의 불필요한 async도 제거하고, 관련 상태 동기화 helper와 회귀 테스트를 추가했습니다. --- .../AnimateNumberText/Private/TextType.swift | 27 +++++++++++++++++ .../Public/AnimateNumberText.swift | 27 +++++++++++------ .../Public/AnimateNumberTextAnimation.swift | 4 +-- .../AnimateNumberTextTests.swift | 29 +++++++++++++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/Sources/AnimateNumberText/Private/TextType.swift b/Sources/AnimateNumberText/Private/TextType.swift index 7d51609..f9de879 100644 --- a/Sources/AnimateNumberText/Private/TextType.swift +++ b/Sources/AnimateNumberText/Private/TextType.swift @@ -78,6 +78,7 @@ extension Array where Element == TextColumn { 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 { @@ -88,6 +89,32 @@ extension Array where Element == TextColumn { 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 { guard indices.contains(index) else { return false } diff --git a/Sources/AnimateNumberText/Public/AnimateNumberText.swift b/Sources/AnimateNumberText/Public/AnimateNumberText.swift index 528eece..e0e0222 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberText.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberText.swift @@ -120,12 +120,12 @@ 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 } @@ -160,7 +160,7 @@ public struct AnimateNumberText: View { private var renderedColumns: [RenderedTextColumn] { var digitOrdinal = 0 - let digitCount = animationRange.filter(\.value.isNumber).count + let digitCount = animationRange.digitCount return animationRange.map { column in let ordinal = column.value.isNumber ? digitOrdinal : nil @@ -253,15 +253,20 @@ public struct AnimateNumberText: View { @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)) { + withAnimation(animation.digitAnimation(at: digitOrdinal, + digitCount: digitCount)) { animationRange.set(value, index: index) } } + + synchronizeReelPositionsToCurrentDigits() } @MainActor @@ -294,12 +299,16 @@ public struct AnimateNumberText: View { private func synchronizeReelPositions() { guard animation.isReel else { return } - var nextPositions: [UUID: Double] = [:] + let nextPositions = animationRange.preservingReelPositions(reelPositions) + guard reelPositions != nextPositions else { return } - for column in animationRange { - guard let digit = column.value.digitValue else { continue } - nextPositions[column.id] = reelPositions[column.id] ?? Double(digit) - } + reelPositions = nextPositions + } + + @MainActor + private func synchronizeReelPositionsToCurrentDigits() { + let nextPositions = animationRange.currentDigitPositions() + guard reelPositions != nextPositions else { return } reelPositions = nextPositions } diff --git a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift index 78c3e35..9dd11ff 100644 --- a/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift +++ b/Sources/AnimateNumberText/Public/AnimateNumberTextAnimation.swift @@ -93,7 +93,7 @@ extension AnimateNumberTextAnimation { /// /// `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 = 1) -> Animation { + func digitAnimation(at ordinal: Int, digitCount: Int) -> Animation { switch style { case .smooth(let duration): return .interactiveSpring(response: sanitized(duration), @@ -108,7 +108,7 @@ extension AnimateNumberTextAnimation { } } - func digitDuration(at ordinal: Int, digitCount: Int = 1) -> TimeInterval { + func digitDuration(at ordinal: Int, digitCount: Int) -> TimeInterval { switch style { case .smooth(let duration): return sanitized(duration) diff --git a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift index 120ba86..068b619 100644 --- a/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift +++ b/Tests/AnimateNumberTextTests/AnimateNumberTextTests.swift @@ -311,6 +311,35 @@ struct AnimateNumberTextTests { #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 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))