我是靠谱客的博主 满意芝麻,这篇文章主要介绍animation动画不生效_SwiftUI动画(1)之Animatable,现在分享给大家,希望可以做个参考。

2f1e18350f50ab55b986ff460e4876ca.png

相信大家都已经对SwiftUI有了基本的了解,在SwiftUI写动画,相对来说变得更加简单了,接下来,会用3篇文章,带领大家一览SwiftUI动画的魅力。

1. 显式和隐式动画

在SwiftUI中有两种类型的动画,显式和隐式。

隐式动画指的就是用animation()modifier的view,当该view的可动画的参数变化的时候,系统会自动进行动画,这些所谓的可动画的参数包括size,offsetcolorscale等等。

显式动画指的是withAnimation { ... }闭包中指定的参数,所有依赖这些参数的view,都会执行动画。

我们先看个例子,下边的动画使用了隐式动画:

edfc1a82f3aefb20954416585b26df07.gif

代码如下:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Example1: View { @State private var half = false @State private var dim = false var body: some View { Image("tower") .scaleEffect(half ? 0.5 : 1.0) .opacity(dim ? 0.2 : 1.0) .animation(.easeInOut(duration: 1.0)) .onTapGesture { self.dim.toggle() self.half.toggle() } } }

从上边的代码中,我们可以看出动画依赖half,dim这2个参数,我们并没有直接告诉view这2个参数要动画,系统会自动把旧值到新值的变化做动画。

我们把代码做一点简单的改变:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Example2: View { @State private var half = false @State private var dim = false var body: some View { Image("tower") .scaleEffect(half ? 0.5 : 1.0) .opacity(dim ? 0.5 : 1.0) .onTapGesture { self.half.toggle() withAnimation(.easeInOut(duration: 1.0)) { self.dim.toggle() } } } }

我们去掉了.animation(.easeInOut(duration: 1.0)),新增了withAnimation闭包,我们把self.dim.toggle()放到闭包中,这就是显式的告诉系统,view的透明度要执行xxx动画,所有依赖dim参数的view,在dim改变的时候,都会执行动画,效果如下:

f954984ffcca2c96c4a63aa8061297bd.gif

仔细看上图的动画过程,就会发现,只有透明度指定了动画,缩放并没有执行动画,这就说明,我们显式的告诉系统dim需要动画,它就只为dim执行动画,非常的听话。

此时此刻,我有一个问题,我用隐式动画如何实现上边这种动画呢?也非常简单,先看代码:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Example2: View { @State private var half = false @State private var dim = false var body: some View { Image("tower") .opacity(dim ? 0.2 : 1.0) .animation(.easeInOut(duration: 1.0)) .scaleEffect(half ? 0.5 : 1.0) .onTapGesture { self.dim.toggle() self.half.toggle() } } }

animationmodifier作用于view时,他的顺序时很重要的,在上边的代码中,它只对它前边的内容生效,当然这个顺序我们其实时可以任意调整的,我们要想使用隐式动画禁用某些动画时,只需要.animation(nil)就行了。

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Example2: View { @State private var half = false @State private var dim = false var body: some View { Image("tower") .opacity(dim ? 0.2 : 1.0) .animation(nil) .scaleEffect(half ? 0.5 : 1.0) .animation(.easeInOut(duration: 1.0)) .onTapGesture { self.dim.toggle() self.half.toggle() } } }

2.How Do Animations Work

SwiftUI动画背后的原理在于Animatable协议,它要求我们实现一个计算属性animatableData,该属性遵守VectorArithmetic协议,VectorArithmetic的目的是让系统可以在需要变化的动画数据中间插入很多值,这些值的计算依赖动画的时间函数。

本质上,在SwiftUI中执行动画,就是系统渲染View很多次,每一次渲染,都改变一点点参数,当然,这个参数指的是需要动画的原值到终值。

举个例子,如果我们线性的把透明度从0.3改成0.8,由于0.3是Double类型,实现了VectorArithmetic协议,因此系统可以在0.3到0.8之间插入很对中间的值,这些值的计算依赖时间函数和动画时长。在本例中,它是线性的,系统在插值的时候的算法类似于下边的代码:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let from:Double = 0.3 let to:Double = 0.8 for i in 0..<6 { let pct = Double(i) / 5 var difference = to - from difference.scale(by: pct) let currentOpacity = from + difference print("currentOpacity = (currentOpacity)") } currentOpacity = 0.3 currentOpacity = 0.4 currentOpacity = 0.5 currentOpacity = 0.6 currentOpacity = 0.7 currentOpacity = 0.8

本质上,系统会为这些插入的值,都生成一个View,在duration的时间内把这些Views,播放出来,这就是我们看到的动画效果。

关于时间函数,我们用下边的这张图来举例, 这是一个图片的scale,也就是缩放效果,可以看到,不同的函数下,系统插入的值不同,根据插入值计算缩放后的图片也是不同的。

d85052432b90cdbfedc6bdf4b9df6d77.png

3. Why Do I Care About Animatable?

那么为什么我们需要如此关注Animatable这个协议呢? 像opacity,scale,这些系统自动会执行动画,完全不需要我们关心。

是的,像这些基本的效果,系统是知道该如何做动画的,但在平时的开发中,我们要做的动画往往不是这么简单,比如说,path的变换,渐变色的切换等等,这些例子会在后续的文章中都介绍到,其最核心的思想就是animatableData。大家继续阅读就是了。

4. Animating Shape Paths

这一小节,我们要做的事情就是利用Animatable来实现正多边形的绘制,类似下边这样:

d918925b3deb7fad8d107c7c068df785.png

上图中,只展示了正三边形和正四边形的例子,我们马上就会把它扩展到随意n边形。

在开始撸码之前,我们先简单介绍下实现该功能需要的一点三角函数的知识,我不会在这里做详细的介绍,更详细的可以点击这里。

有一个基本定理,在一个圆中,我们可以画出任何n正边形,这一点很重要,在画正边形之前,我们需要先确定该正边形外圆的半径,如下图:

de8b108c0b7b3efd1c0ffe9d047192d3.png

有了这个基本概念后,我们就可以动手来实现了:

f89f7ab38d14e14d2a4afb4af027a9af.png
  • 圆点的位置我们已经知道,通常是图形的中心点
  • 半径很好计算,我们要绘制正边形的背景通常是正方形或者长方形,因此取最短边的一半作为半径比较合适
  • 正边形各个定点到圆点形成的夹角很好计算

有点圆点,夹角,半径,我们就能够确定每个定点的point,因此就能轻松画出正多边形的path。我们这些例子中的第一个顶点在圆心的正右方,并不是上图中对应的位置。

我们把上边的思想写成代码,如下:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct PolygonShape: Shape { var sides: Int func path(in rect: CGRect) -> Path { // hypotenuse let h = Double(min(rect.size.width, rect.size.height)) / 2.0 // center let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0) var path = Path() for i in 0..<sides { let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180 // Calculate vertex position let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h)) if i == 0 { path.move(to: pt) // move to first vertex } else { path.addLine(to: pt) // draw line to next vertex } } path.closeSubpath() return path } }

现在大家应该能够清楚的理解上边代码的实现方式了吧?用起来也很简单:

复制代码
1
2
3
PolygonShape(sides: isSquare ? 4 : 3) .stroke(Color.blue, lineWidth: 3) .animation(.easeInOut(duration: duration))

当我们改变siders的时候,你以为这么简单就能指定动画了? 还是太年轻了,实际效果为:

15d797303200382b1c69dd5366325e7f.png

原因很简单,系统不知道它该如何动画?它只知道在siders改变的时候,重新绘制图形,为了解决这个问题,我们需要做2件事情:

  • 需要把Int类型的siders改成Double类型,这样才能在其值改变的时候,往中间插入很多值
  • 通过animatableData告诉系统哪些值需要插值

幸运的是,Shape已经遵守了Animatable协议,因此,代码如下:

复制代码
1
2
3
4
5
6
7
8
struct PolygonShape: Shape { var sides: Double var animatableData: Double { get { return sides } set { sides = newValue } } ... }

那么问题又来了,假设我们siders从3变为4,系统把siders分割成3.1, 3.2, 3.3... 3.9,4.0,这个时候我们应该如何根据这些数值来画路径呢?

看下核心代码:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func path(in rect: CGRect) -> Path { // hypotenuse let h = Double(min(rect.size.width, rect.size.height)) / 2.0 // center let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0) var path = Path() let extra: Int = Double(sides) != Double(Int(sides)) ? 1 : 0 for i in 0..<Int(sides) + extra { let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180 // Calculate vertex let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h)) if i == 0 { path.move(to: pt) // move to first vertex } else { path.addLine(to: pt) // draw line to next vertex } } path.closeSubpath() return path }

let extra: Int = Double(sides) != Double(Int(sides)) ? 1 : 0这行代码保证了像3.4这样大于3的数能够画出4个顶点。

for i in 0..<Int(sides) + extra这里的循环,循环多少次就会产生多少的顶点,这一点很重要。

let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180,不管siders是多少,(360.0 / Double(sides))都是相同的值,也就是说,每次遍历旋转的角度是相同的。

SwiftUI角度旋转是顺时针方向的,水平x轴为0度。我们看下边几个截图:

846bded7886173b8ef55378d4887d81a.png

上边这张图是siders等于3.2的时候,绘制的路径,我们在该图的基础上添加一些说明:

c7a9b33cc3fa41850f9f2a60bf67cbf2.png

绘图的顺序为1 > 2 > 3 > 4, 角1,角2, 角3是相同的,绘制这个图,for循环了4次,大家仔细想想,这里 角1,角2, 角3相加不等于360度是正常的。

很明显,假设当siders增大一点到3.4的时候,由于(360.0 / Double(sides))的原因,这时候角1,角2, 角3会变小一些,正好1和4之间的线段会增长一点。如下图:

37178d87f97bc98fe805e2406fbd91d4.png

好了,我们已经分析的很详细了,大家如果还有不明白的地方,可以留言。只需要增加一点点代码就能动起来了“

复制代码
1
2
3
4
5
6
7
8
9
10
struct Example1PolygonShape: Shape { var sides: Double var animatableData: Double { get { return sides } set { sides = newValue } } func path(in rect: CGRect) -> Path { ... } }

99786ca8d413fbce6dc20285101e0dc6.png

我们在上边的基础上再扩展一点东西出来,如果我想同时执行2种动画,那该如何呢? 其实非常简单。animatableData只要求set和get实现了VectorArithmetic协议的值就行,我们上边用到的Double就实现了,如果我们两同时执行2种动画,我们需要使用AnimatablePair<First, Second>.

很明显,它封装了2个参数,我们的代码就会变成这样:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
struct PolygonShape: Shape { var sides: Double var scale: Double var animatableData: AnimatablePair<Double, Double> { get { AnimatablePair(sides, scale) } set { sides = newValue.first scale = newValue.second } } ... }

绘制路径的方法也只需要改一点点就可以了,利用scale计算半径:

复制代码
1
2
3
4
func path(in rect: CGRect) -> Path { let h = Double(min(rect.size.width, rect.size.height) / 2.0) * scale ... }

如此简单,再看下效果:

8ee4273603e28e875189f1a78e44226f.png

也许你现在有了一个新的疑问,如果我们同时执行超过两个动画,应该怎么办? 答案也同样很简单,

复制代码
1
AnimatablePair<AnimatablePair<CGFloat, CGFloat>, AnimatablePair<CGFloat, CGFloat>>

基于此方法,可以引申到n个值,在系统中CGPoint,CGSize和CGRect都可以执行动画,是因为他们都实现了Animatable协议。

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
extension CGPoint : Animatable { public typealias AnimatableData = AnimatablePair<CGFloat, CGFloat> public var animatableData: CGPoint.AnimatableData } extension CGSize : Animatable { public typealias AnimatableData = AnimatablePair<CGFloat, CGFloat> public var animatableData: CGSize.AnimatableData } extension CGRect : Animatable { public typealias AnimatableData = AnimatablePair<CGPoint.AnimatableData, CGSize.AnimatableData> public var animatableData: CGRect.AnimatableData }

在这一小节的最后,我们再看一个更加酷炫的效果:

8abee833bdaaf2d63a72719abb694ed1.png

实现上边的的效果也很简单就是用上边的方法绘制完图形后,再让每个顶点分别同别的顶点连线,核心代码为函数drawVertexLines。代码如下:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func path(in rect: CGRect) -> Path { ... drawVertexLines(path: &path, vertexs: vertex, n: 0) return path } func drawVertexLines(path: inout Path, vertexs: [CGPoint], n: Int) { if vertexs.count - n < 3 { return } for i in (n+2)..<min(n + vertexs.count - 1, vertexs.count) { path.move(to: vertexs[n]) path.addLine(to: vertexs[i]) } drawVertexLines(path: &path, vertexs: vertexs, n: n+1) }

5.Making Your Own Type Animatable (with VectorArithmetic)

在上边的这些小节中,我们都使用了SwiftUI系统提供的数据结构,大部分情况下这些结构足矣,但我们还想在此基础之上,做出一些更加复杂的东西。

举个例子,我们们想使用我们自定义的struct来做动画,只要讲到动画,就离不开一个值从某一个值到另一个值的变化,我们这个例子就是时钟的一个动画,先看下最后的效果:

81d88a7116b1a24555ef67da58b7f7a6.png

要想描述某一刻的时间,我们需要3个属性,时,分,秒,因此我们需要把它们封装到一个结构体中,当需要切换时间的时候,直接在变化的两个结构体中间插值。

小提示:Angle, CGPoint, CGRect, CGSize, EdgeInsets, StrokeStyleUnitPoint,这些都实现了Animatable协议,AnimatablePair, CGFloat, Double, EmptyAnimatableDataFloat,这些都实现了VectorArithmetic协议。

我们先写ClockTime结构体,代码如下:

复制代码
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
struct ClockTime { var hours: Int var minutes: Int var seconds: Double init(_ h: Int, _ m: Int, _ s: Double) { self.hours = h self.minutes = m self.seconds = s } init(_ seconds: Double) { let hours = Int(seconds) / 3600 let minutes = (Int(seconds) - (hours * 3600)) / 60 let seconds = seconds - Double(hours * 3600) - Double(minutes * 60) self.hours = hours self.minutes = minutes self.seconds = seconds } func asSeconds() -> Double { return Double(self.hours * 3600) + Double(self.minutes * 60) + self.seconds } func asString() -> String { return String(format: "%2i", self.hours) + " : " + String(format: "%02i", self.minutes) + " : " + String(format: "%02.0f", self.seconds) } }

这里的代码非常简单,就是初始化和一些函数,大家应该能够理解,如果要对ClockTime做加减法,其实都是对两个时间的总秒数做加减法。

我们让ClockTime实现VectorArithmetic协议:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
extension ClockTime: VectorArithmetic { static func - (lhs: ClockTime, rhs: ClockTime) -> ClockTime { return ClockTime(lhs.asSeconds() - rhs.asSeconds()) } static func + (lhs: ClockTime, rhs: ClockTime) -> ClockTime { return ClockTime(lhs.asSeconds() + rhs.asSeconds()) } mutating func scale(by rhs: Double) { var s = Double(self.asSeconds()) s.scale(by: rhs) let time = ClockTime(s) self.hours = time.hours self.minutes = time.minutes self.seconds = time.seconds } var magnitudeSquared: Double { 1 } static var zero: ClockTime { ClockTime(0, 0, 0) } }

其实,类似上边的代码,基本上算是固定写法,但可以发现一些新的想法,SwiftUI系统内部在做插值的时候,会用到VectorArithmetic协议中的方法。

关于图形绘制方面,还是上边的那一套,代码如下:

复制代码
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
struct ClockShape: Shape { var time: ClockTime var animatableData: ClockTime { get { time } set { time = newValue } } func path(in rect: CGRect) -> Path { var path = Path() let radius = min(rect.size.width / 2.0, rect.size.height / 2.0) let center = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0) let hHypotenuse = Double(radius) * 0.5 let mHypotenuse = Double(radius) * 0.6 let sHypotenuse = Double(radius) * 0.8 let hAngle: Angle = .degrees(Double(time.hours) / 12 * 360 - 90) let mAngle: Angle = .degrees(Double(time.minutes) / 60 * 360 - 90) let sAngle: Angle = .degrees(Double(time.seconds) / 60 * 360 - 90) let hoursNeedle = CGPoint(x: center.x + CGFloat(hHypotenuse * cos(hAngle.radians)), y: center.y + CGFloat(hHypotenuse * sin(hAngle.radians))) let minutesNeedle = CGPoint(x: center.x + CGFloat(mHypotenuse * cos(mAngle.radians)), y: center.y + CGFloat(mHypotenuse * sin(mAngle.radians))) let secondsNeedle = CGPoint(x: center.x + CGFloat(sHypotenuse * cos(sAngle.radians)), y: center.y + CGFloat(sHypotenuse * sin(sAngle.radians))) /// 画圆 path.addArc(center: center, radius: radius, startAngle: .degrees(0), endAngle: .degrees(360), clockwise: true) /// 表盘刻度 let numberLength: CGFloat = 5.0 let numberPadding: CGFloat = 12.0 let centerToNumber: CGFloat = radius - numberLength - numberPadding for i in 0..<12 { let angle: Angle = .degrees(360.0 / 12.0 * Double(i)) let inPt = CGPoint(x: center.x + centerToNumber * CGFloat(cos(angle.radians)), y: center.y - centerToNumber * CGFloat(sin(angle.radians))) let outPt = CGPoint(x: center.x + (centerToNumber + numberLength) * CGFloat(cos(angle.radians)), y: center.y - (centerToNumber + numberLength) * CGFloat(sin(angle.radians))) path.move(to: inPt) path.addLine(to: outPt) } /// 时针 path.move(to: center) path.addLine(to: hoursNeedle) path = path.strokedPath(StrokeStyle(lineWidth: 3, lineCap: .round)) /// 分针 path.move(to: center) path.addLine(to: minutesNeedle) path = path.strokedPath(StrokeStyle(lineWidth: 3, lineCap: .round)) /// 秒针 path.move(to: center) path.addLine(to: secondsNeedle) path = path.strokedPath(StrokeStyle(lineWidth: 1, lineCap: .round)) return path } }

6.SwiftUI + Metal

如果我们想在SwiftUI中实现特别复杂的动画,并在真机上运行,可能会发现,动画不一定那么流畅,这种情况比较适合开启Metal,开启Metal非常简单,代码如下:

复制代码
1
FlowerView().drawingGroup()
According to WWDC 2019, Session 237 (Building Custom Views with SwiftUI): A drawing group is a special way of rendering but only for things like graphics. It will basically flatten the SwiftUI view into a single NSView/UIView and render it with metal. Jump the WWDC video to 37:27 for a little more detail.

c89a972b07b5e016f0cd1407ca66b2a4.png

可以看下上图的效果,开启了Metal后流畅了很多。至于代码,我们这里就不粘贴了,大家可以去原作者网站去看,上图中整个图形是旋转的,但是花瓣并没有做额外的旋转,而是控制了绘制花瓣的宽度来实现的,这有助于大家理解代码。

总结

SwiftUI动画的本质就是插值,凡是实现了Animatable协议的对象,系统就知道如何执行动画,这是一个核心思想。

注:上边的内容参考了网站https://swiftui-lab.com/swiftui-animations-part1/,如有侵权,立即删除。

最后

以上就是满意芝麻最近收集整理的关于animation动画不生效_SwiftUI动画(1)之Animatable的全部内容,更多相关animation动画不生效_SwiftUI动画(1)之Animatable内容请搜索靠谱客的其他文章。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(71)

评论列表共有 0 条评论

立即
投稿
返回
顶部