SwiftUI 动画基础

在 SwiftUI 中,只需要定义【开始】和【结束】的两个状态,剩下两个状态之间的【变化阶段】,系统将自动为你处理。一般数值型的参数属性,都可以呈现动画。您可以通过不同的方式来控制变化,例如速度和持续时间。SwiftUI 动画大体设置原理是:

  1. 一个触发器(例如点击按钮),触发了一个 @State 状态值的改变
  2. @State 状态值的改变引发了视图的某个参数值的改变
  3. 视图的参数值的改变,触发动画修饰符的发生

Screenshot - 2023-09-26 11.35.21.png


Animation 结构体实例

当我们使用 .default.easeInOut 这些动画曲线时,实际上是在创建一个 Animation 结构体的实例,它们有自己的属性和修饰符。您可以从各种内置选项中指定所需曲线,包括以下这些。它们的语法都一样,都是 .animation(.xxxxxx, value: )

参数:持续 duration

如果没有设置该属性,则SwiftUI会用默认的时间,大概是0.4秒左右完成动画

// 完成时间 2 秒
.animation(.easeOut(duration: 2))

// 完成时间 15 秒
.animation(.linear(duration: 15))

修饰符:速度 speed

设置速度 和 duration 一样,也可以控制一个动画完成的快慢。

.animation(Animation.easeInOut(duration: duration))
.animation(Animation.easeInOut(duration: duration).speed(2))
.animation(Animation.easeInOut(duration: duration).speed(0.5))

修饰符:延迟 delay

使用 delay 延迟时,动画曲线的前面必须得加上 Animation 关键词,否则会提示“类型不明确”

//依次延迟的效果
HStack {
	ForEach(0...4, id: \\.self) { 
			index in
		  Circle()
	      .frame(width: 10, height: 10)
        .foregroundColor(.green)
        .scaleEffect(self.isLoading ? 0 : 1)
        .animation(Animation.linear(duration: 0.6).repeatForever().delay(0.2 * Double(index)))
	}
}
.onAppear() {
	self.isLoading = true
}

修饰符:重复 repeatCount

默认情况下,当使用 repeatCount 时,SwiftUI会将动画自动反转进行。 并且点击按钮后,动画播放3次后停止;这个时候再点击按钮,这个动画实际是以反转的效果开始,再运行3次。所以前后两次点击的效果其实不大一样。类似(1,2,1)和(2,1,2)

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/753fed50-e367-4588-bd1c-b06e05b640cc/Screenshot_-_2020-10-29_13.07.40.png

//当触发一个动画时,默认只会执行一遍。如果想重复需要额外设置:

//重复,默认反转
.animation(Animation.easeOut(duration: 0.6).repeatCount(3))

//重复,但不反转
.animation(Animation.easeOut(duration: 0.6).repeatCount(3, autoreverses: false))

//一直重复
.animation(Animation.easeOut(duration: 0.6).repeatForever())

//一直重复,但不反转
.animation(Animation.easeOut(duration: 0.6).repeatForever(autoreverses: false))

//每次重复之前设置一个延迟间隔
.animation(Animation.easeOut(duration: 0.6).delay(1).repeatForever(autoreverses: false))

.animation(.easeInOut(duration: 1).repeatCount(3, autoreverses: true), value: animationAmount)
//如果将重复计数设置为2双数,那么按钮将先放大然后再缩小,然后立即跳回到较大状态。这是因为最终按钮必须与程序的状态匹配,无论我们应用什么动画


隐性动画 .animation

隐式动画:指的是通过视图修饰符 .animation(_: value:) 添加动画,SwiftUI 自动处理状态变化的动画效果

// 首先:因为希望在某种条件下,更改某个修饰器的值,且执行某个动画;因此首先需要使用 @State 属性来存储某个状态;
@State private var heartActived : Bool = false

// 然后:在具体视图的属性上,使用三元运算符,定义不同状态下,UI是什么样的
ZStack {
		Circle()
			  .foregroundColor(heartActived ? .gray : .red)
				.scaleEffect(heartActived ? 1.0 : 0.5)
}

// 最后:把 animation(_: value:) 修饰器加到想要呈现动画的视图上,並指定所需要到动画类型
// 此时视图就会在参数发生改变时呈现动画,还可以定义动画持续时间与延迟时间

// 不止可以在单个视图中应用 animation 修饰器,也可以在父视图中使用。SwiftUI 会寻找嵌入在父视图中所有的改变状态,并建立动画。
// 这个 animation 修饰器是加到 ZStack 的,所以它会查找 ZStack 里,和 heartActived 相关的所有状态参数,赋予动画
ZStack{...}
		.animation(.bouncy(duration: 0.5), value: heartActived)

// 最后的最后,通过在具体时机改变该状态参数的值,即可随时触发动画
.OnTapGesture{
		self.heartActived.toggle()
}

1. 取消动画效果

// 在不想要动画的视图下加上以下修饰符
.animation(.none)
.animation(nil)

2. 切换动画的启用和关闭

@State private var showCard = false    
@State private var useAnimation = false

VStack {                
	HStack {
		Button(action: {
			self.useAnimation = false 
			self.showCard.toggle()                    
		}) {                        
			Text("X").font(.body).padding(8)                    
		}          
	}             
	Image("Card")            
}                     
.offset(x: showCard ? 0 : -400)

// 使用三元运算符判断 useAnimation 的值,决定有没有动画
.animation(useAnimation ? .default : .none) 

3. 控制动画堆栈

// 示例1:我们可以使用默认动画使颜色发生变化,但使用弹簧作为剪辑形状:
Button("Tap Me") {
    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)
// 示例2:动画触发后,两个动画都是同时计时的。第一个动画2秒钟后开始,它只影响颜色动画;
// 再过1秒,第二个动画也开始了(因为它是延迟3秒),它只影响第二个放大动画;
.stroke(isLoading ? Color.blue : Color.red, lineWidth: 10.0)
// 这个修改器只影响上面的动画   
.animation(Animation.easeInOut(duration: 1).delay(2), value: isLoading)     
.scaleEffect(change ? 0.75 : 1)
// 这个修改器只影响上面的动画  
.animation(Animation.easeIn.delay(3), value: isLoading)
// 示例3:为了获得更多控制,还可以通过将 nil 传递给修饰符来完全禁用动画
// 例如,您可能希望颜色立即发生变化,但剪辑形状保留其动画,在这种情况下您可以这样写:
Button("Tap Me") {
    enabled.toggle()
}
.frame(width: 200, height: 200)
.background(enabled ? .blue : .red)
// 如果不加这一行,则所有的样式属性都会应用后面的弹簧动画。但我们想颜色变化没有动画。如果没有多个 animation() 修饰符,这种控制不可能实现
.animation(nil, value: enabled)
.foregroundStyle(.white)
.clipShape(.rect(cornerRadius: enabled ? 60 : 0))
.animation(.spring(duration: 1, bounce: 0.6), value: enabled)

在 iOS 17 及更高版本中引入了一种更新、更清晰的方法:

struct ContentView: View {
    @State private var isEnabled = false
    var body: some View {
        Button("Press Me") {
            isEnabled.toggle()
        }
        .foregroundStyle(.white)
        .frame(width: 200, height: 200)
        .animation(.easeInOut(duration: 1)) { content in
            content
                .background(isEnabled ? .green : .red)
        }
        .animation(.easeInOut(duration: 2)) { content in
            content
                .clipShape(.rect(cornerRadius: isEnabled ? 100 : 0))
        }
    }
}

4. 动画修饰符的位置影响

.animation(Animation.linear(duration: 1).delay(1.0).repeatForever(autoreverses: false), value: isLoading)
// 像上面这个加了 delay,后面还加了无限循环。那每次循环之前它其实都要等待1秒。
// 如果不希望这样,可以把 delay 写到 repeat的后面去就可以了

5. 动画修饰符在父/子元素上的影响

HStack {                
	Color.blue                    
		.hueRotation(Angle.degrees(change ? 180 : 0))                
	Color.green                    
		.hueRotation(Angle.degrees(change ? 180 : 0))                
	Color.red                    
		.hueRotation(Angle.degrees(change ? 180 : 0))            
}
//这样上面的所有color视图都会有动画
.animation(.linear)

<aside> 💡 对处于判断条件下的视图,其视图的展示和隐藏是通过条件判断实现的,那它的动画修饰符最好加到父元素上,否则可能动画修饰符不会起效果。比如给元素A设置了 animation(.default),但是实际上A如果消失了,是看不到动画效果的,因为动画想发生时,A已经不在了。所以要把 animation(.default) 加到A的上一级父元素上。

</aside>


绑定值动画

animation() 修饰符可以应用于任何 SwiftUI 绑定,这会让该【值】在当前值和新值之间进行动画处理。即使是一些看起来不能被赋予动画的值,例如布尔值,它也是可以赋予动画效果的。

为什么可以为布尔值变化设置动画?事实上 Swift 并不是在 false 和 true 之间填充了新的中间值。它只是确认【更改前视图的状态A】,和【更改后视图的目标状态B】后,然后在 A 和 B 之间应用动画。

struct ContentView: View {
    @State private var animationAmount = 1.0
    var body: some View {
        VStack {
            Stepper("Scale amount", value: $animationAmount.animation(), in: 1...10)
            Button("Tap Me") {
                animationAmount += 1
            }
            .padding(40)
            .clipShape(.circle)
            .scaleEffect(animationAmount)
        }
    }
}

// 例如在这个例子中:stepper 步进器可以增减 animationAmount;点击 button 也可以增加 animationAmount
// 但是,点击 button 会立即更改 animationCount (这意味着按钮视图马上变到大尺寸)
// 相反,由于 stepper 绑定了 $animationAmount.animation() (这意味着自动为值的更改设置了动画,按钮视图会慢慢变到大尺寸)

绑定值动画的设置方法:

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

显性动画 withAnimation

@State private var heartActived = false

ZStack {
	Circle()
	  .foregroundColor(heartActived ? .gray : .red)
		.scaleEffect(heartActived ? 1.0 : 0.5)
}
.onTapGesture {
		// 动画加到这里
		withAnimation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3)) {
				self.heartActived.toggle()
		}
}

<aside> 💡 注意:withAnimation不像是一个修饰器,它不是用点号加到视图后面。它是一个函数,接受一些动画参数。首字母不需要大写。

</aside>

排除某个动画

如果有几个状态参数去控制不同的动画,你希望其中一个动画不要执行,那可以将对应那行代码从 withAnimation 中排出即可:

.onTapGesture {
    withAnimation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3)) {
        self.circleColorChanged.toggle()
        self.heartColorChanged.toggle()
    }
		//这里把其中一个参数的改变,移出了 withAnimation 外,所以它关联的视图参数不会产生动画
    self.heartSizeChanged.toggle()
}

延迟动画的写法

//withAnimation 的延迟设置
Button("Change") {
	// Animation 1        
	withAnimation(Animation.default) {
		self.show1.toggle()
	}
	// Animation 2   
	withAnimation(Animation.default.delay(0.4)) {
		self.show2.toggle()
	}
	// Animation 3
	withAnimation(Animation.default.delay(0.8)) {
		self.show3.toggle()
	}            
}

次序动画的两种写法

最好是使用 withAnimation 写法,尤其是涉及父元素、子元素之间的依次执行的情况时。

动画完成闭包 completion

iOS 17 中的新功能。

我们可以为 withAnimation() 函数提供一个完成回调,在动画完成时运行代码。这可能是您调整某些程序状态的地方;也可以将其用作链接动画的简单方法(即完成一个动画,再进行下一个动画)。添加方法如下:

struct ContentView: View {
    @State private var scaleUp = false
    @State private var fadeOut = false
    var body: some View {
        Button("Tap Me!") {
            withAnimation {
                scaleUp = true
            } completion: {
                withAnimation {
                    fadeOut = true
                }
            }
        }
        .scaleEffect(scaleUp ? 3 : 1)
        .opacity(fadeOut ? 0 : 1)
    }
}

当你使用弹簧动画时,动画完成时,视图可能还有一个长尾的抖动;而 Completion 完成闭包,可能不会等到抖动结束再执行,如果你希望它等到抖动全部结束再执行,可以这样设置:

struct ContentView: View {
    @State private var scaleUp = false
    @State private var fadeOut = false
    var body: some View {
        Button("Tap Me!") {
            withAnimation(.bouncy, completionCriteria: .removed) {
                scaleUp = true
            } completion: {
                withAnimation {
                    fadeOut = true
                }
            }
        }
        .scaleEffect(scaleUp ? 3 : 1)
        .opacity(fadeOut ? 0 : 1)
    }
}