Option:定制化你的动画

动画时长(Animation Duration)

动画时长是指动画播放的「秒」数。到目前为止,您看到的所有动画代码都使用了默认的动画时长(大概是 0.4 秒左右)。在本节中,我们将展示如何在动画中设置不同的动画时长。比如使用.linear设置匀速:

1
2
3
swift复制代码View.animation(.linear, value: change) //匀速,默认大概 0.4 秒左右
View.animation(.linear(duration: 2), value: change) //2 秒
View.animation(.linear(duration: 5), value: change) //5 秒

Export-1713491585510.gif

除了匀速(linear),之前的章节中,还简介了比如easeIneaseOuteaseInOut等类型,它们都包含一个duration参数,这个参数决定了动画从开始到结束的时间。

CleanShot 2024-04-19 at 09.41.35@2x.png

一旦设置了duration,不管动画类型是「匀速」,还是曲线「加速」或「减速」,最终的执行时间都是一样(duration)的。如下例:

Export-1713493088591.gif

长时动画很适合作为开屏动画或者某个重要版块的开幕介绍,比如:

Export-1713493898671.gif

动画间隔(Animation Delay)

到目前为止,在本书中,动画都是在被触发后立即开始的。我们可以通过在使用的动画中添加「间隔」方法来控制动画的开始时机,这是让一个动画在另一个动画之后发生的好方法,比如:

Export-1713494744072.gif

首先实现一个动画时长 2 秒的幕布动画:

1
2
3
4
5
6
7
8
9
10
11
12
swift复制代码GeometryReader(content: { geometry in
HStack(spacing: 0) {
//左幕布
Rectangle()
.fill(.green)iiii'ii'i'ii'ii
.offset(x: changed ? -geometry.size.width / 2 : 0)//向左移动(隐藏)
//右幕布
Rectangle()
.fill(.green)
.offset(x: changed ? geometry.size.width / 2 : 0)//向右移动(隐藏)
}.animation(.easeIn(duration: 2), value: changed)//动画时长 2 秒
})

然后将「文字」(Hello SwiftUI)「延迟」 2 秒后放大(恰好幕布展开):

1
2
3
4
5
6
swift复制代码Text("Hello SwiftUI")
.frame(width: 300, height: 100)
.font(changed ? .largeTitle : .body)
.foregroundStyle(changed ? .black : .gray)
.fontWeight(changed ? .black : .ultraLight)
.animation(.easeIn.delay(2), value: changed) // 延迟 2 秒后执行

Delay_Intro.swift

CleanShot 2024-04-19 at 10.53.20@2x.png

延迟方法需要在动画类型(比如lineareaseIn等)后添加.delay()修饰符即可,它不能作为一个独立的参数来传入.animate修饰符,要依托「前置」的动画类型,它的入参是TimeInterval类型,单位是秒(s)。

「动画间隔」常用于「组合」动画「依次」展示的场景,比如设置一个倒计时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
swift复制代码ZStack {
Image(systemName: "1.circle")
.font(.system(size: 144))
.background()
.opacity(change ? 0 : 1)
.animation(.linear.delay(3), value: change)//3 秒后执行

Image(systemName: "2.circle")
.font(.system(size: 144))
.background()
.opacity(change ? 0 : 1)
.animation(.linear.delay(2), value: change)//2 秒后执行

Image(systemName: "3.circle")
.font(.system(size: 144))
.background()
.opacity(change ? 0 : 1)
.animation(.linear.delay(1), value: change)//1 秒后执行
}

Export-1713495696846.gif

重复(Repeat)

前面已经学习了许多触发动画的方法。当动画播放时,它只「播放一次」。但是,有一种方法可以让动画「重复播放」,不管是设定「次数」重复还是「无限」重复。

次数重复(Repeat Count)

1
2
swift复制代码//重复 3 次
View.animation(.easeIn(duration: 1).repeatCount(3), value: changed)

Export-1713496099071.gif

AnimationOption_RepeatCount_Intro.swift

那么怎么理解动画的多次重复呢?

比如上例中的重复 3 次,我们将步骤分解如下:

CleanShot 2024-04-19 at 11.29.47@2x.png

对应动画曲线:
CleanShot 2024-04-21 at 09.17.07@2x.png

在「第二次」动画中,出现了「反转」(reverse)。动画从起始状态「变化」到终止状态如果称之为「正」,那么终止状态「变化」到起始状态则可以称之为「反」。
CleanShot 2024-04-19 at 11.36.11@2x.png

默认情况下,repeatCount方法是「反转的」,如果想要禁止反转,可以传入autoreverses参数并指定为false

1
2
swift复制代码View.animation(.easeIn(duration: 1)
.repeatCount(3, autoreverses: false), value: changed)

CleanShot 2024-04-19 at 11.53.37@2x.png

对应动画曲线:
CleanShot 2024-04-21 at 09.19.24@2x.png

这样设置后,每次重复都是「起始」到「终止」

Export-1713499139280.gif

无限重复(Repeat Forever)

CleanShot 2024-04-19 at 12.02.01@2x.png
repeatFovever不必指定次数,它可以无限重复下去,与repeatCount类似的是,它也默认是「反转」的。

Export-1713499608484.gif

声明式语法虽然简洁,但是简洁的背后是复杂被高度封装,动画时长、动画间隔与重复通过「链式」调用的方式可以很容易的组合使用,但是「不同的链顺序」也会带来「不同的效果」,比如下例:

1
2
swift复制代码View.animation(.linear(duration: 0.2).repeatForever().delay(1), value: changed)
View.animation(.linear(duration: 0.2).delay(1).repeatForever(), value: changed)

Export-1713501317739.gif

  • delay(1)放在最后,代表动画会在 1 秒后执行,每个动画的执行时长是 0.2 秒。
  • delay(1)夹在linearrepeatForever之间,代表动画的「每次重复都要延迟 1 秒」才会执行,每个动画的执行时长是 0.2 秒。

动画范围(Animation Scope)

范围(Scope)是指某事涉及的区域大小。添加动画修饰符的「位置」(链式调用的顺序)会影响到哪些修饰符(Modifier)或视图(View)会被「应用」动画。

链式调用

如果把动画修饰符(.animate)放在视图的修饰符链的「最后」,那么所有「变化」的修饰符都会应用动画效果。

CleanShot 2024-04-19 at 13.11.40@2x.png

容器视图

如果把动画修饰符应用在「父视图或容器视图」上,那么所有「子视图」的变化都会带有动画效果。

CleanShot 2024-04-19 at 13.18.29@2x.png

修饰在父视图或容器视图时,即使某个兄弟视图「没有」可变化的属性,如果「布局」产生改变,它也会产生联动的动画,如下例:

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
swift复制代码VStack {

//例 1
HStack {
//宽度变化;.animation 修饰「子视图」
Color.orange.frame(width: state * 400).animation(.linear(duration: 3), value: state)
Color.green
.overlay {
Text("没有变化属性")
.foregroundStyle(.white)
.font(.title)
}
}
//例 2
HStack {
//宽度变化
Color.orange.frame(width: state * 400)
Color.green.overlay {
Text("没有变化属性")
.foregroundStyle(.white)
.font(.title)
}
//.animation 修饰「父视图」
}.animation(.linear(duration: 3), value: state)

//滑块触发动画
Slider(value: $state)

}.padding()

Export-1713516052578.gif

例 2 中,绿色方块没有变化属性,但是橘色方块宽度变化会导致容器视图内的布局也产生变化(橘色宽增,则绿色宽减,反之亦然),由于例 2 中动画修饰符(.animation)修饰在父视图,所以绿色方块也响应了动画的渐变效果。得益于这个特性,在实际的使用当中,极大的简化了复杂视图布局中动画的设计难度。

关闭动画

如果想要某个视图,「不响应」动画的「影响」,可以传入.nonenil来手动禁用动画,如下例:

1
swift复制代码View.animation(.none, value: changed)
1
swift复制代码View.animation(nil, value: changed)

Export-1713518347011.gif

覆盖

动画场景下,既想要容器视图一劳永逸的便捷(修饰父视图,子视图均生效),又想要个性化的对子视图进行定制(某个子视图有特殊的要求),两者可以兼得吗?

常用 SwiftUI 的开发者对此应该不会陌生,默认情况下,子视图的修饰符默认会覆盖掉父视图的修饰符效果,比如子视图的段落样式.title的会覆盖父视图的.body

CleanShot 2024-04-19 at 17.01.50@2x.png

动画修饰符也同理,如下例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
swift复制代码VStack {
Button("开始") {
changed.toggle()
}
Text("父视图动画")
Circle()
.fill(.cyan)
.frame(width: 100)
.offset(x: changed ? 150 : -150)
//子:动画修饰符,「覆盖」父
.animation(.linear(duration: 2), value: changed)
Text("子视图动画")
Circle()
.fill(.green)
.frame(width: 100)
.offset(x: changed ? 150 : -150)
Spacer()
}.font(.title)
//父:动画修饰符
.animation(.linear, value: changed)

Export-1713518023649.gif

还有一种场景,同一个视图之上有「多个」动画修饰符,允许对不同的「可动画属性」应用不同的「动画修饰符」,如下例:

1
2
3
4
5
6
7
8
9
10
11
swift复制代码Button("点击") {
isEnabled.toggle()
}
.foregroundStyle(.white)
.frame(width: 200, height: 200)
.background(isEnabled ? .green : .red)
//背景色变化禁用动画
.animation(nil, value: isEnabled)
.scaleEffect(isEnabled ? 1.5 : 1)
//缩放应用动画,时长 2 秒
.animation(.easeInOut(duration: 2), value: isEnabled)

Export-1713665182279.gif

虽然这种方式在叶子视图下表现良好,但是如果修饰的父视图或容器视图,可能会出现不可控的动画表现,在 iOS 17 版本,提供了一个新的更清晰的方法来改进,如下例:

1
2
3
4
5
6
7
8
9
10
11
swift复制代码Button("点击") {
isEnabled.toggle()
}
.foregroundStyle(.white)
.frame(width: 200, height: 200)
.animation(nil){content in
content.background(isEnabled ? .green : .red)
}
.animation(.easeInOut(duration: 2)){content in
content.scaleEffect(isEnabled ? 1.5 : 1)
}

content指代的是应用动画修饰器的「视图」,在上例中就是指代「Button.foregroundStyle.frame」,这样可以「直接」对应到可动画属性,就不用关心调用链的「顺序」,因为不会对其它属性有影响,不会产生冲突,还有一种更简化的写法:

1
2
3
4
5
6
7
8
9
10
11
swift复制代码Button("点击") {
isEnabled.toggle()
}
.foregroundStyle(.white)
.frame(width: 200, height: 200)
.animation(nil){
$0.background(isEnabled ? .green : .red)
}
.animation(.easeInOut(duration: 2)){
$0.scaleEffect(isEnabled ? 1.5 : 1)
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%