[SwiftUI 100 天] Animations - part3

614 阅读6分钟
译自 Controlling the animation stack
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀

控制动画栈

在这一节,我想要把两个你已经理解的东西放在一起,单独理解都没问题,但放在一起可能有点伤脑筋。

之前我们已经理解 modifiers 的顺序会如何影响视图。因此,我们写过这样的代码:

Button("Tap Me") {
    // do nothing
}
.background(Color.blue)
.frame(width: 200, height: 200)
.foregroundColor(.white)

它跟下面这种代码的效果是不一样的:

Button("Tap Me") {
    // do nothing
}
.frame(width: 200, height: 200)    
.background(Color.blue)
.foregroundColor(.white)

这是因为如果我们在调整 frame 之前给背景上色的话,只有原始的区域被上色,而不是之后 frame 扩展之后的区域。回忆一下,这种表象下面是 SwiftUI 用 modifier 包裹视图的机制,它使得我们可以多次应用相同的 modifier —— 我们可以通过重复多次 background() 和 padding() 来创建一个有一层层条纹的边框效果。

这里是第一个概念:modifier 的顺序很重要,因为 SwiftUI 以 modifiers 应用的顺序来包裹视图。

第二个概念是我们可以应用一个 animation() modifier 到一个视图,以便这个视图能获得一个隐式的动画。

为了演示上面两个概念,我们可以修改按钮的代码,让它根据不同的状态展示不同的颜色。首先,我们需要定义状态:

@State private var enabled = false

在按钮的 action 中切换启动状态:

self.enabled.toggle()

然后在 background() modifier 里用一个条件化的值,让按钮即可以是蓝色的,也可以是红色的:

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

最后,我们添加 animation() modifier 给按钮,让上面那些变化都以动画的方式呈现:

.animation(.default)

运行 app ,点击按钮,你会看到按钮在蓝色和红色之间变化。

目前为止,modifier 顺序很重要,并且我们可以给一个视图多次添加同一个 modifier ,我们还可以用 animation() modifier 创建隐式动画。这些都很清晰对吧?

是的,接下来是伤脑筋的地方。

你可以多次使用 animation() modifier ,并且你使用它们的顺序也很重要。

下面是演示时刻。我们把这个 modifier 加到按钮所有的 modifiers 之后:

.clipShape(RoundedRectangle(cornerRadius: enabled ? 60 : 0))

这会让按钮根据 enabled 状态在方形和圆角矩形之前变换。

当你运行程序,点击按钮,你会看到按钮会以动画的方式在红色和蓝色之间变化,但方形和圆角矩形之间的变换确是直接跳变的 —— 这部分没有执行动画。

希望你能想到我们接下来要做什么:我需要你把 clipShape() modifier 移到 animation 之前,像这样:

.frame(width: 200, height: 200)
.background(enabled ? Color.blue : Color.red)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: enabled ? 60 : 0))
.animation(.default)

当你再次运行代码,你会发现背景色和按钮形状都会执行动画了。

因此,我们应用动画的顺序也很重要:只有发生 animation() modifier 之前的变化才会以动画的方式呈现。

接下来是有趣的部分:如果我们应用多个 animation() modifiers ,每一个动画会控制在它前面直到上一个动画的所有视图。这种特性使得我们可以用各种方式动画演示属性的变化,而不局限于一种方式。

举个例子,我们可以让颜色变化以默认动画的方式呈现,让形状变化以弹簧动画的方式呈现:

Button("Tap Me") {
    self.enabled.toggle()
}
.frame(width: 200, height: 200)
.background(enabled ? Color.blue : Color.red)
.animation(.default)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: enabled ? 60 : 0))
.animation(.interpolatingSpring(stiffness: 10, damping: 1))

你可以根据需要使用任意多的 animation() modifiers 来构建你的设计,这让我们可以把一个状态变化按需拆解成许多部分。

你还可以通过传入 nil 来禁用动画。举个例子,你可能希望颜色变化立即发生,而形状变化保持动画,那么可以把代码改成这样:

Button("Tap Me") {
    self.enabled.toggle()
}
.frame(width: 200, height: 200)
.background(enabled ? Color.blue : Color.red)
.animation(nil)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: enabled ? 60 : 0))
.animation(.interpolatingSpring(stiffness: 10, damping: 1))

上面这种程度的控制,如果没有多个 animation() modifiers 是无法实现的。


译自 Animating gestures

动画手势

SwiftUI 允许我们给任意视图添加手势支持,并且这些手势的效果也能以动画呈现。我们稍后再来看手势的细节,现在让我们先尝试一个很简单的东西:一个可以在屏幕上拖来拖去的卡片,放手的时候自动跳过原来的位置。

首先是初始布局:

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

上面的代码在屏幕中央绘制了一个卡片样子的视图。我们希望能基于手指的位置来在屏幕上移动它,一共需要三步。

第一步,我们需要一个存储拖拽数量的状态:

@State private var dragAmount = CGSize.zero

第二步,我们用这个数值来影响卡片在屏幕上的位置。SwiftUI 有一个专门的 modifier 实现这种需求,它叫 offset() ,可以让我们调整视图的 X 和 Y 坐标,不影响这个视图周围的其他视图。你可以传入具体的 X 坐标和 Y 坐标,或者 offset() 也可以直接接收一个 CGSize 。

把下面的 modifier 加到卡片的 gradient 。

.offset(dragAmount)

接下来是重要的部分。我们要创建一个 DragGesture 并且附着到卡片。Drag gestures 有两个 modifiers 我们会用到。一个是 onChanged() ,它在用户手指在屏幕上移动时执行一个闭包,第二个是 onEnded() ,在用户手指从屏幕上抬起时执行一个闭包,结束拖拽过程。

两个闭包都提供一个参数,描述拖拽操作 —— 从哪里开始,现在位于哪里,移动了多远,等等。对于 onChanged() modifier ,我们将读取拖放的平移量,这个值告诉我们从起点移动了多远 —— 我们把它直接赋给 dragAmount 以便视图跟着手势一起移动。对于 onEnded() ,我们将完全忽略输入,因为这里我们要把 dragAmount 设回 0 。

把这个 modifier 添加到 linear gradient :

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

运行代码,拖拽卡片,当你放手的时候,卡片会直接跳回中央。卡片的偏移量是由 dragAmount 决定的,最终由拖拽手势决定。

现在,逻辑都已经工作,我们需要让它们以动画的方式鲜活起来,这里有两个选项:添加一个隐式动画,演示拖拽和释放,或者只添加一个显式的动画,只演示释放。

对于隐式动画的选项,添加这个 modifier 到 linear gradient :

.animation(.spring())

当你拖拽卡片时,卡片会稍稍延迟一下,然后移动到拖拽的位置,这是由于弹簧动画的关系。如果你突然移动的话,弹簧动画还会呈现出超过位置的效果。

对于显式动画的选项:我们去掉 animation() modifier ,然后把拖拽手势的 onEnded() 代码改成这样:

.onEnded { _ in
    withAnimation(.spring()) {
        self.dragAmount = .zero
    }
}

这样一来卡片会立即跟随你的拖拽 (因为已经不做动画了) ,而释放过程会有动画。

如果我们以一些延迟来结合 offset 动画和拖拽动作,可以用很少的代码创建出非常有趣的动画。

下面演示这种动画,我们先以一组文本视图的形式呈现 “Hello SwiftUI” 的每个字母,每个字母都有背景色和由状态控制的偏移量。由于字符串本质上可以理解为字符数组的高级版本,所以我们可以通过字符串得到一个字符数组,就像这样:Array("Hello SwiftUI")

先奉上代码:

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) { num in
                Text(String(self.letters[num]))
                    .padding(5)
                    .font(.title)
                    .background(self.enabled ? Color.blue : Color.red)
                    .offset(self.dragAmount)
                    .animation(Animation.default.delay(Double(num) / 20))
            }
        }
        .gesture(
            DragGesture()
                .onChanged { self.dragAmount = $0.translation }
                .onEnded { _ in
                    self.dragAmount = .zero
                    self.enabled.toggle()
                }
        )
    }
}

运行代码,你可以拖拽任意一个字母,然后你会发现整个字符串的其他字母都会跟随这个字母移动,并且伴随不同数量的延迟,整个效果就像蛇行一样。SwiftUI 还会在你手指释放时用动画呈现字母蓝色和红色之间的变化,伴随字母移动到中央的过程。


我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~