2024-07-10 14:43:51
,某些文章具有时效性,若有错误或已失效,请在下方留言。SwiftUI 的过渡系统允许我们自定义插入或移除视图的方式,可以在要过渡的视图周围插入一系列新视图、创建本地状态、添加复杂的动画等等。
本文创建的效果,如下所示

添加新的视图修饰符
struct ConfettiModifier: ViewModifier {
private let speed = 0.3
var color: Color
var size: Double
func body(content: Content) -> some View {
content
}
}
body()
方法现在什么也不做,它只是渲染任何给定的内容。
为了使其更易于使用,我们将扩展 AnyTransition
并为其提供一个属性和一个方法,就像 SwiftUI 自己的内置过渡一样。
extension AnyTransition {
static var confetti: AnyTransition {
.modifier(
active: ConfettiModifier(color: .blue, size: 3),
identity: ConfettiModifier(color: .blue, size: 3)
)
}
static func confetti(color: Color = .blue, size: Double = 3.0) -> AnyTransition {
AnyTransition.modifier(
active: ConfettiModifier(color: color, size: size),
identity: ConfettiModifier(color: color, size: size)
)
}
}
最后,我们可以创建一个简单的测试视图,使用三种字体渲染 SF 符号,这样你就可以看到不同尺寸下的效果:
struct Advanced_transitions: View {
@State private var isFavorite = false
var body: some View {
VStack(spacing: 60) {
ForEach([Font.body, Font.largeTitle, Font.system(size: 72)], id: \.self) { font in
Button {
isFavorite.toggle()
} label: {
if isFavorite {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.transition(.confetti(color: .red, size: 3))
} else {
Image(systemName: "heart")
.foregroundStyle(.gray)
}
}
}
}
}
}

动画的第一步是创建一个从中心向外扩散的圆,这意味着它开始时非常小,然后不断扩大以填满所有可用空间,然后再扩大一些。
要制作这个圆圈动画,我们首先要为 ConfettiModifier
添加一个新属性,以跟踪圆圈的增大,并从一个非常小的值开始:
@State private var circleSize = 0.00001
继续修改 body()
方法:
struct ConfettiModifier: ViewModifier {
private let speed = 0.3
@State private var circleSize = 0.00001
var color: Color
var size: Double
func body(content: Content) -> some View {
content
.hidden()
.padding(10)
.overlay {
// circle here
}
.padding(10)
.onAppear {
withAnimation(.easeIn(duration: speed)) {
circleSize = 1
}
}
}
}
这个圆需要填充颜色,但动画的第二步是让圆从内向外收缩。也就是说,一旦圆长到最大,它就会开始变空,并且越缩越细,直到最后消失。
要获得这种效果,我们需要对圆进行描边而不是填充,特别是要确保整个描边都在圆内,而且描边宽度正好是可用空间的一半,这样圆就会始终被完全填充。
使用 GeometryReader
恰好获得一半的可用空间,因为我们稍后还要添加五颜六色的彩纸,所以我们要把彩纸放在 ZStack
中。因此,请将 // circle here
注释替换为以下内容:
ZStack {
GeometryReader { proxy in
Circle()
.strokeBorder(color, lineWidth: proxy.size.width / 2)
.scaleEffect(circleSize)
}
}
再次运行应用程序,你会看到我们的第一步动画已经完成。

第二个动画步骤是我们需要将圆挖空。这其实非常简单,因为我们使用的是 strokeBorder()
:如果我们减少描边的 lineWidth
,它就会自动为我们挖空圆。
因此,首先要添加一个新属性来跟踪我们想要绘制的笔划的数量:
@State private var strokeMultiplier = 1.0
其次,修改 strokeBorder()
修饰符,将行宽乘以该乘数:
.strokeBorder(color, lineWidth: proxy.size.width / 2 * strokeMultiplier)
最后,在前一个调用之后再添加一个 withAnimation()
调用,将 strokeMultiplier
设置为一个很小的值:
withAnimation(.easeOut(duration: speed).delay(speed)) {
strokeMultiplier = 0.00001
}
请注意,我将该动画延迟了 speed
秒,这样它就会等到前一个动画完成后才开始播放。

动画的第三步是让一些彩色纸屑从圆的边缘飞出。这些彩纸的动作相当精确:它们从圆圈边缘附近开始,向外移动一小段距离,然后收缩消失。
通过为 ConfettiModifier
添加三个新属性,我们可以获得类似的效果:一个用于跟踪纸屑是否可见,一个用于跟踪纸屑移动了多远,第三个用于跟踪纸屑的大小。我们将测量相对于 GeometryReader
大小的移动,其中 1.0 表示 “视图的最边缘”。
现在就把这三个添加到 ConfettiModifier
中:
@State private var confettiIsHidden = true
@State private var confettiMovement = 0.7
@State private var confettiScale = 1.0
我们需要将这些值动画化为其他值,即 false、1.2 和 0.00001,这意味着需要在调用其他值的同时再添加两个 withAnimation()
调用。这些调用不需要按照任何特定的顺序进行,因为它们并不相互依赖,但按照它们的执行顺序进行结构化通常是个好主意。
withAnimation(.easeOut(duration: speed).delay(speed * 1.25)) {
confettiIsHidden = false
confettiMovement = 1.2
}
withAnimation(.easeOut(duration: speed).delay(speed * 2)) {
confettiScale = 0.00001
}
绘制纸屑颗粒需要更多的工作,事实上,这是整个过渡中最复杂的部分,因为有很多非常精确的修饰符。为了让大家更容易理解,我将把它分成几个小部分,先从简单的部分开始–在圆圈代码下方的 GeometryReader
内添加以下内容:
ForEach(0..<15) { i in
Circle()
.fill(color)
// more modifiers to come
}
首先,我们需要给这些圆添加一个框架,否则它们都会变得很大。我们已经添加了一个 size
属性,但如果我们通过 sin()
传递 i
循环变量,我们就可以稍微调节一下大小 – 一些纸屑会大一些,一些会小一些。
立即添加此修饰符:
.frame(width: size + sin(Double(i)), height: size + sin(Double(i)))
接下来,我们需要根据 confettiScale
的值来放大或缩小彩纸,这很容易:
.scaleEffect(confettiScale)
接下来,我们需要在特效动画中将彩纸向外移动。这意味着我们要以半径(proxy 宽度的一半)乘以 confettiMovement
中的值,将圆向外推。当视图首次创建时,由于 confettiMovement
的初始值为 0.7,因此它们将处于半径的 70%,但我们的动画会将它们移动到半径的 120%,因此它们会向外飞很远。
现在,我们可以使用以下修饰符来实现这一功能:
.offset(x: proxy.size.width / 2 * confettiMovement)
这样做是可行的,但有点无趣,因为每一片纸屑都会移动相同的距离。为了让事情更有变化,我们要给每一片纸屑添加一些额外的移动,就像这样:
.offset(x: proxy.size.width / 2 * confettiMovement + (i.isMultiple(of: 2) ? size : 0))
此时,我们已经将所有纸屑都移到了圆的一侧,但它们都在同一侧。因此,我们的下一步是将圆形旋转 24 次 i
,使其遍布整个圆形,之所以使用 24 次,是因为我们创建了 15 个圆形–24 x 15 等于 360,涵盖了所有角度。
立即添加此修饰符:
.rotationEffect(.degrees(24 * Double(i)))
我们还需要使用两个修饰符,但第一个修饰符可能有点令人困惑:我们将再次使用 offset()
。
如果把发生的事情细分一下,就会明白其中的原因:
- 当我们在
GeometryReader
中创建视图时,视图会放置在左上角。 - 我们的第一个
offset()
将彩纸视图横向推过GeometryReader
的一半。 - 我们的
rotationEffect()
修饰符会使这些视图围绕其原点旋转,原点同样是左上角。 - 因此,我们的彩纸视图现在围绕
GeometryReader
的左上角展开了一圈。 - 我们希望它们居中,这意味着要再次偏移,这次是 proxy 宽度和高度的一半。
现在,虽然纸屑的视图很小,但要真正做到精确,我们需要从这些偏移量中减去一半的纸屑大小,因为我们要确保粒子居中,而不是从左上方定位。
立即添加此修饰符:
.offset(x: (proxy.size.width - size) / 2, y: (proxy.size.height - size) / 2)
希望你能明白为什么需要这样做,如果不明白,可以在看到修饰符工作后,尝试注释掉它,当它不工作时,问题就一目了然了!
最后一个修饰符是为了确保我们的彩纸在我们说准备好之前一直隐藏起来,就像这样:
.opacity(confettiIsHidden ? 0 : 1)
完成后的代码如下所示
ForEach(0..<15) { i in
Circle()
.fill(color)
.frame(width: size + sin(Double(i)), height: size + sin(Double(i)))
.scaleEffect(confettiScale)
.offset(x: proxy.size.width / 2 * confettiMovement + (i.isMultiple(of: 2) ? size : 0))
.rotationEffect(.degrees(24 * Double(i)))
.offset(x: (proxy.size.width - size) / 2, y: (proxy.size.height - size) / 2)
.opacity(confettiIsHidden ? 0 : 1)
}

最后一步要做:我们填满的心形图标需要从中心弹出,跳到最终位置。
就像我们的其他工作一样,这意味着添加一个属性来跟踪它的移动,在布局中的某个地方放置一个视图,然后对它进行动画处理。从这个新属性开始:
@State private var contentsScale = 0.00001
该视图非常简单,因为它只是传入方法的 content
参数,只是带有缩放效果,因此我们可以将其制成动画。将此添加到 GeometryReader
之后,但仍在 ZStack
内:
content
.scaleEffect(contentsScale)
现在,为了完成整个效果,我们需要在其他调用旁边添加最后一个 withAnimation()
调用。我在前面说过,按执行顺序编排动画代码是个好主意,但在这里比较麻烦,因为我们需要的是一个弹簧动画,而不是一个特定的持续时间。因此,如果我是你,我会把它放在四个现有动画的中间– strokeMultiplier
动画之后, confettiIsHidden
动画之前–因为它有一个延迟,可以很好地适应这个位置。
现在就添加这个最终代码:
withAnimation(.interpolatingSpring(stiffness: 50, damping: 5).delay(speed)) {
contentsScale = 1
}

希望这能让你对 SwiftUI 的过渡功能有一个很好的了解–由于可以插入完全自定义的视图和动画,它们的功能真的是无限的。
暂无评论内容