Skip to content

第33天 项目 6 第二部分

今天我们将深入学习更高级的动画,通过这部分内容,你会更深入地理解动画的工作原理,以及如何在很大程度上自定义动画。

德国有一位著名的工业设计师叫迪特·拉姆斯(Dieter Rams)。你可能没听说过他,但肯定见过他的作品——多年来,他的设计极大地启发了苹果自身的设计,从iPod到iMac,再到Mac Pro,都有他设计理念的影子。他曾说过:“好的设计能让事物清晰易懂且令人难忘;伟大的设计能让事物既令人难忘又富有意义。”

SwiftUI强大的动画系统让我们能轻松创建令人难忘的动画,但“富有意义”这一点则取决于你——你的动画仅仅是看起来好看,还是能向用户传递额外信息?

这并不是说动画不能只追求美观;在应用开发中,总有一些空间可以容纳奇思妙想。但当发生重要变化时,我们有必要帮助用户理解变化的内容和原因。在SwiftUI中,这在很大程度上是“过渡效果”(transitions)的职责,今天你就会接触到它。

今天你需要学习四个主题,从中你将了解多重动画、手势动画、过渡效果等更多内容。

  • 控制动画栈
  • 为手势添加动画
  • 通过视图显示和隐藏添加过渡效果
  • 使用ViewModifier构建自定义自定义过渡效果

控制动画栈

作者:Paul Hudson 2024年4月30日

到目前为止,我想把你已经分别理解的两个知识点结合起来讲,不过这两者结合起来可能会让你有点困惑。

之前我们学习过修饰符的顺序很重要。比如,如果我们编写这样的代码:

swift
Button("点击我") {
    // 不执行任何操作
}
.background(.blue)
.frame(width: 200, height: 200)
.foregroundStyle(.white)

其结果会和下面这段代码的效果不同:

swift
Button("点击我") {
    // 不执行任何操作
}
.frame(width: 200, height: 200)    
.background(.blue)
.foregroundStyle(.white)

这是因为,如果我们在调整视图框架(frame)之前设置背景颜色,那么只有视图原来的区域会被着色,而不是扩大后的区域。如果你还记得,背后的原因是SwiftUI会用修饰符对视图进行包装,这样我们就可以多次应用同一个修饰符——我们之前就多次重复使用background()padding()来创建条纹边框效果。

这是第一个概念:修饰符的顺序很重要,因为SwiftUI会按照修饰符的应用顺序用它们来包装视图。

第二个概念是,我们可以给视图应用animation()修饰符,从而让视图的变化产生隐式动画。

为了演示这一点,我们可以修改按钮代码,让按钮根据某种状态显示不同的颜色。首先,我们定义一个状态:

swift
@State private var enabled = false

然后在按钮的动作闭包中切换这个状态的值:

swift
enabled.toggle()

接着,我们可以在background()修饰符中使用条件表达式,让按钮在蓝色和红色之间切换:

swift
.background(enabled ? .blue : .red)

最后,给按钮添加animation()修饰符,让颜色变化产生动画效果:

swift
.animation(.default, value: enabled)

运行这段代码,你会看到点击按钮时,按钮的颜色会在蓝色和红色之间平滑过渡。

所以,总结一下:修饰符的顺序很重要,而且我们可以给一个视图多次应用同一个修饰符;同时,我们可以通过animation()修饰符让视图产生隐式动画。到这里都清楚吗?

好的,做好准备,接下来的内容可能会有点难理解。

我们可以多次给视图应用animation()修饰符,而且应用的顺序也很重要。

为了演示这一点,我希望你在按钮的所有其他修饰符之后添加这个修饰符:

swift
.clipShape(.rect(cornerRadius: enabled ? 60 : 0))

这样,根据enabled布尔值的状态,按钮会在正方形和圆角矩形矩形之间切换。

运行程序后,你会发现点击按钮时,按钮的颜色会在红色和蓝色之间产生动画效果,但形状会在正方形和圆角矩形矩形之间“跳跃”——这部分没有动画效果。

希望你能猜到接下来要做什么:我希望你把clipShape()修饰符移到animation()修饰符之前,像这样:

swift
.frame(width: 200, height: 200)
.background(enabled ? .blue : .red)
.foregroundStyle(.white)
.clipShape(.rect(cornerRadius: enabled ? 60 : 0))
.animation(.default, value: enabled)

现在运行代码,你会发现背景颜色和裁剪形状的变化都有动画效果了。

由此可见,应用动画的顺序很重要:只有在animation()修饰符之前发生的变化,才会产生动画效果。

接下来是更有趣的部分:如果我们应用多个animation()修饰符,那么每个animation()修饰符会控制它之前到下一个animation()修饰符之间的所有视图变化。这样一来,我们就可以用各种不同的方式为状态变化设置动画,而不是对所有属性都使用统一的动画效果。

例如,我们可以让颜色变化使用默认动画,而让裁剪形状(clip shape)的变化使用弹簧动画:

swift
Button("点击我") {
    enabled.toggle()
}
.frame(width: 200, height: 200)
.background(enabled ? .blue : .red)
.animation(.default, value: enabled)
.foregroundStyle(.white)
.clipShape(.rect(cornerRadius: enabled ? 60 : 0))
.animation(.spring(duration: 1, bounce: 0.6), value: enabled)

你可以根据设计需求,添加任意多个animation()修饰符,这样就能将一次状态变化拆分成多个部分,分别设置动画。

如果想进一步控制,我们还可以给animation()修饰符传递nil,从而完全完全禁用动画。例如,你可能希望颜色变化立即生效,而裁剪形状变化保留动画效果,这时就可以这样写:

swift
Button("点击我") {
    enabled.toggle()
}
.frame(width: 200, height: 200)
.background(enabled ? .blue : .red)
.animation(nil, value: enabled)
.foregroundStyle(.white)
.clipShape(.rect(cornerRadius: enabled ? 60 : 0))
.animation(.spring(duration: 1, bounce: 0.6), value: enabled)

如果没有多个animation()修饰符,这种控制是无法实现的——如果你尝试把background()修饰符移到animation()修饰符之后,就会发现它会抵消clipShape()修饰符的作用。

为手势添加动画

作者:Paul Hudson 2023年10月23日

SwiftUI允许我们给任何视图添加手势,而且这些手势产生的效果也可以添加动画。我们可以使用多种手势,比如让任意视图响应点击的轻击手势(tap gesture)、响应手指在视图上拖动的拖动手势(drag gesture)等等。

之后我们会更详细地学习手势,不过现在先尝试一个相对简单的案例:创建一张卡片,我们可以在屏幕上拖动它,但松开手指后,它会弹回原来的位置。

首先,我们来构建初始布局:

swift
struct ContentView: View {
    var body: some View {
        LinearGradient(colors: [.yellow, .red], startPoint: .topLeading, endPoint: .bottomTrailing)
            .frame(width: 300, height: 200)
            .clipShape(.rect(cornerRadius: 10))
    }
}

这段代码会在屏幕中央绘制一个类似卡片的视图。要让它能根据手指的位置在屏幕上移动,需要分三步进行。

第一步,我们需要一个状态来存储拖动的偏移量:

swift
@State private var dragAmount = CGSize.zero

第二步,我们要利用这个偏移量来控制卡片在屏幕上的位置。SwiftUI提供了一个专门的修饰符offset(),它可以调整视图的X坐标和Y坐标,而且不会影响其他视图的位置。你可以分别传入X和Y的坐标值,不过——绝非巧合——offset()也可以直接接收一个CGSize类型的值。

所以,第二步就是给渐变卡片添加这个修饰符:

swift
.offset(dragAmount)

接下来是关键部分:我们可以创建一个DragGesture(拖动手势),并把它附加到卡片上。拖动手势有两个对我们很有用的额外修饰符:onChanged()允许我们在用户移动手指时执行一个闭包,onEnded()允许我们在用户松开手指、结束拖动时执行一个闭包。

这两个闭包都接收一个参数,该参数描述了拖动操作的相关信息——拖动的起始位置、当前位置、移动的距离等等。在onChanged()修饰符中,我们会读取拖动的“位移”(translation),它表示拖动操作相对于起始点移动了多少距离——我们可以直接把这个位移值赋给dragAmount,这样视图就能跟着手势一起移动。在onEnded()修饰符中,我们会忽略传入的参数,因为我们要把dragAmount重置为零。

现在,给线性渐变添加这个修饰符:

swift
.gesture(
    DragGesture()
        .onChanged { dragAmount = $0.translation }
        .onEnded { _ in dragAmount = .zero }
)

运行代码,你会发现现在可以拖动渐变卡片了,松开手指后,卡片会跳回屏幕中央。卡片的偏移量由dragAmount决定,而dragAmount的值又由拖动手势来设置。

现在功能已经实现,我们可以给这个移动效果添加动画,有两种选择:添加一个隐式动画,让拖动过程和回弹过程都产生动画;或者添加一个显式动画,只让回弹过程产生动画。

要实现第一种效果,给线性渐变添加这个修饰符:

swift
.animation(.bouncy, value: dragAmount)

提示: .bouncy是SwiftUI内置的动画选项之一,会产生带有轻微弹跳效果的弹簧动画。

拖动卡片时,由于弹簧动画的作用,卡片会有轻微的延迟才会跟上手势的移动;如果你的动作突然,卡片还会有轻微的“过冲”效果。

要实现显式动画,先移除上面的animation()修饰符,然后把现有的拖动手势中onEnded()的代码改成这样:

swift
.onEnded { _ in
    withAnimation(.bouncy) {
        dragAmount = .zero
    }
}

现在,拖动卡片时,卡片会立即跟上手势(因为拖动过程没有添加动画),但松开手指后,卡片会以动画的形式弹回原位。

如果我们把偏移动画、拖动手势和一点延迟结合起来,不用写太多代码就能创建出非常有趣的动画效果。

为了演示这一点,我们可以把文本“Hello SwiftUI”拆分成一个个单独的字母,每个字母都有背景颜色和由某个状态控制的偏移量。字符串本质上是一种特殊的字符数组,所以我们可以像这样把字符串转换成一个真正的数组:Array("Hello SwiftUI")

好了,试试下面这段代码,看看效果如何:

swift
struct ContentView: View {
    let letters = Array("Hello SwiftUI")
    @State private var enabled = false
    @State private var dragAmount = CGSize.zero

    var body: some View {
        HStack(spacing: 0) {
            ForEach(0..<letters.count, id: \.self) { num in
                Text(String(letters[num]))
                    .padding(5)
                    .font(.title)
                    .background(enabled ? .blue : .red)
                    .offset(dragAmount)
                    .animation(.linear.delay(Double(num) / 20), value: dragAmount)
            }
        }
        .gesture(
            DragGesture()
                .onChanged { dragAmount = $0.translation }
                .onEnded { _ in
                    dragAmount = .zero
                    enabled.toggle()
                }
        )
    }
}

运行这段代码,你会发现拖动任意一个字母,整个字符串都会跟着移动,而且由于延迟的存在,会产生一种类似“蛇形”的效果。松开手指时,SwiftUI还会同时执行颜色变化的动画,字母在回到屏幕中央的过程中,颜色会在蓝色和红色之间平滑过渡。

为视图显示和隐藏添加过渡效果

作者:Paul Hudson 2023年10月23日

SwiftUI最强大的功能之一,就是能够自定义视图显示和隐藏的方式。之前你已经了解过,我们可以使用普通的if条件语句来有条件地显示视图,这意味着当条件变化时,我们可以在视图层级中插入或移除视图。

过渡效果(transitions)控制着这些插入和移除操作的呈现方式,我们可以使用内置的过渡效果、以不同方式组合它们,甚至创建完全自定义的过渡效果。

为了演示这一点,我们先创建一个包含按钮和矩形的VStack

swift
struct ContentView: View {
    var body: some View {
        VStack {
            Button("点击我") {
                // 不执行任何操作
            }

            Rectangle()
                .fill(.red)
                .frame(width: 200, height: 200)
        }
    }
}

我们可以让矩形只在满足特定条件时才显示。首先,添加一个我们可以操作的状态:

swift
@State private var isShowingRed = false

然后,用这个状态作为显示矩形的条件:

swift
if isShowingRed {
    Rectangle()
        .fill(.red)
        .frame(width: 200, height: 200)
}

最后,在按钮的动作闭包中切换isShowingRed的值:

swift
isShowingRed.toggle()

运行程序,你会发现点击按钮时,红色正方形会显示或隐藏,但没有任何动画效果,只是突然出现或消失。

我们可以用withAnimation()包裹状态变化的代码,从而让SwiftUI使用默认的视图过渡效果,像这样:

swift
withAnimation {
    isShowingRed.toggle()
}

只需这一小处修改,应用就会让红色矩形淡入淡出,同时按钮会向上移动以腾出空间。这个效果还不错,但我们可以通过transition()修饰符让它变得更好。

例如,我们可以给矩形添加transition()修饰符,让它在显示时进行缩放:

swift
Rectangle()
    .fill(.red)
    .frame(width: 200, height: 200)
    .transition(.scale)

现在点击按钮,效果会好很多:矩形缩放着显示出来,同时按钮向上移动;再次点击按钮,矩形又会缩放着消失。

如果你想尝试其他过渡效果,还有一些内置选项可供选择。其中一个很有用的是.asymmetric,它允许我们在视图显示时使用一种过渡效果,在视图消失时使用另一种过渡效果。要尝试这个效果,可以把矩形现有的过渡效果替换成这样:

swift
.transition(.asymmetric(insertion: .scale, removal: .opacity))

使用ViewModifier创建自定义过渡效果

作者:Paul Hudson 2023年10月23日

为SwiftUI创建全新的过渡效果是完全可能的,而且实际上非常简单,这让我们能够通过完全自定义的动画来添加和移除视图。

实现这一功能的关键是.modifier过渡效果,它可以接收我们想要的任何视图修饰符。不过有一点需要注意:我们需要能够初始化这个修饰符,这意味着它必须是我们自己创建的修饰符。

为了演示这一点,我们可以编写一个视图修饰符,模拟Keynote中的“旋转”(Pivot)动画——这种动画会让新幻灯片从左上角旋转进入屏幕。用SwiftUI的术语来说,这意味着我们要创建一个视图修饰符,让视图从某个角旋转进入屏幕,而且不会超出它应有的边界。SwiftUI实际上提供了专门的修饰符来实现这个效果:rotationEffect()可以让视图在二维空间中旋转,clipped()可以防止视图在其矩形边界外被绘制出来。

rotationEffect()rotation3DEffect()类似,只不过它总是围绕Z轴旋转。不过,它还允许我们控制旋转的“锚点”——即视图旋转时固定不动的中心点。SwiftUI提供了UnitPoint类型来控制锚点,我们可以用它指定旋转的精确X/Y坐标,也可以使用许多内置的选项,比如.topLeading(左上角)、.bottomTrailing(右下角)、.center(中心)等等。

让我们把这些想法整合到代码中,创建一个CornerRotateModifier结构体,它包含一个用于控制旋转锚点的属性和一个用于控制旋转角度的属性:

swift
struct CornerRotateModifier: ViewModifier {
    let amount: Double
    let anchor: UnitPoint

    func body(content: Content) -> some View {
        content
            .rotationEffect(.degrees(amount), anchor: anchor)
            .clipped()
    }
}

这里添加的clipped()修饰符,会确保视图旋转时,超出其自然矩形边界的部分不会被绘制出来。

我们可以直接使用.modifier过渡效果来尝试这个自定义修饰符,但这样做会有点繁琐。一个更好的方法是,把它封装到AnyTransition的扩展中,让视图从左上角以-90度旋转到0度:

swift
extension AnyTransition {
    static var pivot: AnyTransition {
        .modifier(
            active: CornerRotateModifier(amount: -90, anchor: .topLeading),
            identity: CornerRotateModifier(amount: 0, anchor: .topLeading)
        )
    }
}

这样一来,我们就可以用下面的代码给任何视图添加这个旋转过渡效果:

swift
.transition(.pivot)

例如,我们可以使用onTapGesture()修饰符,让一个红色矩形以旋转的方式出现在屏幕上,代码如下:

swift
struct ContentView: View {
    @State private var isShowingRed = false

    var body: some View {
        ZStack {
            Rectangle()
                .fill(.blue)
                .frame(width: 200, height: 200)

            if isShowingRed {
                Rectangle()
                    .fill(.red)
                    .frame(width: 200, height: 200)
                    .transition(.pivot)
            }
        }
        .onTapGesture {
            withAnimation {
                isShowingRed.toggle()
            }
        }
    }
}

本站使用 VitePress 制作