我们已经看到 Animatable 协议是如何帮助我们实现路径和变换矩阵的动画化的。在本系列的最后一部分,我们将更进一步。AnimatableModifier 是三者中最强大的。有了它,我们可以不受限制地完成任务。
这个名字说明了一切: AnimatableModifier。它是一个 ViewModifier,符合 Animatable。如果你不知道 Animatable和animatableData 是如何工作的,请先到本系列的第一部分去看看。
iOS 15补充:
现在,视图协议可以准守Animatable
协议,而且AnimatableModifier
已被弃用。不过,本文的大部分内容也适用于Animatable
视图。在文章的最后,我将向你展示如何编写一个Animatable
视图。它与AnimatableModifier
基本相同,但更为简单!
好吧,让我们暂停一下,想想拥有一个可动画的修饰符意味着什么......你可能认为这好得不像真的。我真的可以通过一个动画多次修改我的视图吗?答案很简单:是的,你可以。
本文的完整示例代码可在以下位置找到:
https://gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798
示例8 需要的图片资源。从这里下载:
https://swiftui-lab.com/?smd_process_download=1&download_id=916
AnimatableModifier不能实现动画! 为什么?
如果你打算在生产代码中使用 AnimatableModifier,请确保阅读本文最后一节: 与版本共舞。
如果你自己尝试使用了这个协议,有可能你一开始就会碰壁。我当然也是一样。在我的第一次尝试中,我写了一个非常简单的可动画的修饰符,但是,视图并没有动画化。我又试了几次,都没有效果。由于我们处于早期的测试阶段,我认为这个功能就是不存在的,于是就完全放弃了它。幸运的是,我后来坚持了下来。让我强调这个词:"幸运地"。事实证明,我的第一个修饰符是完美的,但可动画的修饰符在容器内是不工作的。恰好我第二次尝试时,我的视图不在容器内。如果我不是那么幸运,你就不会看到这第三篇文章了。
例如,以下 modifier 可以成功实现动画:
MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))
但是相同的代码,在 VStack 中就没有动画了:
VStack {
MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))
}
这个问题在官方解决之前,经过尝试,可以在 VStack 中改成下面的代码,就可以实现动画:
VStack {
Color.clear.overlay(MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))).frame(width: 100, height: 100)
}
我们基本上是用一个透明的视图来占据我们的实际视图的空间,这个视图将被放置在它上面,使用.overlay()
。唯一不方便的是,我们需要知道实际视图有多大,这样我们就可以在它后面设置透明视图的大小。这有时会很棘手,但我们必须要有一些技巧。我们将在下面的例子中看到。
## 动画文本
首先需要制作一些文字动画。对于这个例子,我们将创建一个进度加载指示器,它将是一个带有标签的 Label:
可能很多人都认为应该使用动画路径实现。但是那样的话,内部标签就无法设置动画,然而使用 AnimatableModifier
可以实现。
完整的代码作为 示例10 在文章开始的链接中。关键代码如下:
struct PercentageIndicator: AnimatableModifier {
var pct: CGFloat = 0
var animatableData: CGFloat {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
content
.overlay(ArcShape(pct: pct).foregroundColor(.red))
.overlay(LabelView(pct: pct))
}
struct ArcShape: Shape {
let pct: CGFloat
func path(in rect: CGRect) -> Path {
var p = Path()
p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
radius: rect.height / 2.0 + 5.0,
startAngle: .degrees(0),
endAngle: .degrees(360.0 * Double(pct)), clockwise: false)
return p.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 10))
}
}
struct LabelView: View {
let pct: CGFloat
var body: some View {
Text("\(Int(pct * 100)) %")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.white)
}
}
}
正如你在例子中所看到的,我们没有让ArcShape成为可动画的。这是没有必要的,因为修饰符已经在用不同的 pct 值多次创建形状。
## 渐变动画
如果你曾经尝试对渐变进行动画处理,你可能发现有一些限制。例如,你可以对起点和终点制作动画,但你不能对渐变的颜色制作动画。在这里,我们也可以从AnimatableModifier中获益。
很容易就可以实现这个功能,在这个基础上可以实现更多复杂的动画。如果需要插入中间颜色,我们只需要计算 RGB 值的平均值。另外需要注意,modifier
假设输入颜色数组都包含相同数量的颜色。
完整的代码作为 示例11 在文章开始的链接中。关键代码如下:
struct AnimatableGradient: AnimatableModifier {
let from: [UIColor]
let to: [UIColor]
var pct: CGFloat = 0
var animatableData: CGFloat {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
var gColors = [Color]()
for i in 0.. Color {
guard let cc1 = c1.cgColor.components else { return Color(c1) }
guard let cc2 = c2.cgColor.components else { return Color(c1) }
let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)
return Color(red: Double(r), green: Double(g), blue: Double(b))
}
}
更多文本动画
在我们的下一个例子中,我们将再次为文本制作动画。不过,在这种情况下,我们将逐步地做:一次一个字符。
平滑的渐进式缩放需要一些数学运算,但结果是值得的。完整的代码可在本页顶部链接的gist文件中的Example12中找到。
struct WaveTextModifier: AnimatableModifier {
let text: String
let waveWidth: Int
var pct: Double
var size: CGFloat
var animatableData: Double {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
HStack(spacing: 0) {
ForEach(Array(text.enumerated()), id: \.0) { (n, ch) in
Text(String(ch))
.font(Font.custom("Menlo", size: self.size).bold())
.scaleEffect(self.effect(self.pct, n, self.text.count, Double(self.waveWidth)))
}
}
}
func effect(_ pct: Double, _ n: Int, _ total: Int, _ waveWidth: Double) -> CGFloat {
let n = Double(n)
let total = Double(total)
return CGFloat(1 + valueInCurve(pct: pct, total: total, x: n/total, waveWidth: waveWidth))
}
func valueInCurve(pct: Double, total: Double, x: Double, waveWidth: Double) -> Double {
let chunk = waveWidth / total
let m = 1 / chunk
let offset = (chunk - (1 / total)) * pct
let lowerLimit = (pct - chunk) + offset
let upperLimit = (pct) + offset
guard x >= lowerLimit && x < upperLimit else { return 0 }
let angle = ((x - pct - offset) * m)*360-90
return (sin(angle.rad) + 1) / 2
}
}
extension Double {
var rad: Double { return self * .pi / 180 }
var deg: Double { return self * 180 / .pi }
}
## 发挥你的创意
在我们对AnimatableModifier有所了解之前,下面这个例子可能看起来不可能实现。我们的下一个挑战是创建一个计数器。
这个练习的诀窍是为每个数字使用5个文本视图,用.spring()
动画上下移动它们。我们还需要使用一个.clipShape()
修饰符,来隐藏画在边界外的部分。为了更好地理解它的工作原理,你可以对.clipShape()
进行注释,并大大降低动画的速度。
完整的代码可以在本页顶部链接的gist文件中以Example13的形式获得。
struct MovingCounterModifier: AnimatableModifier {
@State private var height: CGFloat = 0
var number: Double
var animatableData: Double {
get { number }
set { number = newValue }
}
func body(content: Content) -> some View {
let n = self.number + 1
let tOffset: CGFloat = getOffsetForTensDigit(n)
let uOffset: CGFloat = getOffsetForUnitDigit(n)
let u = [n - 2, n - 1, n + 0, n + 1, n + 2].map { getUnitDigit($0) }
let x = getTensDigit(n)
var t = [abs(x - 2), abs(x - 1), abs(x + 0), abs(x + 1), abs(x + 2)]
t = t.map { getUnitDigit(Double($0)) }
let font = Font.custom("Menlo", size: 34).bold()
return HStack(alignment: .top, spacing: 0) {
VStack {
Text("\(t[0])").font(font)
Text("\(t[1])").font(font)
Text("\(t[2])").font(font)
Text("\(t[3])").font(font)
Text("\(t[4])").font(font)
}.foregroundColor(.green).modifier(ShiftEffect(pct: tOffset))
VStack {
Text("\(u[0])").font(font)
Text("\(u[1])").font(font)
Text("\(u[2])").font(font)
Text("\(u[3])").font(font)
Text("\(u[4])").font(font)
}.foregroundColor(.green).modifier(ShiftEffect(pct: uOffset))
}
.clipShape(ClipShape())
.overlay(CounterBorder(height: $height))
.background(CounterBackground(height: $height))
}
func getUnitDigit(_ number: Double) -> Int {
return abs(Int(number) - ((Int(number) / 10) * 10))
}
func getTensDigit(_ number: Double) -> Int {
return abs(Int(number) / 10)
}
func getOffsetForUnitDigit(_ number: Double) -> CGFloat {
return 1 - CGFloat(number - Double(Int(number)))
}
func getOffsetForTensDigit(_ number: Double) -> CGFloat {
if getUnitDigit(number) == 0 {
return 1 - CGFloat(number - Double(Int(number)))
} else {
return 0
}
}
}
## 文本颜色动画
如果你曾经尝试对.foregroundColor()
进行动画处理,你可能已经注意到它工作得很好,除了当视图是文本类型时。我不知道这是个错误,还是功能缺失。尽管如此,如果你需要对文本的颜色进行动画处理,你可以用下面这样的AnimatableModifier来实现。完整的代码可以在本页顶部链接的gist文件中的Example14中找到。
struct AnimatableColorText: View {
let from: UIColor
let to: UIColor
let pct: CGFloat
let text: () -> Text
var body: some View {
let textView = text()
return textView.foregroundColor(Color.clear)
.overlay(Color.clear.modifier(AnimatableColorTextModifier(from: from, to: to, pct: pct, text: textView)))
}
struct AnimatableColorTextModifier: AnimatableModifier {
let from: UIColor
let to: UIColor
var pct: CGFloat
let text: Text
var animatableData: CGFloat {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
return text.foregroundColor(colorMixer(c1: from, c2: to, pct: pct))
}
// This is a very basic implementation of a color interpolation
// between two values.
func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
guard let cc1 = c1.cgColor.components else { return Color(c1) }
guard let cc2 = c2.cgColor.components else { return Color(c1) }
let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)
return Color(red: Double(r), green: Double(g), blue: Double(b))
}
}
}
## 与版本共舞
我们已经看到AnimatableModifier非常强大......但是,也有一点小毛病。最大的问题是,在Xcode和iOS/MacOS版本的某些组合下,应用程序会在启动时直接崩溃。更糟糕的是,这通常发生在部署应用程序时,但在正常开发期间用Xcode编译和运行时不会发生。你可能花了很多时间来开发和调试,认为一切都很好,但在部署时,你会得到这样的结果。
dyld: Symbol not found: _$s7SwiftUI18AnimatableModifierPAAE13_makeViewList8modifier6inputs4bodyAA01_fG7OutputsVAA11_GraphValueVyxG_AA01_fG6InputsVAiA01_L0V_ANtctFZ
Referenced from: /Applications/MyApp.app/Contents/MacOS/MyApp
Expected in: /System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI
例如,如果用Xcode 11.3部署应用程序,并在macOS 10.15.0上执行,它将无法启动,并出现 "没有找到符号 "的错误。然而,在10.15.1上运行相同的可执行文件却能正常工作。
相反,如果我们用Xcode 11.1进行部署,它在所有的macOS版本(至少我试过的版本)都能正常工作。
类似的情况也发生在iOS上。一个使用AnimatableModifier的应用程序,在Xcode 11.2中部署后,在iOS 13.2.2中无法启动,但在iOS 13.2.3中可以正常工作。
目前,我将继续使用Xcode 11.1用于我的需要AnimatableModifier的macOS项目。在未来,我可能会使用较新版本的Xcode,但将应用程序的要求提高到macOS 10.15.1(除非这个问题得到解决,我非常怀疑)。
Animatable View (iOS 15补充)
自 iOS15 和 macOS12 起,View
协议可以采用 Animatable
协议。这样就无需使用 AnimatableModifier
了。如果你知道如何使用 AnimatableModifier
,通过这个快速示例,你就会发现更新代码是多么容易:
struct ExampleView: View {
@State var animate = false
var body: some View {
CustomView(xoffset: animate ? 100 : -100)
.task {
withAnimation(.spring.repeatForever(autoreverses: true)) {
animate.toggle()
}
}
}
}
struct CustomView: View {
var xoffset: CGFloat
var body: some View {
Rectangle()
.fill(.green.gradient)
.frame(width: 30, height: 30)
.offset(computedOffset())
}
func computedOffset() -> CGSize {
return CGSize(width: xoffset, height: sin(xoffset/100 * .pi) * 100)
}
}
该动画将使矩形从左到右直线移动。虽然垂直偏移量的计算公式为 sin(xoffset/100 * .pi) * 100,但在 x = -100.0 时,y 为 0.0,而在 x = 100.0 时,y 为 0.0。就 SwiftUI 而言,由于动画开始和结束时 y 均为 0.0,因此纵轴上没有任何动画。
如果我们希望在动画的每一帧中都重新计算视图主体,那么我们可以通过添加 Animatable
和animatableData
属性来采用 Animatable
协议。现在,在制作动画时,矩形将遵循正弦波路径:
struct CustomView: View, Animatable {
var xoffset: CGFloat
var animatableData: CGFloat {
get { xoffset }
set { xoffset = newValue }
}
var body: some View {
Rectangle()
.fill(.green.gradient)
.frame(width: 30, height: 30)
.offset(computedOffset())
}
func computedOffset() -> CGSize {
return CGSize(width: xoffset, height: sin(xoffset/100 * .pi) * 100)
}
}
小结
我们已经看到了Animatable协议有多简单,它有多大的作用。把你的创造力用于工作,结果将是惊人的。
译自 The SwiftUI Lab 的 Advanced SwiftUI Animations – Part 3: AnimatableModifier