在前两篇文章中,我们已经讲解了如何使用Animatable
和GeometryEffect
来实现一些比较复杂的动画,其基本原理,是根据animatableData
来自由控制形变。
这篇文章中,我们将带来更为强大的一个工具AnimatableModifier,它之所以强大,是因为它不仅实现了Animatable协议,还实现了ViewModifier协议,因此,我们能够利用这两个协议的优势。
基于ViewModifier
,我们可以直接返回some View
,这让我们能够不断的往原来的view上增加新的view。代码操作起来也更加灵活。
1. Animating Text
小试牛刀,我们看上边的gif图,这仍然是一个percent动画,根据percent(0~1),我们画一个路径,当然,该路径我们使用了path.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 20))
.
其中dashPhase
表示虚线开始的点,这里不多做解释,一个小技巧,利用这个值我们可以做一些有意思的动画:
struct ContentView: View { @State private var phase: CGFloat = 0 var body: some View { Rectangle() .strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10], dashPhase: phase)) .frame(width: 200, height: 200) .onAppear { self.phase -= 20 } .animation(Animation.linear.repeatForever(autoreverses: false)) } }
效果如下:
回到正文,大家仔细思考,我们如果用GeometryEffect
也能实现最上边进度的问题,但是我们无法显示30%,40%这样的问题。看看实现代码:
struct Example10: View { @State private var pct: CGFloat = 0 var body: some View { VStack { Spacer() Indicator(pct: pct) Spacer() HStack(spacing: 10) { MyButton(label: "20%", font: .subheadline) { withAnimation(.easeInOut(duration: 1.0)) { self.pct = 0.2 } } MyButton(label: "60%", font: .subheadline) { withAnimation(.easeInOut(duration: 1.0)) { self.pct = 0.6 } } MyButton(label: "100%", font: .subheadline) { withAnimation(.easeInOut(duration: 1.0)) { self.pct = 1.0 } } } Spacer() } } } struct Indicator: View { var pct: CGFloat var body: some View { Circle() .fill(LinearGradient(gradient: .init(colors: [.green, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 150, height: 150) .modifier(IndicatorModirier(pct: pct)) } } struct IndicatorModirier: 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(.orange)) .overlay(LabelView(pct: pct)) } struct ArcShape: Shape { var pct: CGFloat func path(in rect: CGRect) -> Path { var path = Path() path.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(Double(pct) * 360), clockwise: false) return path.strokedPath(.init(lineWidth: 10, dash: [6, 3, 20, 3], dashPhase: 0)) } } struct LabelView: View { var pct: CGFloat var body: some View { Text("\(pct * 100, specifier: "%.0f") %") .font(.largeTitle) .bold() .foregroundColor(.white) } } }
其中,最核心的代码是下边这几行,有了content,你就可以做出你想要的任何动画样式。
func body(content: Content) -> some View { content .overlay(ArcShape(pct: pct).foregroundColor(.orange)) .overlay(LabelView(pct: pct)) }
2. Animating Gradients
当我们平时需要对Gradient进行动画时会有一些限制,比如,我们可以对start point和end point进行动画,但是却无法对颜色进行动画。
但有了AnimatableModifier,就变得简单很多,我们先看把最终效果做进一步的拆分:
然后我们定义进度,我们仍然使用percent,它的值为0,0.1, 0.2 … 1,这个值是系统根据动画需要自动计算的。
大家想一想,这个矩形区域上有很多像素,我们不可能把每一个像素对应于percent的变化都计算出来,每一次percent变化,我们只需要应用一个LinearGradient
就可以了:
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: .init(colors: gColors), startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 300, height: 300) }
从上边的代码可以看出,我们修改了gColors
,而这个gColors
是通过下边的代码计算出来的:
func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color { let rgbSpace = CGColorSpaceCreateDeviceRGB() guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else { return Color(c1) } guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else { return Color(c2) } 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)) }
注意,类似于black,white这样的颜色,它的rgb颜色空间只有两个值,分别表示基于white的值和透明度,其他的颜色的rgb空间有4个值。
说白了,就是根据percent混合from和to的颜色,在本例中,from混合的颜色是green
和yellow
,to混合的颜色是blue
和red
。
完整代码如下:
struct Example11: View { @State private var animate = false var body: some View { let gradient1: [UIColor] = [.green, .blue] let gradient2: [UIColor] = [.yellow, .red] return VStack { Spacer() RoundedRectangle(cornerRadius: 15) .frame(width: 300, height: 300) .modifier(GradientAnimatabelModifier(from: gradient1, to: gradient2, pct: animate ? 1 : 0)) Spacer() Button("颜色过渡") { withAnimation(.easeInOut(duration: 1.0)) { self.animate.toggle() } } Spacer() } } } struct GradientAnimatabelModifier: AnimatableModifier { let from: [UIColor] let to: [UIColor] var pct: CGFloat 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: .init(colors: gColors), startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 300, height: 300) } func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color { let rgbSpace = CGColorSpaceCreateDeviceRGB() guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else { return Color(c1) } guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else { return Color(c2) } 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)) } }
3. More Text Animation
这一小节,主要讲解如何实现下边的动画:
很明显,动画是针对字符串中的字符来执行的,基本原理是随着percent的变化,字符的scale随之变化。
因此我们的问题变为如何根据percent计算相应位置的字符的scale?
我们设置一个具有一定宽度的区域,通过计算该区域的字符的位置来计算相应的scale,核心代码如下:
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 }
关于上边的代码,做几点说明:
chunk
表示scale范围的大小,其中waveWidth
表示scale的字符数,在本代码中的值为6- pct的取值范围是0~1,x的值通过
n/total
计算出来,因此x的取值范围为0..<1
offset
指的是pct点右便的距离,它动态变化lowerLimit
和upperLimit
是scale范围的上下限angle
表示角度,它的取值范围是-90~-450,因此(sin(angle.rad) + 1) / 2
计算的结果范围是0~1
scale范围计算的示意图:
反映到上图中angle
的取值范围为B点到A点,因此计算sin(angle.rad)
的取值范围正好是-1~1,这里边比较巧妙的是B->C是上升过程,C->A是下降过程,正好符合波形。
大家仔细思考就能够想明白其中奥妙,完整代码如下:
extension Double { var rad: Double { return self * .pi / 180 } var deg: Double { return self * 180 / .pi } } struct Example12: View { @State private var flag = false var body: some View { VStack { Spacer() Color.clear.overlay(WaveText("The SwiftUI Lab", waveWidth: 6, pct: flag ? 1.0 : 0.0).foregroundColor(.blue)).frame(height: 40) Color.clear.overlay(WaveText("swiftui-lab.com", waveWidth: 6, pct: flag ? 0.0 : 1.0, size: 18).foregroundColor(.green)).frame(height: 30) Spacer() }.onAppear { withAnimation(Animation.easeInOut(duration: 2.0).repeatForever()) { self.flag.toggle() } }.navigationBarTitle("Example 12") } } struct WaveText: View { let text: String let pct: Double let waveWidth: Int var size: CGFloat init(_ text: String, waveWidth: Int, pct: Double, size: CGFloat = 34) { self.text = text self.waveWidth = waveWidth self.pct = pct self.size = size } var body: some View { Text(text).foregroundColor(Color.clear).modifier(WaveTextModifier(text: text, waveWidth: waveWidth, pct: pct, size: size)) } 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 } } }
4. Getting Creative
类似于这样的counter,看上去实现起来会非常麻烦,但是用AnimatableModifier
实现起来就非常easy。记住一点,AnimatableModifier
最牛逼的地方在于,能够让我们处理某一个时间点的状态,就好象把一系列的变化定格在某一时刻,我们只关心那一时刻的样式。
这个Counter最核心的想法就是分别计算十位数和个位数在某个数值时的offset。
举个例子,当数字为21时,他的offset为:
可能大家不理解,按理说21正好是整数,offset应该为0啊。其实你想的也没错,但是作者这里的代码是以n+1为基准的,21 + 1 是22, 正好offset为1.
再看一个21.3的例子
21 + 1 = 22 ,显示了22的offset为0.7,再看一个21.8的例子:
相信大家已经明白这个offset是什么意思了,上边演示的是个位数的offset,十位数的offset同理。完整代码如下:
struct Example13: View { @State private var number: Double = 21 var body: some View { VStack { Spacer() MovingCounter(number: number) Spacer() HStack { MyButton(label: "35", font: .headline) { withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) { self.number = 35 } } MyButton(label: "44", font: .headline) { withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) { self.number = 44 } } MyButton(label: "87", font: .headline) { withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) { self.number = 87 } } } Spacer() } } } struct MovingCounter: View { var number: Double var body: some View { Text("00") .modifier(CounterAnimatableModifier(number: number)) } struct CounterAnimatableModifier: AnimatableModifier { var number: Double var animatableData: Double { get { number } set { number = newValue } } func body(content: Content) -> some View { let n = self.number + 1 let uoffset = getOffsetForUnitDigit(n) let toffset = getOffsetForTenDigit(n) let u = [n - 2, n - 1, n, n + 1, n + 2].map{ getUnitDigit($0) } let x = getTenDigit(n) var t = [abs(x - 2), abs(x - 1), abs(x), 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(CounterShap()) .overlay(CounterBorder()) .background(CounterBackground()) } func getUnitDigit(_ number: Double) -> Int { return abs(Int(number) - (Int(number) / 10) * 10) } func getTenDigit(_ number: Double) -> Int { return abs(Int(number) / 10) } func getOffsetForUnitDigit(_ number: Double) -> CGFloat { return 1 - CGFloat(number - Double(Int(number))) } func getOffsetForTenDigit(_ number: Double) -> CGFloat { if getUnitDigit(number) == 0 { return 1 - CGFloat(number - Double(Int(number))) } return 0 } } struct ShiftEffect : GeometryEffect { var pct: CGFloat = 1.0 func effectValue(size: CGSize) -> ProjectionTransform { ProjectionTransform(CGAffineTransform(translationX: 0, y: size.height / 5.0 * pct)) } } struct CounterShap: Shape { func path(in rect: CGRect) -> Path { var path = Path() let h = rect.height / 5.0 + 30 let r = CGRect(x: 0, y: (rect.height - h) * 0.5, width: rect.width, height: h) path.addRoundedRect(in: r, cornerSize: CGSize(width: 5.0, height: 5.0)) return path } } struct CounterBorder: View { var body: some View { GeometryReader { proxy in RoundedRectangle(cornerRadius: 5.0) .stroke(lineWidth: 5) .foregroundColor(.blue) .frame(width: 80, height: proxy.size.height / 5.0 + 30) } } } struct CounterBackground: View { var body: some View { GeometryReader { proxy in RoundedRectangle(cornerRadius: 5.0) .fill(Color.black) .frame(width: 80, height: proxy.size.height / 5.0 + 30) } } } }
5. Animating Text Color
如果我们可以对某个View的foregroundColor
执行颜色的渐变动画,但是,当把这个动画放大文本上的时候,就不好使了,利用AnimatableModifier
可以轻松实现,这个动画的实现实在是太简单了,我们就不做更多的解释了,直接上代码:
struct Example15: View { @State private var flag = false var body: some View { VStack { AnimatableColorText(from: .systemRed, to: .systemBlue, pct: flag ? 1 : 0) { Text("我是一个好人").font(.largeTitle).bold() } } .onTapGesture { withAnimation(.easeInOut(duration: 1.0)) { self.flag.toggle() } } } } 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(.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)) } func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color { let rgbSpace = CGColorSpaceCreateDeviceRGB() guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else { return Color(c1) } guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else { return Color(c2) } 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
的强大之处在于他即遵守了Animatable
协议,又是一个ViewModifier
,因此我们可以根据animatableData
来返回一个View。这就像把一段连续的动画,打散成一张张的图片。
注:上边的内容参考了网站https://swiftui-lab.com/swiftui-animations-part3/