学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

SwiftUI 编程思想 下

《SwiftUI编程思想》 onevcat 第一版笔记 下

动画

隐式动画

隐式动画是 view 树的一部分:通过为 view 添加 .animation 修饰器,任何对这个 view 的改变都会自动进行动画。

1
2
3
4
5
6
7
8
9
10
struct ContentView: View {
@State var selected: Bool = false
var body: some View {
Button(action: { self.selected.toggle() }) {
RoundedRectangle(cornerRadius: 10)
.fill(selected ? Color.red : .green)
.frame(width: selected ? 100 : 50, height: selected ? 100 : 50)
}.animation(.default)
}
}

以这种方式所创建的动画感觉上有一点像 Apple 的 “Keynote 演讲” 程序里的神奇移动 (Magic Move):我们定义初始点和结束点,程序来决定要怎么在两者之间进行动画。

和其他 view 更新的机制一样,动画也只会在状态改变时被触发,当我们为 view 树添加一个像是 .animation(.default) 的修饰器时,SwiftUI 将在 view 更新的时候以动画的方式把旧的 view 树改变为新的 view 树。

demo

构建一个类似 iOS 内建的活动指示器 (activity indicator) 那样的,一个图片一直旋转的 view

首先,我们需要一些可以更改的状态以触发动画,这里我们使用一个布尔属性,当 view 出现时,我们将它设为 true

其次,我们需要通过在线性动画中加上 repeatForever,来让动画无限次重复

1
2
3
4
5
6
7
8
9
10
11
12
struct LoadingIndicator: View {
@State private var animating = false
var body: some View {
Image(systemName: "rays")
.rotationEffect(animating ? Angle.degrees(360) : .zero)
.animation(Animation
.linear(duration: 2)
.repeatForever(autoreverses: false)
)
.onAppear { self.animating = true }
}
}

动画是由状态变更所驱动的

过渡

想要以动画的方式插入一个新 view,或者是移除一个已经存在的 view。SwiftUI为此准备了特定的方式,那就是过渡 (transition)

demo

下面是一个使用滑入滑出动画来将矩形移入和移出屏幕的过渡。当矩形被插入到 view 树中时,它从左侧动画滑入,当它被移出view 树时,它向右侧动画滑出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ContentView: View {
@State var visible = false
var body: some View {
VStack {
Button("Toggle") { self.visible.toggle() }
if visible {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.transition(.slide)
.animation(.default)
}
}
}
}

注意,过渡效果 (.transition 修饰器) 本身并不进行动画,我们依然需要为它们开启动画。和之前一样,我们使用 .animation(.default) 来达成这一点。

动画是如何工作的

考察圆角矩形的动画,这次我们只对它的宽度进行动画。我们也会将动画的持续时间改成五秒,并让它按照线性方式进行动画:

1
2
3
4
5
6
7
8
9
10
struct AnimatedButton: View {
@State var selected: Bool = false
var body: some View {
Button(action: { self.selected.toggle() }) {
RoundedRectangle(cornerRadius: 10)
.fill(Color.green)
.frame(width: selected ? 100 : 50, height: 50)
}.animation(.linear(duration: 5))
}
}

在动画期间内,SwiftUI 对 width 值的计算分为两部分。
首先,SwiftUI 使用我们指定的Animation 值 (在上面的例子中,这个值是 .linear)来计算动画的进度。确定了动画的进度,SwiftUI 就可以对被变更的属性在起始值和终止值之间进行插值了。

通常,动画的进度是间于 0 和 1 之间的一个值,0 代表动画的开始,1 代表动画的结束 (不过,有一些动画可以带有“弹性”,它们的进度值有可能会大于 1 或者小于 0)

为了对值进行动画,SwiftUI 使用了 Animatable 协议,该协议仅具有一个要求:一个类型遵守VectorArithmetic 协议的 animatableData 属性。

VectorArithmetic 类型可以进行加法和减法运算,也可以使用 Double 进行乘法。通过这些运算,SwiftUI 可以得出动画的起始值和终止值之间的差值,并将它乘以当前的进度值来得到实际需要的插值。

以矩形 width 动画从 50 到100 为例,SwiftUI 通过 50 + (100 - 50) * progress 就可以计算出当前的宽度。或者,当从绿色动画到红色时,我们可以将它写作 .green + (.red - .green) * progress

一般而言,SwiftUI 将animatableData 属性的当前值计算为 startValue + (endValue - startValue) * progress

当为一个 view 的子树设定隐式动画 (比如 .animation(.default)) 时,子树上的所有可动画属性都会被加上动画,我们也可以通过调用 .animation(nil) 来在这棵子树上禁用所有动画 (甚至包括那些显式动画)。

动画曲线

.linear(duration: 5) 这个 Animation 值提供了一条动画曲线,它在五秒钟的
过程内将进度平均地从 0 到 1 进行插值。
.easeInOut(duration: 0.35) 来使用缓入/缓出曲线 (ease-in/ease-out curve),它将一开始会缓慢移动,然后加快速度,最后再降低速度。

动画曲线可以被看作是一个函数,它接受时间作为输入,返回在这个时间点上对应的动画所达到的进度。下面是内置的动画曲线进行可视化后的样子。

横轴代表时间,纵轴代表动画进行的进度:

可以通过一些修饰器来改变 Animation 的值。比如,我们可以调用 .speed(0.1) 来让动画速度减慢到十分之一,或者我们可以调用 .delay(2) 来让动画延后两秒。另外,repeatCount 是一个很有意思的修饰器,它可以让我们重复一个动画若干次

显示动画

下面这个另一版本的加载指示器,它将一个小原点沿着圆圈进行动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct LoadingIndicator: View {
@State var appeared = false
let animation = Animation
.linear(duration: 2)
.repeatForever(autoreverses: false)
var body: some View {
Circle()
.fill(Color.accentColor)
.frame(width: 5, height: 5)
.offset(y: -20)
.rotationEffect(appeared ? Angle.degrees(360) : .zero)
.animation(animation)
.onAppear { self.appeared = true }
}
}

当我们旋转模拟器(或者设备) 时,这个点将会在一条奇怪的路径上运动:因为我们使用了隐式动画,SwiftUI 也将会为点的 frame 改变进行这个永不停止的动画 (这个 frame 改变是由设备转向所造成的)。

只想为那些 view 树中由于 self.appeared 状态属性变更所造成的变化进行动画。要做到这一点,我们将隐式动画移除,并将状态改变的代码包装到一个 withAnimation (显式动画) 调用中:

1
2
3
4
5
6
7
8
9
10
Circle()
.fill(Color.accentColor)
.frame(width: 5, height: 5)
.offset(y: -20)
.rotationEffect(appeared ? Angle.degrees(360) : .zero)
.onAppear {
withAnimation(self.animation) {
self.appeared = true
}
}

自定义动画

有时候,实现自定义的可动画 view 修饰器是达成某些特定动画的唯一途径

demo

我们想要实现一个可以摇晃 view 来吸引用户注意的动画。当动画开始时,view 应该从它的初始位置向右移动,然后向左移动到初始位置的左侧,最后在移动回到初始位置。

要实现这个动画,我们可以实现我们自己的自定义 view 修饰器,并使它遵守 AnimatableModifier 协议 (这个协议继承自 AnimatableViewModifier)。我们的修饰器有一个 times 属性,当它增加时,就将晃动 view。我们的目标是当这个属性被增加 1 时,晃动一次 view,当增加 2 时晃动两次,以此类推:

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
struct Shake: AnimatableModifier {
var times: CGFloat = 0
let amplitude: CGFloat = 10
var animatableData: CGFloat {
get { times }
set { times = newValue }
}
func body(content: Content) -> some View {
return content.offset(x: sin(times * .pi * 2) * amplitude)
}
}

extension View {
func shake(times: Int) -> some View {
return modifier(Shake(times: CGFloat(times)))
}
}

struct ContentView: View {
@State private var taps: Int = 0
var body: some View {
Button("Hello") {
withAnimation(.linear(duration: 0.5)) {
self.taps += 1
}
}
.shake(times: taps * 3)
}
}

在body 的实现中,我们使用 sin 来为摇晃的动画设置一个平滑的曲线:如果 times 是一个整数,偏移量为 0。在整数之间的数字 (比如说 0 到 1 之间),偏移量将从 0 动画变更到 10,然后变更到 -10,最后回到 0。

如果需要暴露两个属性作为可动画属性,我们可以将它们包装在一个 AnimatablePair里。想要支持任意数量的属性,我们可以嵌套使用 AnimatablePair。

要验证 SwiftUI 在动画期间插入不同值的方式,我们可以在 animatableData 的 set-ter 或者 AnimatableModifier 的 body 中打印 log。

当我们的动画修饰器需要进行动画的值可以被表示为仿射变换 (不论 2D 还是 3D) 时,我们也可以使用 GeometryEffect 来代替 AnimatableModifier。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ShakeEffect: GeometryEffect {
var times: CGFloat = 0
let amplitude: CGFloat = 10
var animatableData: CGFloat {
get { times }
set { times = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(
translationX: sin(times * .pi * 2) * amplitude,
y: 0
))
}
}

自定义过渡

要自定义 view 的插入和移除动画,我们可以使用 AnyTransition 的 modifier(active:identity:)方法来创建自定义过渡。
这个方法接受两个 view 修饰器:一个用于过渡发生时,另一个用于过渡结束时。

当 view 被插入时,active 修饰器在插入开始时被应用到 view 上,然后向着 identity 修饰器所定义的状态进行迁移。

在移除过程中,动画反向进行:从 identity 修饰器开始,向 active 修饰器靠拢。

demo

创建一个模糊过滤。当 view 被插入时,它会以模糊状态出现,并且 opacity 值为零 (全透明);当移除时,它会淡出并模糊。

第一步,我们创建一个自定义的 ViewModifier。当 active 是 true 时,内容是模糊且透明,当 active 为 false 时,模糊半径被设为 0,内容非透明:

1
2
3
4
5
6
7
8
struct Blur: ViewModifier {
var active: Bool
func body(content: Content) -> some View {
return content
.blur(radius: active ? 50 : 0)
.opacity(active ? 0 : 1)
}
}

第二步,我们可以向 AnyTransition 添加一个静态属性,这可以帮助我们更容易访问到我们定义的新过渡:

1
2
3
4
5
6
extension AnyTransition {
static var blur: AnyTransition {
.modifier(active: Blur(active: true),
identity: Blur(active: false))
}
}

最后,我们可以使用上面的例子,将 .transition(.slide) 替换成 .transition(.blur)。现在,在插入时,view 会从模糊状态淡入;而在它被移除时,会淡出到模糊状态。