如何在 SwiftUI 中组合“复杂”的动画?其中复杂是指完整的动画由多个子动画组成。
我正在努力将现有项目从
UIKit
迁移到 SwiftUI
。该项目使用自定义 UIView
来显示进度条,以动画方式显示正值和负值之间的变化。正值用绿色条显示,负值用红色条显示。
假设
maxValue
为 100:
因此,无论值如何变化,动画总长度始终为 1 秒。当从正值更改为负值(或反之亦然)时,条形会缩小到 0,然后再改变颜色并再次变长。
在 SwiftUI 中创建一个简单的进度条没什么大不了的:
struct SomeProgressBar: View {
var height: CGFloat = 20
var minValue: Double = 0
var maxValue: Double = 100
var currentValue: Double = 20
var body: some View {
let maxV = max(minValue, maxValue)
let minV = min(minValue, maxValue)
let currentV = max(min(abs(currentValue - minValue), maxV), minV)
let isNegative = currentValue < minValue
let progress = (currentV - minV) / (maxV - minV)
Rectangle()
.fill(.gray)
.frame(height: height)
.overlay(alignment: .leading) {
GeometryReader { geometry in
Rectangle()
.fill(isNegative ? .red : .green)
.frame(width: geometry.size.width * progress)
}
}
.animation(.easeInOut(duration: 0.5), value: progress)
}
}
struct SomeProgressBarTestView: View {
@State var value: Double = 40
var body: some View {
VStack {
SomeProgressBar(currentValue: value)
Button("Change") {
value = .random(in: -100...100)
}
}
}
}
虽然在仅使用正值或仅使用负值时效果很好,但正值和负值之间的切换不会按预期设置动画。例如,在 +x 和 -x 之间切换只会更改条形颜色,但不会使条形动画化。这并不奇怪,因为宽度没有改变,值改变前后是 x%。然而,期望的结果是将宽度从 x 动画化到 0,然后再返回到 x。
到目前为止我发现的所有来源仅区分隐式动画和显式动画。虽然这改变了动画的实现/描述方式,但它似乎并不影响动画的功能。
创建所需的“复杂”动画的“正确”方法是什么?
当然,可以计算是否发生正负之间的切换,并在这种情况下使用
.withAnimation { ... }
触发两个不同的动画。根据值差计算第一个动画和第二个动画的长度是没有问题的。
但是,这仅适用于线性动画。当使用任何缓动功能(如 spring 等)时,使用这种方法不可能实现连续动画。
此外,进度条只是一个示例。如果需要链接更多动画才能获得所需的结果怎么办?
有没有解决方案来创建这样的自定义动画?
您可以遵守
Animatable
。将与动画相关的部分提取到符合ViewModifier
的Animatable
。
var body: some View {
let maxV = max(minValue, maxValue)
let minV = min(minValue, maxValue)
let currentV = max(min(abs(currentValue - minValue), maxV), minV)
let progress = (currentV - minV) / (maxV - minV)
Rectangle()
.fill(.gray)
.frame(height: height)
.overlay {
Rectangle()
.modifier(ProgressBarModifier(
progress: currentValue < minV ? -progress : progress
))
}
.animation(.linear(duration: 1), value: progress)
}
struct ProgressBarModifier: ViewModifier, Animatable {
var progress: Double // between -1 and 1
nonisolated var animatableData: Double {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
content
.foregroundStyle(progress < 0 ? .red : .green)
// this is clearer than GeometryReader, but GeometryReader should also work
.scaleEffect(x: abs(progress), anchor: .leading)
}
}
在动画的每一帧中,SwiftUI 都会将
animatableData
设置为动画起点和终点之间的某个插值。然后它将调用 body
并使用新框架更新 UI。
您也可以使整个
SomeProgressBar
与 Animatable
保持一致,但随后需要将 animation
修饰符移至父视图。