偵測 SwiftUI 動畫完成的 withAnimation(_:completionCriteria:_:completion:) — iOS 17 新功能
SwiftUI 提供方便的 function 幫我們快速實現各種動畫,像是 modifier animation(_:value:) & function withAnimation(_:_:)。
不過在 iOS 17 之前,有個我們很需要但 SwiftUI 實現會有點麻煩的功能,偵測動畫完成。
例如以下例子,舊版的 withAnimation 只能傳入想做的改變,沒有參數讓我們傳入動畫完成時觸發的 closure。
func withAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result
struct ContentView: View {
@State private var offset = CGSize.zero
var body: some View {
Image(.peter)
.offset(offset)
.onTapGesture {
withAnimation(.easeInOut(duration: 1)) {
offset = CGSize(width: 100, height: 100)
}
}
}
}
假設我們想在動畫完成時讓 peter 回到初始位置,從前我們有兩種解法。
- 解法 1: 使用 asyncAfter(deadline:qos:flags:execute:)。
動畫時間 1 秒鐘,表示動畫將在 1 秒後完成,因此我們設定 1 秒後將 offset 設為 .zero。 不過由於動畫不見得會精準在一秒後完成,所以動畫有時會有點誤差。
struct ContentView: View {
@State private var offset = CGSize.zero
var body: some View {
Image(.peter)
.offset(offset)
.onTapGesture {
withAnimation(.easeInOut(duration: 1)) {
offset = CGSize(width: 100, height: 100)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
offset = .zero
}
}
}
}
- 自訂 AnimatableModifier。
自訂 AnimatableModifier 可以更精準地判斷動畫完成的時間,但是程式會複雜許多。
從 iOS 17 開始,我們有更簡單的方法偵測動畫完成,新的 function withAnimation(_:completionCriteria:_:completion:)
提供參數 completion 讓我們傳入動畫完成時觸發的 closure。
func withAnimation<Result>(
_ animation: Animation? = .default,
completionCriteria: AnimationCompletionCriteria = .logicallyComplete,
_ body: () throws -> Result,
completion: @escaping () -> Void
) rethrows -> Result
以下例子在參數 completion 設定 offset = .zero
,peter 將在動畫完成時瞬間回到初始位置。
import SwiftUI
struct ContentView: View {
@State private var offset = CGSize.zero
var body: some View {
Image(.peter)
.offset(offset)
.onTapGesture {
withAnimation {
offset = CGSize(width: 100, height: 100)
} completion: {
offset = .zero
}
}
}
}
有了 completion,我們將可實現多段動畫。當 peter 以動畫移動 (100, 100) 的距離後,再以動畫回到初始位置。
struct ContentView: View {
@State private var offset = CGSize.zero
var body: some View {
Image(.peter)
.offset(offset)
.onTapGesture {
withAnimation {
offset = CGSize(width: 100, height: 100)
} completion: {
withAnimation {
offset = .zero
}
}
}
}
}
要多少段動畫都不是問題,以下例子做了三段動畫,包含移動和縮放動畫。
- 第一段: 移動 (100, 100) 的距離。
- 第二段: 回到初始位置,大小變成 2 倍。
- 第三段: 變回原本的大小。
import SwiftUI
struct ContentView: View {
@State private var offset = CGSize.zero
@State private var scale = 1.0
var body: some View {
Image(.peter)
.offset(offset)
.scaleEffect(scale)
.onTapGesture {
withAnimation(.easeInOut(duration: 3)) {
offset = CGSize(width: 100, height: 100)
} completion: {
withAnimation(.bouncy(duration: 3)) {
offset = .zero
scale = 2.0
} completion: {
withAnimation {
scale = 1.0
}
}
}
}
}
}
withAnimation(_:completionCriteria:_:completion:)
還有個型別是 AnimationCompletionCriteria 的參數 completionCriteria
,它控制判斷動畫完成的機制,影響 completion 的觸發時機。
以下我們以 spring 動畫說明 completionCriteria 的影響。
struct ContentView: View {
@State private var offset = CGSize.zero
@State private var opacity = 1.0
var body: some View {
Image("peter")
.offset(offset)
.opacity(opacity)
.onTapGesture {
withAnimation(.spring(duration: 1, bounce: 0.9)) {
offset = CGSize(width: 100, height: 100)
} completion: {
withAnimation {
opacity = 0
}
}
}
}
}
completionCriteria 的預設值是 .logicallyComplete
,意思是主要的動畫效果完成時就會觸發 completion ,然而動畫可能還在進行一些較不顯眼的部分。
如下圖所示,bounce 0.9 的 spring 動畫會來回震盪多次,logicallyComplete 將讓 completion 在震盪還沒結束前就觸發,所以震盪還在進行時 peter 就開始淡出。
若是 completionCriteria 傳入 .removed,completion 將在動畫完全結束時才觸發。
struct ContentView: View {
@State private var offset = CGSize.zero
@State private var opacity = 1.0
var body: some View {
Image(.peter)
.offset(offset)
.opacity(opacity)
.onTapGesture {
withAnimation(.spring(duration: 1, bounce: 0.9), completionCriteria: .removed) {
offset = CGSize(width: 100, height: 100)
} completion: {
withAnimation {
opacity = 0
}
}
}
}
}
因此 peter 將在震盪完全停止時才開始淡出。
透過參數 completion 我們可以實現多段動畫,然而當動畫愈多段時,程式會因層層的縮排大大增加複雜度。此時建議採用更適合設計多段動畫的 PhaseAnimator & KeyframeAnimator,相關說明可參考以下連結。