我很难理解
SwiftUI
在我正在处理的具体示例中如何对视图变化进行动画处理。
TL;DR 问题描述和代码相当长。基本问题是:在这两种情况下,
.offset
的 VStack
都会发生变化并具有动画效果。为什么生成的动画会根据下面的文本而不同?
景色:
目标是创建一个
RollingCounter
视图:一种显示十进制值的文本,并通过上下“滚动”数字来动画值变化。
我的实现基于我在网上找到的一个想法:值文本被分成字符,并为每个数字创建一个
VStack
数字。根据需要显示的具体数字,使用 .offset
值来定位堆栈。
问题
为了测量文本的正确高度,使用视图
Text("X")
,然后用数字 VStack
覆盖。
简单地使用
Text("X")
效果很好,但会导致静态宽度对于像 1
或小数点分隔符 .
这样的数字来说可能太大。
为了解决这个问题,我使用
Text(valueParts[index])
,其中 valueParts[index] 是实际文本。
这会产生正确的宽度,但会导致动画中出现一些奇怪的行为。
虽然使用第一种方法 (
Text("X")
),当 VStack
时,valueParts
可以很好地上下移动,并且 .offset
会发生变化,但当使用 Text(valueParts[index])
时,这不再起作用。效果很难描述,所以我制作了两张gif:
使用第二种方法 (
Text(valueParts[index])
),VStack
仍然可以很好地移动(如在其边界处看到的),一些包含的数字会乱序移动或根本不移动,但会淡入或淡出。
这是为什么?为什么动画会根据文本而变化?在这两种情况下,堆栈偏移量都会更改并且应该具有动画效果。这个动画是如何受到底层文本影响的?
代码
struct RollingCounter: View {
typealias Formatter = ((Double) -> String)
var font: Font = .largeTitle
@Binding var value: Double
@Binding var formatter: ((Double) -> String)
@State var valueParts: [String] = []
var body: some View {
HStack(spacing: 0) {
ForEach(0..<valueParts.count, id: \.self) { index in
//Text("X") // Approach 1
Text(valueParts[index]) // Approach 2
//.id("part_\(valueParts.count - 1 - index)")
.font(font)
.opacity(0.2)
.overlay {
if let int = Int(valueParts[index]) {
GeometryReader { geometry in
VStack(spacing: 0) {
ForEach(0...9, id: \.self) { number in
Text("\(number)")
//.id("\(index)_\(number)")
.font(font)
.border(Color.blue, width: 2)
}
}
.border(Color.green, width: 1)
.offset(y: -CGFloat(CGFloat(int) * geometry.size.height))
}
//.clipped()
} else {
Text(valueParts[index])
.font(font)
}
}
}
.background(.red)
}
.onAppear {
// Init with 0.00
valueParts = formatter(value).map { if Int("\($0)") == nil { "\($0)" } else { "0" } }
updateText()
}
.onChange(of: value) {
updateText()
}
}
func updateText() {
for (index, part) in Array(formatter(value)).enumerated() {
withAnimation(.easeInOut(duration: 5)) {
valueParts[index] = "\(part)"
}
}
}
}
struct TestContentView: View {
@State var value: Double = 0
@State var formatter: RollingDecimal.Formatter = { value in
let formatter = NumberFormatter()
formatter.locale = Locale.init(identifier: "en_US")
formatter.numberStyle = .currency
formatter.currencySymbol = ""
formatter.currencyCode = ""
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
return formatter.string(from: NSNumber(value: value)) ?? ""
}
var body: some View {
VStack(spacing: 25) {
Button("Change Value") {
value = (value == 1.24 ? 8.76 : 1.24) //.random(in: 1...1999)
}
RollingCounter(font: .system(size: 50), value: $value, formatter: $formatter)
}
}
}
#Preview {
TestContentView()
}
为什么动画会根据文字而变化?
我想简短的答案是因为文本中字符的宽度不同。
当您有
Text("X")
时,所有字母将具有相同的宽度,即“X”的宽度。使用 Text(valueParts[index])
,宽度将根据索引处的值而变化。
一种可能的解决方法是通过在
.monospacedDigit()
修饰符之前添加 .overlay
修饰符来确保所有数字具有相同的宽度:
Text(valueParts[index]) // Approach 2
.font(font)
.opacity(0.2)
.monospacedDigit() // <- try adding this
.overlay {
//...
要为每个数字的偏移设置动画,不需要
GeometryReader
。可以根据数字值本身和高度(基于字体大小)来计算。
理想情况下,滚动计数器视图应该只接受双精度作为参数,而不需要特殊设置,例如格式化程序状态。任何必要的格式化都应该发生在滚动计数器视图内。
.overlay
的使用是有问题的,我不确定它是否仅用于演示目的。基本上,所需要的只是改变偏移量和充当遮罩的固定框架。实际的滚动数字基本上是(单个)动画数字的水平堆栈,因此应该有一个
RollingDigit
视图接受单个数字或字符作为参数并相应地对其进行动画处理。这种方法还可以在每个数字的间距或样式方面提供更大的灵活性。根据上述想法,这是我对滚动数字的看法:
import SwiftUI
struct RollingNumberTest: View {
//State values
@State private var number: Double = 0.00
//Body
var body: some View {
VStack(spacing: 20) {
//Number label
Text("Number: \(number, format: .number.precision(.fractionLength(2)))")
.foregroundStyle(.secondary)
//Animated number
RollingNumber(number: $number, fontSize: 30, spacing: 5)
//Style as needed
.fontDesign(.monospaced)
.foregroundStyle(.white)
.padding(.horizontal, 15)
.background(.black, in: RoundedRectangle(cornerRadius: 12))
//Button to change value
Button("Random number"){
number = Double.random(in: 0...20)
}
.buttonStyle(.borderedProminent)
}
}
}
struct RollingNumber: View {
//Parameters
@Binding var number: Double
var fontSize: CGFloat = 30
var spacing: CGFloat = 0
//State values
@State private var numberArray: [String] = []
//Body
var body: some View {
HStack(spacing: spacing) {
ForEach(0..<numberArray.count, id: \.self) { index in
RollingDigit(digit: $numberArray[index], fontSize: fontSize )
}
}
.font(.system(size: fontSize))
.onAppear {
doubleToStringArray(number)
}
.onChange(of: number) {
doubleToStringArray(number)
}
}
//Helper method to convert a double to an array of string characters
private func doubleToStringArray(_ number: Double) {
let formattedNumber = String(format: "%.2f", number )
//Set the animation that affects number of values in array
withAnimation(.interpolatingSpring) {
numberArray = formattedNumber.map { String($0) }
}
}
}
struct RollingDigit: View {
//Parameters
@Binding var digit: String
var fontSize: CGFloat = 30
//State values
@State private var digitOffset: CGFloat = 0
//Computed properties
private var digitHeight: CGFloat {
// Calculate the height of the digit based on the font size
return fontSize * 2 // Adjust as needed for padding
}
private var isDigit: Bool { //determines if digit or other character
Int(digit) != nil ? true : false
}
//Body
var body: some View {
//Vertical stack for digits 0 through 9
VStack(spacing: 0) {
Group {
ForEach(0..<10) { number in
Text("\(number)")
}
Text(".") //adds a period character at the end, after "9"
}
.frame(height: digitHeight)
}
.font(.system(size: fontSize) )
.monospacedDigit() // <- makes every digit same width
.offset(y: digitOffset)
.frame(height: digitHeight, alignment: .top) // Mask to show only one digit
.clipped()
.onAppear {
//if not a digit
if Int(digit) == nil {
//Set the offset to the end to show the period character
digitOffset = -10 * digitHeight
}
}
.onChange(of: digit) {
//Convert from String to Int
let newValue = Int(digit) ?? 10
//Calculate the new offset based on the new value
withAnimation(.interactiveSpring(duration: 2, extraBounce: 0.05)) {
digitOffset = CGFloat(-newValue) * digitHeight
}
}
}
}
//Previews
#Preview {
RollingNumberTest()
}