swift原文链接 https://swiftui-lab.com/swiftui-animations-part3/
We have seen how the Animatable protocol has helped us in animating paths and transform matrices. In this last part of the series, we will take it even further. The AnimatableModifier is the most powerful of all three. With it, we have no limits on what we can accomplish.
我们已经看到了 Animatable 协议如何帮助我们制作路径动画和转换矩阵。在本系列的最后一部分中,我们将更进一步。AnimatableModifier 是这三者中最强大的。有了它,我们可以完成的事情没有限制。
The name says it all: AnimatableModifier. It is a ViewModifier, that conforms to Animatable (your old friend from part 1). If you don’t know how Animatable and animatableData work, please go to the first part of this series and check that out first.
这个名字说明了一切:AnimatableModifier。它是一个 ViewModifier,符合 Animatable(第 1 部分中的老朋友)。如果您不知道 Animatable 和 animatableData 的工作原理,请转到本系列的第一部分并先查看。
The View protocol can now conform to Animatable, and that has deprecated AnimatableModifier. However most of the content of this article also applies to Animatable Views. At the end of the article I’ll show you have to write an Animatable view. It is basically the same as AnimatableModifier, but simpler!
View 协议现在可以符合 Animatable,并且已弃用 AnimatableModifier。但是,本文的大部分内容也适用于可动画视图。在本文的最后,我将展示您必须编写一个可动画视图。它与 AnimatableModifier 基本相同,但更简单!
Ok, so let’s pause here and think what it means to have an animatable modifier… You probably think it is too good to be true. Can I really modify my view multiple times through an animation? The answer is simple: yes, you can.
好的,让我们在这里暂停一下,想想拥有一个可动画的修改器意味着什么......你可能认为这好得令人难以置信。我真的可以通过动画多次修改我的视图吗?答案很简单:是的,你可以。
The complete sample code for this article can be found at:
本文的完整示例代码可在以下位置找到:
gist.github.com/swif...
Example8 requires images from an Asset catalog. Download it from here:
Example8 需要资产目录中的图像。从这里下载:
swiftui-lab.com/?smd...
AnimatableModifier Does Not Animate! Why? AnimatableModifier 不制作动画!为什么?
If you are planning on using AnimatableModifier in production code, make sure you read the final section: Dancing with Versions.
如果您打算在生产代码中使用 AnimatableModifier,请务必阅读最后一节:与版本共舞。
If you tried the protocol yourself, chances are, you probably hit a wall at first. I certainly did. For my first try, I wrote a very simple animatable modifier, and yet, the view did not animate. I tried a couple more, and nothing happened. Since we were in early beta stages I thought the feature just wasn’t there and abandoned it altogether. Luckily, I persevered later. Let me stress the word: “luckily”. It turns out, my first modifier was perfectly fine, but animatable modifiers DO NOT work inside containers. It just so happened that the second time I tried, my view was not inside a container. If I weren’t so lucky, you wouldn’t be reading this third article.
如果你自己尝试过这个协议,很有可能,你一开始可能会碰壁。我当然做到了。在我的第一次尝试中,我编写了一个非常简单的可动画修改器,但是,视图没有动画。我又试了几次,什么也没发生。由于我们处于早期测试阶段,我认为该功能不存在并完全放弃了它。幸运的是,我后来坚持了下来。请允许我强调“幸运”一词。事实证明,我的第一个修改器完全没问题,但是可动画的修改器在容器内不起作用。碰巧的是,当我第二次尝试时,我的视野不在容器内。如果我不是那么幸运,你就不会读到这第三篇文章了。
For example, the following modifier will animate fine:
例如,以下修饰符可以正常进行动画处理:
MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))
But the same modifier, inside a VStack will not:
但是在 VStack 中,相同的修饰符不会:
VStack {
MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))
}
Ok, so I hear you saying: but you promised me gold! And gold you should get. Until this big issue gets resolved, there’s a way of making our animatable modifiers work inside a VStack. We just need one more dirty trick in our bag:
好吧,所以我听到你说:但你答应给我金子!你应该得到黄金。在这个大问题得到解决之前,有一种方法可以让我们的可动画修改器在 VStack 中工作。我们只需要一个肮脏的伎俩:
VStack {
Color.clear.overlay(MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))).frame(width: 100, height: 100)
}
We are basically using a transparent view to occupy the space of our actual view, which will be placed above it, using .overlay()
. The only inconvenience, is we need to know how big the actual view is, so we can set the frame of the transparent view behind it. It can be tricky sometimes, but tricks we have to spare. We’ll see in the examples below.
我们基本上是使用透明视图来占据实际视图的空间,该视图将放置在其上方,使用 .overlay()
.唯一的不便之处是,我们需要知道实际视图有多大,这样我们才能将透明视图的框架设置在它后面。有时这可能很棘手,但我们必须保留技巧。我们将在下面的示例中看到。
I reported this to Apple, check FB code here. I encourage you to do the same.
我向Apple报告了此事,请在此处查看FB代码。我鼓励你也这样做。
Animating Text 对文本进行动画处理
Our first goal is to make some text to follow the animation. For this example, we are going to create a loading indicator. It will be a ring with a label:
我们的第一个目标是制作一些文本来跟随动画。在这个例子中,我们将创建一个加载指示器。它将是一个带有标签的戒指:
Our first instinct may be to use an animatable path. However, that will not let us animate the label. Instead we are going to use an AnimatableModifier:
我们的第一反应可能是使用可动画的路径。但是,这不会让我们为标签制作动画。相反,我们将使用 AnimatableModifier:
The full code is available as Example10, in the gist file linked at the top of this page.
完整代码以 Example10 的形式提供,位于本页顶部链接的 gist 文件中。
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)
}
}
}
As you can see in the example, we did not make the ArcShape animatable. It is not necessary, because the modifier is already creating the shape multiple times, with different pct values.
正如您在示例中看到的,我们没有使 ArcShape 可进行动画处理。这不是必需的,因为修改器已经多次创建具有不同 pct 值的形状。
Animating Gradients 对渐变进行动画处理
If you ever tried to animate a gradient, you probably found out that there are limitations. For example, you can animate starting and ending points, but you cannot animate the gradient colors. Here’s where we can also benefit from AnimatableModifier:
如果您曾经尝试过为渐变制作动画,您可能会发现存在局限性。例如,可以对起点和终点进行动画处理,但不能对渐变颜色进行动画处理。在这里,我们也可以从 AnimatableModifier 中受益:
This implementation is rather basic, but it is a good starting point if you later need something more elaborate. To interpolate the intermediate colors, we simply calculate an average of its RGB values. Also note that the the modifier assumes the input color arrays (from and to) both contain the same number of colors.
这个实现是相当基本的,但如果你以后需要更精细的东西,它是一个很好的起点。为了插值中间颜色,我们只需计算其RGB值的平均值。另请注意,修饰符假定输入颜色数组(from 和 to)都包含相同数量的颜色。
The full code is available as Example11, in the gist file linked at the top of this page.
完整代码以 Example11 的形式提供,位于本页顶部链接的 gist 文件中。
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..<from.count {
gColors.append(colorMixer(c1: from[i], c2: to[i], pct: pct))
}
return RoundedRectangle(cornerRadius: 15)
.fill(LinearGradient(gradient: Gradient(colors: gColors),
startPoint: UnitPoint(x: 0, y: 0),
endPoint: UnitPoint(x: 1, y: 1)))
.frame(width: 200, height: 200)
}
// 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))
}
}
More Text Animation 更多文本动画
On our next example, we will animate text again. In this case, however, we’ll do it progressively: one character at a time:
在下一个示例中,我们将再次对文本进行动画处理。然而,在这种情况下,我们将逐步进行:一次一个字符:
The smooth progressive scaling requires a little math, but the result is worth the effort. The full code is available as Example12, in the gist file linked at the top of this page.
平滑的渐进式缩放需要一点数学运算,但结果是值得的。完整代码以 Example12 的形式提供,位于本页顶部链接的 gist 文件中。
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 }
}
Getting Creative 发挥创意
Before we knew anything about the AnimatableModifier, the following example might have seem impossible to achieve. Our next challenge is to create a counter:
在我们了解 AnimatableModifier 之前,以下示例似乎不可能实现。我们的下一个挑战是创建一个计数器:
The trick of this exercise, is using 5 Text views for each digit and move them up and down, with a .spring() animation. We also need to use a .clipShape() modifier, to hide the part that draws outside the border. To understand better how it works, you may comment the .clipShape() and slow down the animation considerably. The full code is available as Example13, in the gist file linked at the top of this page.
本练习的诀窍是为每个数字使用 5 个文本视图,并使用 .spring() 动画上下移动它们。我们还需要使用 .clipShape() 修饰符来隐藏在边框外绘制的部分。为了更好地理解它是如何工作的,您可以注释 .clipShape() 并大大减慢动画速度。完整代码以 Example13 的形式提供,位于本页顶部链接的 gist 文件中。
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
}
}
}
Animating Text Color 对文本颜色进行动画处理
If you ever tried to animate .foregroundColor(), you may have noticed it works nicely, except when the view is of type Text. I don’t know if it is a bug, or missing functionality. Nevertheless, should you need to animate the color of text, you can do so with an AnimatableModifier like the one below. The full code is available as Example14, in the gist file linked at the top of this page.
如果您曾经尝试过对 .foregroundColor() 进行动画处理,您可能已经注意到它运行良好,除非视图的类型为 Text。我不知道这是一个错误,还是缺少功能。不过,如果您需要对文本的颜色进行动画处理,则可以使用如下所示的 AnimatableModifier 来实现。完整代码以 Example14 的形式提供,位于本页顶部链接的 gist 文件中。
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))
}
}
}
Dancing With Versions 与版本共舞
We’ve seen that AnimatableModifier is very powerful… but also, a little buggy. The biggest issue is that under certain combinations of Xcode and iOS/macOS versions, the application will simply crash at launch. What’s even worst, that normally happens when deploying the app, but not when compiling and running with Xcode during normal development. You may spend a lot of time developing and debugging, thinking all is good with the world, but then deploy, and you get something like this:
我们已经看到 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
For example, if the app is deployed with Xcode 11.3, and executed on macOS 10.15.0 it will fail to launch with the “Symbol not found” error. However, running the same executable on 10.15.1 works fine.
例如,如果应用使用 Xcode 11.3 部署,并在 macOS 10.15.0 上执行,则它将无法启动,并显示“找不到符号”错误。但是,在 10.15.1 上运行相同的可执行文件可以正常工作。
On the contrary, if we deploy with Xcode 11.1, it works fine with all macOS versions (at least the ones I tried).
相反,如果我们使用 Xcode 11.1 进行部署,它适用于所有 macOS 版本(至少是我尝试过的版本)。
Something similar happens with iOS. An app that uses AnimatableModifier, deployed with Xcode 11.2 will fail to launch on iOS 13.2.2 but will work fine on iOS 13.2.3.
iOS 也会发生类似的事情。使用 AnimatableModifier 的应用(通过 Xcode 11.2 部署)将无法在 iOS 13.2.2 上启动,但在 iOS 13.2.3 上可以正常工作。
For the time being, I’ll keep using Xcode 11.1 for my macOS projects that need AnimatableModifier. In the future, I will probably use a newer version of Xcode, but increase the app requirement to macOS 10.15.1 (unless the problem gets fixed, which I seriously doubt).
目前,我将继续将 Xcode 11.1 用于需要 AnimatableModifier 的 macOS 项目。将来,我可能会使用较新版本的 Xcode,但将应用程序要求提高到 macOS 10.15.1(除非问题得到解决,我对此表示严重怀疑)。
Animatable View 可动画视图
Since iOS15 and macOS12, the View protocol can adopt the Animatable protocol. This makes the use of AnimatableModifier unnecessary. If you know how to use AnimatableModifier, with this quick example you’ll see how easy it is to update your code:
从 iOS15 和 macOS12 开始,View 协议可以采用 Animatable 协议。这使得不需要使用 AnimatableModifier。如果您知道如何使用 AnimatableModifier,那么通过这个快速示例,您将了解更新代码是多么容易:
Consider this view: 请考虑以下视图:
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)
}
}
This animation will move the rectangle from left to right in a straight line. Although the vertical offset is calculated as sin(xoffset/100 * .pi) * 100 for x = -100.0, y is 0.0 and for x = 100.0, y is 0.0. As far as SwiftUI is concern there’s is nothing to animate in the vertical axis, because y is 0.0 at the start and end of the animation.
此动画将在一条直线上从左向右移动矩形。尽管垂直偏移量的计算公式为 sin(xoffset/100 * .pi) * 100,但 x = -100.0 时,y 为 0.0,x = 100.0 时,y 为 0.0。就 SwiftUI 而言,它们在垂直轴上没有任何动画效果,因为在动画的开始和结束时 y 是 0.0。
If we want the body of the view to be recomputed for every frame of the animation, then we adopt the Animatable protocol, by adding Animatable
, and the animatableData
property. Now the rectangle will follow a sin wave path when animating:
如果我们希望为动画的每一帧重新计算视图的主体,那么我们采用 Animatable 协议,通过添加 Animatable
和 animatableData
属性。现在,矩形在制作动画时将遵循正弦波路径:
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)
}
}
Summary What’s Next 总结 下一步
We’ve seen how simple the Animatable protocol is and how much it has to offer. Put your creativity to work, and the results will be spectacular.
我们已经看到了 Animatable 协议是多么简单,以及它必须提供多少。发挥你的创造力,结果将是惊人的。
This concludes the “Advanced SwiftUI Animations” series. Soon I will be posting an article on custom Transitions, which will complement nicely with this series. Make sure you follow me on twitter, if you want to be notified when new articles are published. The link is below. Until next time.
“高级 SwiftUI 动画”系列到此结束。很快,我将发布一篇关于自定义过渡的文章,这将与本系列很好地互补。如果您想在新文章发布时收到通知,请务必在 twitter 上关注我。链接如下。下次再见。