高级 SwiftUI 动画 — Part 3:AnimatableModifier

前言

之前的两篇文章animating pathstransform matrices 对 Animatable 协议使用做了介绍,今天这篇文章将为大家介绍 AnimatableModifier,使用它可以完成更多的动画工作。

AnimatableModifier 是一个 ViewModifier,符合 Animatable 协议,如果对这个协议不了解可以阅读之前发布的两篇文章。

AnimatableModifier 无法实现动画

如果是第一次使用 AnimatableModifier,可能会遇到问题。写一个简单的动画,但是没有动画效果。 我又试了几次,也没有成功。因此我认为该功能不存并且放弃使用。幸运的是,后来我坚持了下来。事实证明,我的第一个 modifier 非常好,但是 animatable modifiers 在容器中不起作用。 我在第二次尝试时,动画视图不在容器内。

例如,以下 modifier 可以成功实现动画:

1
MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))

但是相同的代码,在 VStack 中就没有动画了:

1
2
3
VStack {
MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))
}

这个问题在官方解决之前,经过尝试,可以在 VStack 中改成下面的代码,就可以实现动画:

1
2
3
VStack {
Color.clear.overlay(MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))).frame(width: 100, height: 100)
}

这样写是使用一个透明视图占据实际视图空间,动画被放在透明视图上,使用 .overlay()。有点不方便的是,我们需要知道实际视图有多大,所以我们可以在它后面设置透明视图的框架。在下面的示例中可以开到实现代码。

动画文本

首先需要制作一些文字动画。对于这个例子,我们将创建一个进度加载指示器。

可能很多人都认为应该使用动画路径实现。但是,内部标签就无法设置动画,使用 AnimatableModifier 可以实现。

完整的代码作为 示例10 在文末链接中。关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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 animatable。 因为 modifier 已经多次创建形状,具有不同的 pct 值。

动画渐变

在实现渐变动画时,可能会遇到一些限制。比如,可以为起点和终点设置动画,但是不能为渐变颜色设置动画。使用 AnimatableModifier 可以避免出现这种情况。

很容易就可以实现这个功能,在这个基础上可以实现更多复杂的动画。如果需要插入中间颜色,我们只需要计算 RGB 值的平均值。另外需要注意,modifier 假设输入颜色数组都包含相同数量的颜色。

完整的代码作为 示例11 在文末链接中。关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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))
}
}

更多文本动画

这个示例中,将再次实现一个文本动画。但是是逐步进行,一次放大一个字符

完整的代码作为 示例12 在文末链接中。关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 的形式提供。

这个动画实现的主要内容是每个数字使用 5 个文本视图,并使用 .spring() 动画上下移动它们。然后使用 .clipShape() 修饰符来隐藏边框之外区域。如果想跟清晰的理解他们是如何实现的,可以通过 .clipShape() 让动画速度变慢。

完整的代码作为 示例13 在文末链接中。关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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() 为动画添加颜色,但是在文本类动画中使用没有效果,不知道是缺少什么配置还是什么原因。我通过下面的方法实现给文本动画添加颜色。

完整的代码作为 示例14 在文末链接中。关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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 某些版本中,App 在启动时会崩溃。而且是在部署时,正常开发编译中是不会发生这种情况。

1
2
3
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

例如,如果 App 在 Xcode 11.3 上部署并在 macOS 10.15.0 上执行,就会出现 “Symbol not found” 错误。然而,在 macOS 10.15.1 上运行相同的可执行文件可以正常工作。

译自 The SwiftUI LabAdvanced SwiftUI Animations – Part 3: AnimatableModifier

本文的完整示例代码可在以下位置找到:

https://gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798

示例8 需要的图片资源。从这里下载:

https://swiftui-lab.com/?smd_process_download=1&download_id=916

-------------本文结束感谢您的阅读-------------

本文标题:高级 SwiftUI 动画 — Part 3:AnimatableModifier

文章作者:Swift社区

发布时间:2022年04月06日 - 10:04

最后更新:2022年04月06日 - 10:04

原始链接:https://fanbaoying.github.io/高级-SwiftUI-动画-—-Part-3:AnimatableModifier/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!