精通 SwiftUI - iOS 16 版

第 9 章
基础动画与转场

你曾在 Keynote 使用过瞬间移动动画( magic move animation )吗?藉由瞬间移动效果, 你可以轻松建立投影片间的平滑动画( slick animation )。Keynote 会自动分析两张投影片之间的物件,并自动渲染动画。同样,SwiftUI 也将瞬间移动动画带入应用程序开发中, 使用该框架的动画是自动且神奇的。当你定义一个视图的两个状态,SwiftUI 会找出其余的状态,接着以动画呈现两个状态之间的变化。

SwiftUI 使你能够为单个视图的变化以及两个视图之间的转场来设定动画。SwiftUI 框架具有许多的内建动画,可建立不同的效果。

隐式动画与显式动画

SwiftUI 提供两种动画类型:隐式( implicit )与显式( explicit )。这两个方式可以让你为视图及视图转场设定动画。为了实现隐式动画,SwiftUI 框架提供一个名为 animation 的修饰器,你把这个修饰器加到要设定动画的视图上,并指定喜欢的动画类型。或者,你可以定义动画的持续时间与延迟时间,SwiftUI 会根据视图的状态变化来自动渲染动画。

显式动画对你要显示的动画提供更具局限性的控制,其并非将修饰器加到视图,而是在 withAnimation() 区块中,告诉 SwiftUI 若有什么的状态变化时,要绘制动画。

仍是觉得有些困惑吗?没有关系,藉由几个示例,你就会更有概念了。

隐式动画

我们从隐式动画来开始介绍,我建议你建立一个新项目来看动画的实际效果。你可以任意为项目命名,而我将它命名为 SwiftUIAnimation

图 9.1. 绘制按钮的状态变化
图 9.1. 绘制按钮的状态变化

我们来看图 9.1,这是一个简单的可点击视图,由红色圆形与心形所组成。当使用者点击心形或圆形时,圆形的颜色会变成淡灰色,而心形则会变成红色,同时心形图示的大小也变得较大。因此,以下是其状态变化:

  1. 圆的颜色会从红色变成淡灰色。
  2. 心形图示的颜色会从白色变成红色。
  3. 心形图示会比原来的大小大两倍。

如果你使用SwiftUI 来实现可点击的圆形,以下为程序内容,你可以建立一个 Xcode 的新项目,并将程序插入至 ContentView.swift 来测试看看:

struct ContentView: View {
    @State private var circleColorChanged = false
    @State private var heartColorChanged = false
    @State private var heartSizeChanged = false

    var body: some View {

        ZStack {
            Circle()
                .frame(width: 200, height: 200)
                .foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)

            Image(systemName: "heart.fill")
                .foregroundColor(heartColorChanged ? .red : .white)
                .font(.system(size: 100))
                .scaleEffect(heartSizeChanged ? 1.0 : 0.5)
        }
        .onTapGesture {
            circleColorChanged.toggle()
            heartColorChanged.toggle()
            heartSizeChanged.toggle()
        }

    }
}

我们定义三个状态变量来建立状态模型,初始值皆设定为“false”。为了建立圆形与心形,我们使用 ZStack 来将心形叠在圆形上,如图9.2 所示。SwiftUI 有个 onTapGesture 修饰器可以侦测,点击手势,你可以将它加在任何视图,使其可点击。在 onTapGesture 闭包中,我们切换状态,以改变视图的外观。

图 9.2. 实现圆形与心形视图
图 9.2. 实现圆形与心形视图

在预览画布测试这个 App,则你点击视图时,圆形及心形图示的颜色会改变,但这些变化不会显示动画。

要让这些变化显示动画效果,你只需将 animation 修饰器加到 CircleImage 视图:

Circle()
    .frame(width: 200, height: 200)
    .foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)
    .animation(.default, value: circleColorChanged)

Image(systemName: "heart.fill")
    .foregroundColor(heartColorChanged ? .red : .white)
    .font(.system(size: 100))
    .scaleEffect(heartSizeChanged ? 1.0 : 0.5)
    .animation(.default, value: heartSizeChanged)

SwiftUI 会自动计算与渲染动画,使视图可以从一个状态流畅转换到另一个状态。再次点击心形,你会见到一个平滑动画。

你不仅可以将 animation 修饰器应用到单一视图中,还可应用于一组视图。举例而言, 你可以将 animation 修饰器加到 ZStack,将上列的代码重新撰写如下:

ZStack {
    Circle()
        .frame(width: 200, height: 200)
        .foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)

    Image(systemName: "heart.fill")
        .foregroundColor(heartColorChanged ? .red : .white)
        .font(.system(size: 100))
        .scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.animation(.default, value: circleColorChanged)
.animation(.default, value: heartSizeChanged)
.onTapGesture {
    self.circleColorChanged.toggle()
    self.heartColorChanged.toggle()
    self.heartSizeChanged.toggle()
}

其工作原理是完全相同的,SwiftUI 寻找嵌入在 ZStack 中所有的状态变化,并建立动画。

在示例中,我们使用默认动画。SwiftUI 提供许多内建动画供你选择,包括 lineareaseIneaseOuteaseInOutspring。“线性动画”(linear animation )是以线性速度来呈现变化,而“缓动动画”(easing animation )则是速度会做变化。详细内容可以参考 www.easings.net 来了解每个 ease 函数的差异。

要使用其他的动画,你只需要在 animation 修饰器中设定特定的动画。例如:你想要使用 spring 动画,则可以将 .default 修改如下:

.animation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3), value: circleColorChanged)

这会渲染一个基于弹簧特性的动画,使得心形产生心跳的效果。你可以调整阻尼(damping )值与混合(blend )值,来达到不同的效果。

显式动画

以上是对视图使用隐式动画的方法。我们来看如何使用显式动画来达到相同的结果。如前所述,你需要将状态变化包裹在 withAnimation 区块内。要建立相同的动画效果,你可撰写下列代码:

ZStack {
    Circle()
        .frame(width: 200, height: 200)
        .foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)

    Image(systemName: "heart.fill")
        .foregroundColor(heartColorChanged ? .red : .white)
        .font(.system(size: 100))
        .scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
    withAnimation(.default) {
        self.circleColorChanged.toggle()
        self.heartColorChanged.toggle()
        self.heartSizeChanged.toggle()
    }
}

我们不再使用 animation 修饰器,而是使用 withAnimation 包裹在 onTapGesture 中。withAnimation 调用带入一个动画参数,这里我们指定使用默认动画。

当然,你可修改 withAnimation,以将动画修改为弹簧动画,如下所示:

withAnimation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3)) {
    self.circleColorChanged.toggle()
    self.heartColorChanged.toggle()
    self.heartSizeChanged.toggle()
}

使用显式动画,你可以轻松控制想加上动画的状态。举例而言,如果你不想为心形图示的大小变化设定动画时,则可以从 withAnimation 排除该行代码,如下所示:

.onTapGesture {
    withAnimation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3)) {
        self.circleColorChanged.toggle()
        self.heartColorChanged.toggle()
    }

    self.heartSizeChanged.toggle()
}

在这个情况下,SwiftUI 将只对圆形与心形的颜色变化设定动画,你不会再看到心形图示的变大动画效果。

你可能想知道我们是否可以使用隐式动画来关闭缩放动画。好的,你仍然可以做到, 可重新调整 .animation 的顺序,以防止 SwiftUI 对特定状态变化设定动画。下列为达到相同效果的程序:

ZStack {
    Circle()
        .frame(width: 200, height: 200)
        .foregroundColor(circleColorChanged ? Color(.systemGray5) : .red)
        .animation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3), value: circleColorChanged)

    Image(systemName: "heart.fill")
        .foregroundColor(heartColorChanged ? .red : .white)
        .font(.system(size: 100))
        .animation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3), value: heartColorChanged)
        .scaleEffect(heartSizeChanged ? 1.0 : 0.5)
}
.onTapGesture {
    self.circleColorChanged.toggle()
    self.heartColorChanged.toggle()
    self.heartSizeChanged.toggle()
}

对于 Image 视图,我们在 scaleEffect 之前置入 animation(nil) 修饰器,这会将动画取消,scaleEffect 修饰器的状态变化将不会设定动画。

虽然你可以使用隐式动画建立相同的动画,但我认为在这种情况下,使用显式动画会更方便。

使用RotationEffect 建立下载指示器

SwiftUI 动画的强大之处在于,你不需关心如何对视图设定动画,你只需要提供初始与结束状态,接着 SwiftUI 会找出其余的状态。当你具备了这个概念,即可以建立各种类型的动画。

图 9.3. 下载指示器示例
图 9.3. 下载指示器示例

举例而言,我们来建立一个简单的下载指示器,你通常可以在一些现实世界的应用程序(如 Medium)中找到它。要建立一个如图 9.3 所示的下载指示器,我们从开放式圆环(open ended circle )来开始,如下所示:

Circle()
    .trim(from: 0, to: 0.7)
    .stroke(Color.green, lineWidth: 5)
    .frame(width: 100, height: 100)

那么,我们如何使圆环连续旋转呢?我们可以利用 rotationEffectanimation 修饰器。诀窍就是使圆环 360 度连续旋转。以下为代码:

struct ContentView: View {
    @State private var isLoading = false

    var body: some View {
        Circle()
            .trim(from: 0, to: 0.7)
            .stroke(Color.green, lineWidth: 5)
            .frame(width: 100, height: 100)
            .rotationEffect(Angle(degrees: isLoading ? 360 : 0))
            .animation(.default.repeatForever(autoreverses: false), value: isLoading)
            .onAppear() {
                isLoading = true
            }
    }
}

rotationEffect 修饰器带入旋转角度,在上列的代码中,我们有一个状态变量来控制下载状态。当它设定为true 时,这个旋转角度设定为 360 度以旋转圆环。在 animation 修饰器中,我们指定使用默认动画,不过还有些不同,我们告诉 SwiftUI 要一次又一次重复相同的动画,这就是建立下载动画的诀窍。

*备注: 如果在预览画布中看不到动画,请在模拟器中运行App再试一试。*

如果你想要修改动画的速度,则可以使用线性动画,并指定持续时间,如下所示:

.animation(.linear(duration: 5).repeatForever(autoreverses: false), value: isLoading)

持续时间越久,则动画越慢。

onAppear 修饰器对你而言可能比较陌生,如果你对 UIKit 有所了解的话,这个修饰器和 viewDidAppear 非常相似,当视图出现在画面上时会自动调用。在该代码中,我们将下载状态设定为true,以在视图载入时启动动画。

当你使用此技术,就可以调整设计并开发各种版本的下载指示器。举例而言,你可以将圆弧叠在圆环上,以建立精美的下载指示器,如图 9.4 所示。

图 9.4. 下载指示器示例
图 9.4. 下载指示器示例

代码片段如下所示:

struct ContentView: View {

    @State private var isLoading = false

    var body: some View {
        ZStack {

            Circle()
                .stroke(Color(.systemGray5), lineWidth: 14)
                .frame(width: 100, height: 100)

            Circle()
                .trim(from: 0, to: 0.2)
                .stroke(Color.green, lineWidth: 7)
                .frame(width: 100, height: 100)
                .rotationEffect(Angle(degrees: isLoading ? 360 : 0))
                .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isLoading)
                .onAppear() {
                    self.isLoading = true
            }
        }
    }
}

下载指示器不需要为圆形,你也可以使用 RectangleRoundedRectangle 来建立该指示器。不过,无需修改旋转角度,你可以修改位移值(offset value)来建立如图 9.5 所示的动画。

图 9.5. 下载指示器的另一个示例
图 9.5. 下载指示器的另一个示例

为了建立动画,我们将两个圆角矩形重叠在一起。上面的矩形比下面的矩形短得多, 当开始载入时,我们将位移值从“-110”修改为“110”。

struct ContentView: View {

    @State private var isLoading = false

    var body: some View {
        ZStack {

            Text("Loading")
                .font(.system(.body, design: .rounded))
                .bold()
                .offset(x: 0, y: -25)

            RoundedRectangle(cornerRadius: 3)
                .stroke(Color(.systemGray5), lineWidth: 3)
                .frame(width: 250, height: 3)

            RoundedRectangle(cornerRadius: 3)
                .stroke(Color.green, lineWidth: 3)
                .frame(width: 30, height: 3)
                .offset(x: isLoading ? 110 : -110, y: 0)
                .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isLoading)
        }
        .onAppear() {
            self.isLoading = true
        }
    }
}

这会让绿色矩形沿着线条移动。而且,当你一次又一次重复相同的动画,它将变成一个载入动画,图 9.6 说明了位移值。

图 9.6. 下载指示器的另一个示例
图 9.6. 下载指示器的另一个示例

建立进度指示器

下载指示器向使用者提供一些回馈,其表示 App 正在处理某些事情。不过,它并没有显示实际进度,如果你需要为使用者提供关于任务进度的更多资讯,则可能需要建立一个如图 9.7 所示的进度指示器。

图 9.7. 进度指示器
图 9.7. 进度指示器

建立进度指示器的方式与下载指示器非常相似。不过,你需要使用状态变量来追踪进度。以下是建立进度指示器的代码片段:

struct ContentView: View {
    @State private var progress: CGFloat = 0.0

    var body: some View {

        ZStack {    
            Text("\(Int(progress * 100))%")
                .font(.system(.title, design: .rounded))
                .bold()

            Circle()
                .stroke(Color(.systemGray5), lineWidth: 10)
                .frame(width: 150, height: 150)

            Circle()
                .trim(from: 0, to: progress)
                .stroke(Color.green, lineWidth: 10)
                .frame(width: 150, height: 150)
                .rotationEffect(Angle(degrees: -90))
        }
        .onAppear() {
            Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
                self.progress += 0.05
                print(self.progress)
                if self.progress >= 1.0 {
                    timer.invalidate()
                }
            }
        }
    }
}

这里的状态变量不是使用布林值,而是我们使用浮点数来储存状态。为了显示进度, 我们以进度值来设定 trim 修饰器。在现实世界的应用程序中,你可以修改 progress 的值来显示操作的实际进度。为了示范,我们只启用一个计时器,其每半秒修改一次。

延迟动画

SwiftUI 框架不只让你可以控制动画的持续时间,你可以通过 delay 函数来延迟动画, 如下所示:

Animation.default.delay(1.0)

这会将动画延迟1 秒后开始。delay 函数也适用其他动画。

通过混合搭配持续时间值与延迟时间值,你可以实现出一些有趣的动画,如图 9.8 所示的圆点下载指示器。

图 9.8. 圆点下载指示器r
图 9.8. 圆点下载指示器r

这个指示器由五个点组成,每个点皆有放大缩小动画,不过各有不同的延迟时间。我们来看代码该如何实现:

struct ContentView: View {
    @State private var isLoading = false

    var body: some View {
        HStack {
            ForEach(0...4, id: \.self) { index in
                Circle()
                    .frame(width: 10, height: 10)
                    .foregroundColor(.green)
                    .scaleEffect(self.isLoading ? 0 : 1)
                    .animation(.linear(duration: 0.6).repeatForever().delay(0.2 * Double(index)), value: isLoading)
            }
        }
        .onAppear() {
            self.isLoading = true
        }
    }
}

我们先使用一个 HStack 来水平布局这些圆形,由于这五个圆形(也就是点)皆有相同的大小与颜色,因此我们使用 ForEach 来建立这些圆形。scaleEffect 修饰器是用来缩放圆形的大小,默认是设定为“1”,也就是原来的大小。当开始载入时,该值会修改为“0”, 这将会缩小此点。

用于渲染动画的代码看起来有些复杂,我们来分拆它并逐步研究:

Animation.linear(duration: 0.6).repeatForever().delay(0.2 * Double(index))

第一个部分建立一个持续时间为 0.6 秒的线性动画,该动画会重复运行,因此我们调用 repeatForever 函数。

如果你没有调用 delay 函数来运行这个动画,则所有的点会同时缩放。但是,这不是我们想要的结果,每个点应独立调整大小,而不是全部同时放大/ 缩小,这也是为何我们调用 delay 函数的原因,并对每个点使用不同的延迟时间值。

你可以修改持续时间值与延迟时间值来调整动画。

将矩形变形为圆形

有时,你可能需要将一个形状(例如:矩形)流畅地变形为另一个形状(例如:圆形)。这该如何实现呢?使用内建的形状与动画,你可以轻松建立如图 9.9 所示的变形。

图 9.9. 将矩形变形为圆形
图 9.9. 将矩形变形为圆形

将矩形变形为圆形的技巧是使用 RoundedRectangle 形状,并为圆角半径的变化设定动画。假设矩形的宽度与高度相同,当圆角半径设定为宽度的一半时,它会变为圆形。以下是变形按钮的实现:

struct ContentView: View {
    @State private var recordBegin = false
    @State private var recording = false

    var body: some View {
        ZStack {

            RoundedRectangle(cornerRadius: recordBegin ? 30 : 5)
                .frame(width: recordBegin ? 60 : 250, height: 60)
                .foregroundColor(recordBegin ? .red : .green)
                .overlay(
                    Image(systemName: "mic.fill")
                        .font(.system(.title))
                        .foregroundColor(.white)
                        .scaleEffect(recording ? 0.7 : 1)
                )

            RoundedRectangle(cornerRadius: recordBegin ? 35 : 10)
                .trim(from: 0, to: recordBegin ? 0.0001 : 1)
                .stroke(lineWidth: 5)
                .frame(width: recordBegin ? 70 : 260, height: 70)
                .foregroundColor(.green)

        }
        .onTapGesture {
            withAnimation(Animation.spring()) {
                self.recordBegin.toggle()
            }

            withAnimation(Animation.spring().repeatForever().delay(0.5)) {
                self.recording.toggle()
            }
        }
    }
}

这里有两个状态变量:recordBeginrecording,其是控制两个单独的动画。第一个变量控制按钮的变形,如前所述,我们使用圆角半径来实现这个变形。矩形的宽度原先是设定为“250 点”,当使用者点击矩形来触发变形时,这个框架的宽度会变为“60 点”。随着改变,圆角半径变成“30 点”,也就是宽度的一半。

这就是我们将矩形变形为圆形的方法,而且SwiftUI 会自动渲染此变形的动画。

另一方面,recording 状态变量处理了麦克风图片的缩放。当它为录音状态时,我们将缩放比例从“1”修改为“0.7”,藉由重复运行相同的动画,即可建立放大及缩小的动画。

请注意,以上的代码使用显式方法来对视图设定动画。这不是强制性的,依照自己的喜好,你也可以使用隐式动画方法来获得相同的结果。

了解转场

到目前为止,我们已经讨论了对视图层次(view hierarchy )中已存在的视图设定动画。我们建立动画来放大和缩小视图,或者对视图大小设定动画。

SwiftUI 让开发者不只是做出前述的动画,你可以定义如何从视图层次中插入或移除视图,而在 SwiftUI 中,这就是所谓的“转场”( transition )。框架默认是使用淡入( fade in )与淡出( fade out )转场。不过它内建了几个现成的转场效果,如滑动( slide )、移动( move)、不透明度(opacity )等。当然,你可以开发自己的转场效果,也可以简单的混合搭配各种类型的转场,以建立所需的转场效果。

图 9.10. 使用 SwiftUI 建立的转场示例
图 9.10. 使用 SwiftUI 建立的转场示例

建立简单的转场

我们来看一个简单的示例,以更加了解转场的含义以及动画如何运作。建立一个名为 SwiftUITransition 的新项目,并修改 ContentView 如下:

struct ContentView: View {

    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 10)
                .frame(width: 300, height: 300)
                .foregroundColor(.green)
                .overlay(
                    Text("Show details")
                        .font(.system(.largeTitle, design: .rounded))
                        .bold()
                        .foregroundColor(.white)

            )

            RoundedRectangle(cornerRadius: 10)
                .frame(width: 300, height: 300)
                .foregroundColor(.purple)
                .overlay(
                    Text("Well, here is the details")
                        .font(.system(.largeTitle, design: .rounded))
                        .bold()
                        .foregroundColor(.white)
                )
        }
    }
}

在上列的代码中,我们使用 VStack 垂直布局两个矩形。而我要做的是让这个堆叠可以点击。首先,要隐藏紫色矩形,只有当使用者点击绿色矩形(也就是Show details )时才会显示。

图 9.11. 垂直布局两个矩形
图 9.11. 垂直布局两个矩形

为此,我们需要声明一个状态变量来决定是否显示紫色矩形。将下列这行代码插入 ContentView 中:

@State private var show = false

接下来,我们将紫色矩形包裹在 if 叙述中,如下所示:

if show {
    RoundedRectangle(cornerRadius: 10)
        .frame(width: 300, height: 300)
        .foregroundColor(.purple)
        .overlay(
            Text("Well, here is the details")
                .font(.system(.largeTitle, design: .rounded))
                .bold()
                .foregroundColor(.white)
        )
}

对于 VStack,我们加入 onTapGesture 函数来侦测点击,并为状态变化建立动画。请注意,该转场效果与动画要有关联,否则它无法自行运作。

.onTapGesture {
    withAnimation(Animation.spring()) {
        self.show.toggle()
    }
}

当使用者点击堆叠时,我们切换为 show 变量来显示紫色矩形。如果你在模拟器或预览画布中运行这个 App,则应该只会看到绿色矩形,如图 9.12 所示。点击它会显示紫色矩形,而你应可看到一个平滑的淡入与淡出的转场效果。

图 9.12. 默认的转场效果
图 9.12. 默认的转场效果

如前所述,如果你没有指定想使用的转场效果,SwiftUI 将渲染淡入淡出转场。要使用其他的转场效果,则在紫色矩形中加入 transition 修饰器,如下所示:

if show {
    RoundedRectangle(cornerRadius: 10)
        .frame(width: 300, height: 300)
        .foregroundColor(.purple)
        .overlay(
            Text("Well, here is the details")
                .font(.system(.largeTitle, design: .rounded))
                .bold()
                .foregroundColor(.white)
        )
        .transition(.scale(scale: 0, anchor: .bottom))
}

这个 transition 修饰器会带入一个 AnyTransition 类型的参数。这里我们使用scale 转场, 锚点(anchor )设定为 .bottom,这就是你修改转场效果所需要做的事情。在模拟器中运行该 App,并看一下结果为何,当 App 显示紫色矩形时,你应该会看到一个弹出动画。我建议使用内建的模拟器来测试动画,而不采用 App 预览的方式,因为预览画布可能无法正确的渲染转场动作。

图 9.13. 缩放转场效果
图 9.13. 缩放转场效果

除了 .scale 之外,SwiftUI 框架还有多个内建的转场效果,包括 .opaque.offset.move.slide。试着以位移转场(offset transition)取代缩放转场(scale transition),如下所示:

.transition(.offset(x: -600, y: 0))

如此,当紫色矩形插入 VStack 时,它会从左侧滑入。

混合式转场

你可以调用 combined(with:) 方法来将两个或者更多个转场效果结合在一起,以建立更流畅的转场效果。举例而言,如果要结合位移与缩放动画,则可以撰写代码如下:

.transition(AnyTransition.offset(x: -600, y: 0).combined(with: .scale))

如果需要混合三个转场效果的话,则可以参考以下这行的示例代码:

.transition(AnyTransition.offset(x: -600, y: 0).combined(with: .scale).combined(with: .opacity))

在某些情况下,如果你需要定义一个可以重复利用的动画,你可以在 AnyTransition 定义一个扩展(extension ),如下所示:

extension AnyTransition {
    static var offsetScaleOpacity: AnyTransition {
        AnyTransition.offset(x: -600, y: 0).combined(with: .scale).combined(with: .opacity)
    }
}

接着,你可以在 transition 修饰器中使用 offsetScaleOpacity 动画:

.transition(.offsetScaleOpacity)

运行这个App,并再次测试转场效果,看起来很棒吧?

图 9.14. 结合缩放、位移与不透明度的转场效果
图 9.14. 结合缩放、位移与不透明度的转场效果

不对称转场

我们刚才讨论的转场皆是对称性的,也就是视图的插入与移除是使用相同的转场效果。举例而言,如果将缩放转场运用于视图,则 SwiftUI 在它插入视图层次时会放大视图,而移除它时,该框架会将其缩回原来大小。

那么,若你想在插入视图时使用缩放转场以及移除视图时使用位移转场呢?这在 SwiftUI 中,即所谓的“不对称转场”( Assymetric Transitions )。要使用这种转场效果非常简单,你只需要调用 .assymetric 方法,来指定插入( insertion )及移除(removal )的转场即可。下列是示例代码:

.transition(.asymmetric(insertion: .scale(scale: 0, anchor: .bottom), removal: .offset(x: -600, y: 0)))

另外,如果你需要重新使用这个转场,则可以在 AnyTransition定义一个扩展,如下所示:

extension AnyTransition {
    static var scaleAndOffset: AnyTransition {
        AnyTransition.asymmetric(
            insertion: .scale(scale: 0, anchor: .bottom),
            removal: .offset(x: -600, y: 00)
        )
    }
}

修改代码后,请使用内建模拟器来运行这个 App。当紫色矩形出现于屏幕上时,你应该会看到缩放转场,而当你点击矩形时,紫色矩形会滑出屏幕画面。

图 9.15. 不对称转场示例
图 9.15. 不对称转场示例

作业 #1: 使用动画与转场建立精美按钮

现在,你应该具备了转场与动画的概念,我们来挑战建立一个精美的按钮,以显示目前操作状态。请输入下列网址:https://www.appcoda.com/wp-content/uploads/2019/10/swiftui-animation-16.gif,来看一下效果。

图 9.16. 精美按钮
图 9.16. 精美按钮

这个按钮有三种状态:

  • 原始状态:以绿色显示“Submit”按钮。
  • 处理状态:显示一个旋转的圆环,并修改其标签为“Processing”。
  • 完成状态:以红色显示“Done”按钮。

这是一个非常具挑战性的项目,它将测试你对 SwiftUI 动画与转场的了解。你将需要结合到目前为止所学的知识来制定解决方案。

如图 9.16 所示的示例按钮,这个处理状态大约是 4 秒处理状态约需要 4 秒,你不需要运行实际操作。作为提示,我使用下列的代码来模拟该操作。

private func startProcessing() {
    self.loading = true

    // 使用DispatchQueue.main.asyncAfter 来模拟操作
    // 在现实世界的项目中,你将在这里运行一个任务
    // 当任务完成之后,你将完成状态设定为true
    DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
        self.completed = true
    }
}

作业 #2: 视图转场动画

你已经学会了如何实现视图转场,试着结合在第 5 章中建立的卡片视图项目,并建立如下所示的视图转场。当使用者点击卡片时,目前的视图将缩小并淡出,下一个视图将以放大动画显示在前面。

图 9.17. 视图转场动画
图 9.17. 视图转场动画

如果你不懂上述的动画, 则可以输入网址: https://www.appcoda.com/wp-content/uploads/2019/10/swiftui-view-animation.gif,来看一下动画效果。

本章小结

动画在行动 UI 设计中扮演着一个特殊的角色,精心设计的动画可改善使用者体验,并让 UI 的互动具有意义。两个视图之间流畅轻快的转场,将让使用者满意且印象深刻。在 App Store 上有超过 200 万支 App,要使你的 App 脱颖而出,并不容易,不过精心设计的 UI 与动画肯定会产生根本的差别。

话虽如此,但是对于有经验的开发者而言,撰写平滑动画动画也不是一件容易的事。幸运的是,SwiftUI 框架简化了 UI 动画与转场的开发,你只须告诉框架:视图在开始及结束时的状态为何,SwiftUI 会计算出其余的状态,为你渲染出一个流畅且漂亮的动画。

在本章中,我已介绍了基本的原理,不过如你所见,你已经建立了一些令人愉悦的动画与转场效果,最重要的是,只需要几行代码即能办到。

我期望你喜欢本章的内容,并发掘出有用的技巧。在本章所准备的示例档中,有完整的项目与作业解答可以下载: