Skip to content

第32天 项目 6 第一部分

2001 年 Mac OS X 发布时,史蒂夫·乔布斯推出了 Aqua 视觉主题,此后该主题一直为 macOS 提供支持。当时他说:“我们把屏幕上的按钮做得如此精美,你会想舔一舔它们。”我不知道你当时是否在使用 Mac,但多年来,Aqua 为我们带来了玻璃质感的按钮、细条纹、拉丝金属等诸多元素,即便在今天,“精灵”窗口最小化效果看起来依然惊艳。

当我们开发出视觉吸引力出色的应用时,用户一定会注意到。当然,这不会影响应用的核心功能,而且过度追求设计很容易让核心功能变得不那么突出,但如果设计得当,美观的用户界面会带来额外的愉悦感,还能帮助你的应用在众多应用中脱颖而出。

动画是让应用“活”起来的基本方式之一,值得高兴的是,SwiftUI 为我们提供了一系列使用动画的工具。今天我们将学习较简单的动画,明天再深入学习更复杂的内容——了解这两类动画很有必要,这样未来遇到任何问题你都能应对。

今天你需要学习五个主题,从中你将了解隐式动画、显式动画、绑定动画等知识。

  • 动画:简介
  • 创建隐式动画
  • 在 SwiftUI 中自定义动画
  • 为绑定添加动画
  • 创建显式动画

动画:简介

作者:Paul Hudson 2021 年 11 月 24 日

我们又回到了技术项目的学习,这次我们要关注的是一种快速、美观且非常容易被低估的元素:动画。

动画的存在有几个原因,其中一个显然是让用户界面看起来更美观。不过,动画还能帮助用户理解程序的运行状态:当一个窗口消失而另一个窗口滑入时,用户能清楚地知道消失的窗口去了哪里,这也意味着他们能清楚地知道去哪里找回那个窗口。

在这个技术项目中,我们将学习 SwiftUI 中的一系列动画和过渡效果。有些很简单——事实上,你几乎能立刻做出很棒的效果!——但有些则需要更多思考。不过所有这些内容都很有用,尤其是当你努力让应用既具吸引力,又能尽可能好地引导用户视线时。

和之前几天一样,最好在 Xcode 项目中实践,这样你就能看到代码的实际运行效果,所以请创建一个名为 Animations 的新 App 项目。

创建隐式动画

作者:Paul Hudson 2023 年 10 月 23 日

在 SwiftUI 中,最简单的动画类型是“隐式动画”:我们提前告诉视图“如果有人想让你动起来,你应该这样响应”,除此之外无需其他操作。之后,SwiftUI 会确保所有发生的变化都遵循你指定的动画效果。实际上,这种动画实现起来非常简单——简单到不能再简单了。

我们从一个例子开始。下面的代码展示了一个简单的红色按钮,没有任何交互动作,设置了 50 点的内边距,并采用圆形裁剪形状:

swift
Button("Tap Me") {
    // 不执行任何操作
}
.padding(50)
.background(.red)
.foregroundStyle(.white)
.clipShape(.circle)

我们想要实现的效果是,每次点击按钮时,按钮都会变大。要实现这个效果,我们可以使用一个新的修饰符 scaleEffect()。你需要给它传入一个 0 及以上的值,视图会按照这个值对应的比例绘制——值为 1.0 相当于 100%,即按钮的原始大小。

因为我们希望每次点击按钮时改变缩放比例的值,所以需要使用一个 @State 属性来存储这个 Double 类型的值。现在,请在你的视图中添加这个属性:

swift
@State private var animationAmount = 1.0

现在,我们可以让按钮使用这个属性来设置缩放效果,添加以下修饰符:

swift
.scaleEffect(animationAmount)

最后,我们希望点击按钮时将动画数值增加 1,所以按钮的交互动作代码如下:

swift
animationAmount += 1

运行这段代码,你会发现反复点击按钮,按钮会不断变大。由于按钮不会以更高的分辨率重新绘制,所以当按钮变得越来越大时,你会发现它有点模糊,但这没关系。

人类的眼睛对运动非常敏感——我们能极其敏锐地察觉物体的移动或外观变化,这也是动画之所以重要且令人愉悦的原因。因此,我们可以让 SwiftUI 为这些变化创建隐式动画,使整个缩放过程平滑进行,只需给按钮添加 animation() 修饰符:

swift
.animation(.default, value: animationAmount)

这行代码告诉 SwiftUI,每当 animationAmount 的值发生变化时,就应用默认动画。这样一来,点击按钮时,你会立即看到按钮以动画的形式放大。

这种隐式动画会对视图中所有发生变化的属性生效,也就是说,如果给视图附加更多可动画化的修饰符,这些修饰符对应的效果都会一起变化。例如,我们可以给按钮再添加一个新的修饰符 .blur(),它能给视图添加高斯模糊效果,并可指定模糊半径——请在 animation() 修饰符之前添加这个修饰符:

swift
.blur(radius: (animationAmount - 1) * 3)

模糊半径 (animationAmount - 1) * 3 意味着,初始时模糊半径为 0(无模糊),之后随着你点击按钮,模糊半径会依次变为 3 点、6 点、9 点,以此类推。

再次运行应用,你会看到按钮现在既能平滑缩放,又能平滑模糊。

关键在于,我们并没有指定动画每一帧的样子,甚至没有指定 SwiftUI 应该何时开始和结束动画。相反,我们的动画和视图本身一样,都成为了状态的函数。

在 SwiftUI 中自定义动画

作者:Paul Hudson 2023 年 10 月 23 日

当我们给视图附加 animation() 修饰符时,只要我们观察的值发生变化,SwiftUI 就会自动使用系统默认的动画来让视图的变化呈现动画效果。实际上,系统默认的动画是一种非常平缓的弹簧动画,这意味着 iOS 会让动画先慢后快,在接近目标值时会稍微超出一点,然后再往回调整一点,最终到达目标状态。

我们可以通过给 animation() 修饰符传入不同的值来控制动画的类型。例如,我们可以使用 .linear 让动画以恒定的速度从开始运行到结束:

swift
.animation(.linear, value: animationAmount)

提示: 你可能会好奇,为什么隐式动画总是需要观察一个特定的值。如果不这样做,任何微小的变化(甚至将设备从竖屏旋转到横屏)都会触发动画,这看起来会很奇怪。

iOS 选择弹簧动画作为默认动画,是因为它模仿了我们在现实世界中常见的运动状态。弹簧动画的可定制性非常高:你可以大致控制弹簧动画的完成时间,还可以控制弹簧的弹性大小——即动画来回弹跳的幅度,1 表示最大弹性,0 表示无弹性。

例如,下面的代码会让按钮快速放大,然后有明显的弹跳效果:

swift
.animation(.spring(duration: 1, bounce: 0.9), value: animationAmount)

如果需要更精确的控制,我们可以自定义动画,并指定以秒为单位的持续时间。比如,我们可以创建一个持续 2 秒的缓入缓出动画:

swift
struct ContentView: View {
    @State private var animationAmount = 1.0

    var body: some View {
        Button("Tap Me") {
            animationAmount += 1
        }
        .padding(50)
        .background(.red)
        .foregroundStyle(.white)
        .clipShape(.circle)
        .scaleEffect(animationAmount)
        .animation(.easeInOut(duration: 2), value: animationAmount)
    }
}

当我们使用 .easeInOut(duration: 2) 时,实际上是创建了一个 Animation 结构体的实例,这个实例有自己的一系列修饰符。因此,我们可以直接给动画附加修饰符来添加延迟,如下所示:

swift
.animation(
    .easeInOut(duration: 2)
        .delay(1),
    value: animationAmount
)

添加这段代码后,点击按钮,会先等待 1 秒,然后再执行持续 2 秒的动画。

我们还可以让动画重复指定的次数,甚至通过将 autoreverses 设置为 true 来让动画来回播放。下面的代码创建了一个持续 1 秒的动画,在达到最终大小之前,按钮会上下弹跳:

swift
.animation(
    .easeInOut(duration: 1)
        .repeatCount(3, autoreverses: true),
    value: animationAmount
)

如果我们将重复次数设置为 2,那么按钮会先放大再缩小,然后立即跳回到放大后的尺寸。这是因为无论我们应用了什么动画,最终视图的状态都必须与程序的状态保持一致——动画结束时,按钮的状态必须与 animationAmount 中设置的值一致。

对于连续动画,我们可以使用 repeatForever() 修饰符,用法如下:

swift
.animation(
    .easeInOut(duration: 1)
        .repeatForever(autoreverses: true),
    value: animationAmount
)

我们可以将 repeatForever() 动画与 onAppear() 结合使用,创建在视图出现时立即开始并在视图存在期间持续播放的动画。

为了演示这一点,我们要先移除按钮本身的动画,转而给按钮添加一个覆盖层,制作一个类似按钮周围脉动的圆环效果。覆盖层通过 overlay() 修饰符创建,它能让我们在被覆盖视图的同一大小和位置上创建新的视图。

首先,在 animation() 修饰符之前,给按钮添加以下 overlay() 修饰符:

swift
.overlay(
    Circle()
        .stroke(.red)
        .scaleEffect(animationAmount)
        .opacity(2 - animationAmount)
)

这段代码会在按钮上方创建一个红色的描边圆环,不透明度设置为 2 - animationAmount。这意味着当 animationAmount 为 1 时,不透明度为 1(完全不透明);当 animationAmount 为 2 时,不透明度为 0(完全透明)。

接下来,移除按钮上的 scaleEffect()blur() 修饰符,同时注释掉 animationAmount += 1 这行交互动作代码(因为我们不再希望通过点击改变这个值),并将按钮的动画修饰符移到覆盖层内部的圆环上:

swift
.overlay(
    Circle()
        .stroke(.red)
        .scaleEffect(animationAmount)
        .opacity(2 - animationAmount)
        .animation(
            .easeOut(duration: 1)
                .repeatForever(autoreverses: false),
            value: animationAmount
        )
)

这里我将 autoreverses 改为了 false,除此之外,动画的其他设置保持不变。

最后,给按钮添加 onAppear() 修饰符,将 animationAmount 设置为 2:

swift
.onAppear {
    animationAmount = 2
}

由于覆盖层的圆环使用了“无限重复”且不反向播放的动画,你会看到覆盖层的圆环不断放大并逐渐淡出。

最终的代码如下:

swift
Button("Tap Me") {
    // animationAmount += 1
}
.padding(50)
.background(.red)
.foregroundStyle(.white)
.clipShape(.circle)
.overlay(
    Circle()
        .stroke(.red)
        .scaleEffect(animationAmount)
        .opacity(2 - animationAmount)
        .animation(
            .easeInOut(duration: 1)
                .repeatForever(autoreverses: false),
            value: animationAmount
        )
)
.onAppear {
    animationAmount = 2
}

只需这么少的代码,就能创建出非常吸引人的效果!

为绑定添加动画

作者:Paul Hudson 2024 年 4 月 30 日

animation() 修饰符可以应用到任何 SwiftUI 绑定上,这会让绑定的值在当前值和新值之间以动画的形式过渡。即便绑定的数据看起来不像是能被动画化的类型(比如布尔值),这种方式也依然有效。你可以在脑海中想象从 1.0 动画过渡到 2.0 的过程——我们可以通过 1.05、1.1、1.15 这样的中间值来实现,但从“false”过渡到“true”似乎不存在中间值。

用实际代码来解释会更清楚,下面是一个包含 VStackStepper(步进器)和 Button 的视图:

swift
struct ContentView: View {
    @State private var animationAmount = 1.0

    var body: some View {
        VStack {
            Stepper("Scale amount", value: $animationAmount.animation(), in: 1...10)

            Spacer()

            Button("Tap Me") {
                animationAmount += 1
            }
            .padding(40)
            .background(.red)
            .foregroundStyle(.white)
            .clipShape(.circle)
            .scaleEffect(animationAmount)
        }
    }
}

如你所见,步进器可以增减 animationAmount 的值,点击按钮也会给这个值加 1——它们都与同一个数据绑定,而这个数据又会影响按钮的大小。不过,点击按钮会立即改变 animationAmount 的值,所以按钮会直接跳转到更大的尺寸。与之不同的是,步进器绑定的是 $animationAmount.animation(),这意味着 SwiftUI 会自动为步进器引起的变化添加动画效果。

现在,我们来做一个实验,将 body 的开头部分修改为以下代码:

swift
var body: some View {
    print(animationAmount)

    return VStack {

由于这里包含了非视图代码,我们需要在 VStack 前添加 return,让 Swift 清楚哪部分是要返回的视图。不过,添加 print(animationAmount) 这行代码很重要,运行程序后,尝试操作步进器,你就能明白为什么要加这行代码。

你应该会看到控制台输出 2.0、3.0、4.0 等数值。与此同时,按钮会平滑地放大或缩小——它不会直接跳到 2 倍、3 倍或 4 倍的大小。实际上,SwiftUI 正在做的事情是:先检查绑定变化前视图的状态,再检查绑定变化后视图的目标状态,然后通过动画效果从初始状态过渡到目标状态。

这就是为什么我们能给布尔值的变化添加动画:Swift 并不是在 false 和 true 之间凭空创造新值,而是对布尔值变化所导致的视图变化添加动画。

这些绑定动画使用的 animation() 修饰符,与我们在视图上使用的修饰符类似,所以你可以根据需要自由添加动画修饰符:

swift
Stepper("Scale amount", value: $animationAmount.animation(
    .easeInOut(duration: 1)
        .repeatCount(3, autoreverses: true)
), in: 1...10)

提示: 使用这种 animation() 修饰符时,我们不需要指定要观察哪个值的变化——因为它本身就直接附加在要观察的值上!

这些绑定动画实际上与隐式动画的逻辑相反:隐式动画是在视图上设置动画,通过状态变化间接触发动画;而绑定动画则是不在视图上设置任何动画,通过状态变化直接触发动画。在隐式动画中,状态变化并不知道自己会触发动画;而在绑定动画中,视图并不知道自己会被设置动画——这两种方式都有效,且都很重要。

创建显式动画

作者:Paul Hudson 2023 年 10 月 23 日

你已经了解了 SwiftUI 中创建动画的两种方式:通过给视图附加 animation() 修饰符创建隐式动画,以及通过给绑定添加 animation() 修饰符创建绑定动画。不过,还有第三种实用的动画创建方式:明确要求 SwiftUI 对状态变化所导致的视图变化添加动画。

这并不意味着我们需要手动创建动画的每一帧——这仍然是 SwiftUI 的工作,它会通过对比状态变化前后视图的状态,来确定动画的效果。

但现在,我们是明确要求在某个任意的状态变化发生时执行动画:动画既不附加在绑定上,也不附加在视图上,只是我们明确要求,由于某个状态变化,需要执行特定的动画。

为了演示这一点,我们再回到一个简单的按钮示例:

swift
struct ContentView: View {   
    var body: some View {
        Button("Tap Me") {
            // 不执行任何操作
        }
        .padding(50)
        .background(.red)
        .foregroundStyle(.white)
        .clipShape(.circle)
    }
}

我们要实现的效果是,点击按钮时,按钮会以 3D 效果旋转。这需要用到另一个新的修饰符 rotation3DEffect(),它可以接收一个角度值(以度为单位),以及一个用于确定视图旋转轴的参数。可以把这个旋转轴想象成一根穿过视图的“烤串签”:

  • 如果我们让“签子”沿着 X 轴(水平方向)穿过视图,视图就能前后旋转。
  • 如果让“签子”沿着 Y 轴(垂直方向)穿过视图,视图就能左右旋转。
  • 如果让“签子”沿着 Z 轴(深度方向)穿过视图,视图也能左右旋转(与沿 Y 轴旋转效果不同)。

要实现这个效果,我们需要一个可修改的状态,旋转角度是 Double 类型,所以请添加以下属性:

swift
@State private var animationAmount = 0.0

接下来,我们让按钮沿着 Y 轴旋转 animationAmount 度,这样按钮就能左右旋转。给按钮添加以下修饰符:

swift
.rotation3DEffect(.degrees(animationAmount), axis: (x: 0, y: 1, z: 0))

现在到了关键部分:我们要在按钮的交互动作中添加代码,让每次点击按钮时,animationAmount 增加 360。

如果我们只写 animationAmount += 360,由于按钮上没有附加动画修饰符,这个变化会立即发生。这时候显式动画就派上用场了:如果我们使用 withAnimation() 函数,SwiftUI 会确保由新状态引起的所有视图变化都自动添加动画效果。

所以,在按钮的交互动作中添加以下代码:

swift
withAnimation {
    animationAmount += 360
}

现在运行代码,相信你会对效果感到惊讶——每次点击按钮,它都会在 3D 空间中旋转,而且实现起来非常简单。如果有时间,你可以尝试修改旋转轴,更好地理解它们的作用。顺便说一下,你也可以同时使用多个旋转轴。

withAnimation() 可以接收一个动画参数,这个参数可以是 SwiftUI 中任何可用的动画类型。例如,我们可以使用弹簧动画来实现旋转效果,代码如下:

swift
withAnimation(.spring(duration: 1, bounce: 0.5)) {
    animationAmount += 360
}

本站使用 VitePress 制作