文章源地址:[https://swiftui-lab.com/swiftui-animations-part2/)
作者: Javier
翻譯: Liaoworking
在本系列的第一部分,我介紹了Animatable協(xié)議,我們現(xiàn)在已經(jīng)可以把它用到Path的動(dòng)畫了,下面我們將運(yùn)用GeometryEffect(幾何效果)把同樣的協(xié)議用到矩陣轉(zhuǎn)換的動(dòng)畫上。如果你還沒有看Part1或者還不知道Animatable協(xié)議是什么, 你可以先看看。如果你只對GeometryEffect 感興趣,那就算了。
可以在下面的網(wǎng)址找到本文的完整示例代碼:
[https : //gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798](https : //gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798)
例8需要的圖像。從這里下載:[https : //swiftui-lab.com/?smd_process_download=1& download_id
=916](https : //swiftui-lab.com/?smd_process_download=1& download_id
=916)
GeometryEffect(幾何效果)
GeometryEffect是一個(gè)遵守了Animatable 和 ViewModifier 的協(xié)議,遵守GeometryEffect協(xié)議需要實(shí)現(xiàn)下面的方法。
func effectValue(size: CGSize) -> ProjectionTransform
假設(shè)你的方法叫做 SkewEffect(偏斜效果) ,使用起來如下。
Text("Hello").modifier(SkewEfect(skewValue: 0.5))
Text("Hello")將由SkewEfect.effectValue() 生成的動(dòng)畫來實(shí)現(xiàn)矩形轉(zhuǎn)化動(dòng)畫。 就這么簡單。只是影響當(dāng)前視圖,父級和子級視圖都不會(huì)受到影響。
因?yàn)?code>GeometryEffect也遵守了Animatable,所以你可以添加一個(gè)animatableData屬性之類的,所以就會(huì)有動(dòng)畫效果。
你可能沒有注意到,你其實(shí)一直在使用GeometryEffect(幾何效果),如果你之前用過.offset(),你實(shí)際上就已經(jīng)使用了GeometryEffect,讓我來演示一下是這么實(shí)現(xiàn)的。
public extension View {
func offset(x: CGFloat, y: CGFloat) -> some View {
return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
}
func offset(_ offset: CGSize) -> some View {
return modifier(_OffsetEffect(offset: offset))
}
}
struct _OffsetEffect: GeometryEffect {
var offset: CGSize
var animatableData: CGSize.AnimatableData {
get { CGSize.AnimatableData(offset.width, offset.height) }
set { offset = CGSize(width: newValue.first, height: newValue.second) }
}
public func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: offset.width, y: offset.height))
}
}
關(guān)鍵幀動(dòng)畫
大多數(shù)動(dòng)畫的框架都有關(guān)鍵幀的概念。它是在閉包中告訴動(dòng)畫框架如何去區(qū)分動(dòng)畫,雖然SwiftUI沒有這些特性,我們可以取模擬這些,在接下來的例子中,我們要去創(chuàng)建一個(gè)水平移動(dòng)視圖的效果,但它一開始會(huì)斜歪,結(jié)束的時(shí)候不會(huì)斜歪。
斜歪的效果前80%會(huì)增加,后20%會(huì)減少。中間的時(shí)候斜歪的效果會(huì)穩(wěn)定不動(dòng)。
一開始先創(chuàng)建斜歪和運(yùn)動(dòng)的效果,先不管最后20%的效果減少。如果你對矩陣轉(zhuǎn)換還不太了解,沒關(guān)系,只要知道CGAffineTransform的C參數(shù)控制斜歪,tx控制x方向的偏移。
struct SkewedOffset: GeometryEffect {
var offset: CGFloat
var skew: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(offset, skew) }
set {
offset = newValue.first
skew = newValue.second
}
}
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))
}
}
做假
下面就是有趣的部分了,為了模擬關(guān)鍵幀,我們將定義一個(gè)值的范圍是0-1的可動(dòng)參數(shù),我們的代碼應(yīng)該像這樣來改變動(dòng)畫的,當(dāng)它的值是0.2的時(shí)候,我們實(shí)現(xiàn)了動(dòng)畫的前20%,當(dāng)參數(shù)值是大于等于0.8的時(shí)候,我們到達(dá)最后的20%。最重要的是,我們還會(huì)告訴動(dòng)畫框架我們是向左還是向右移動(dòng)。所以它能兩邊都斜歪。
struct SkewedOffset: GeometryEffect {
var offset: CGFloat
var pct: CGFloat
let goingRight: Bool
init(offset: CGFloat, pct: CGFloat, goingRight: Bool) {
self.offset = offset
self.pct = pct
self.goingRight = goingRight
}
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { return AnimatablePair<CGFloat, CGFloat>(offset, pct) }
set {
offset = newValue.first
pct = newValue.second
}
}
func effectValue(size: CGSize) -> ProjectionTransform {
var skew: CGFloat
if pct < 0.2 {
skew = (pct * 5) * 0.5 * (goingRight ? -1 : 1)
} else if pct > 0.8 {
skew = ((1 - pct) * 5) * 0.5 * (goingRight ? -1 : 1)
} else {
skew = 0.5 * (goingRight ? -1 : 1)
}
return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))
}
}
下面想要玩的更有趣一點(diǎn),我們將要在多個(gè)視圖上去使用這個(gè)動(dòng)畫,使用動(dòng)畫修改器 .delay() 來讓動(dòng)畫交錯(cuò),完整代碼在頂部的gist文件中的 Example6。
動(dòng)畫反饋
下一個(gè)例子中將介紹一個(gè)對動(dòng)畫過程起反饋?zhàn)饔玫墓ぞ摺?/p>
我們將要?jiǎng)?chuàng)建一個(gè)3D選擇效果,雖然SwiftUI已經(jīng)有對應(yīng)的修改器了,.rotation3DEffect()比較特別,每當(dāng)我們的視圖旋轉(zhuǎn)到足以向我們展示另一面時(shí),Bool值就會(huì)被更新。
通過對Bool值的改變,我們可以在旋轉(zhuǎn)的時(shí)候替換視圖。這會(huì)讓我們有一種這個(gè)視圖有兩面的錯(cuò)覺。
<video width='500px' autoplay preload='true' controls src="https://swiftui-lab.com/wp-content/uploads/2019/08/cards.mp4">
</video>
實(shí)現(xiàn)我們的特效
讓我們來實(shí)現(xiàn)這個(gè)特性,你可能會(huì)發(fā)現(xiàn)這個(gè)3D選擇特效和你之前在Core Animation里的使用不太一樣。在SwiftUI中,默認(rèn)的錨點(diǎn)在視圖的左上角,在Core Animation中是在中間,雖然現(xiàn)有的.rotationg3DEffect() 修改器可以讓你選擇錨點(diǎn),但想要達(dá)到現(xiàn)有的效果,需要結(jié)合其他一些轉(zhuǎn)換:
struct FlipEffect: GeometryEffect {
var animatableData: Double {
get { angle }
set { angle = newValue }
}
@Binding var flipped: Bool
var angle: Double
let axis: (x: CGFloat, y: CGFloat)
func effectValue(size: CGSize) -> ProjectionTransform {
// 我們計(jì)劃在繪制完成后去改變,
// 否則會(huì)收到一個(gè)runtime的error,
// 來告訴我們在繪制的時(shí)候去改變視圖了。
DispatchQueue.main.async {
self.flipped = self.angle >= 90 && self.angle < 270
}
let a = CGFloat(Angle(degrees: angle).radians)
var transform3d = CATransform3DIdentity;
transform3d.m34 = -1/max(size.width, size.height)
transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0)
transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))
return ProjectionTransform(transform3d).concatenating(affineTransform)
}
}
上面的代碼中有一個(gè)有趣的點(diǎn),flipped(翻動(dòng))屬性是由@Binding修飾的,可以通知用戶哪一面是朝著用戶的。
在我們的視圖中,使用flipped的值來顯示不同的視圖,在這個(gè)例子中打算使用一些取巧的方法。如果你仔細(xì)看視頻就會(huì)發(fā)現(xiàn)卡片一直在變,背景一直是一樣的,每次都是前面的在變,這并不是簡單的一邊一個(gè)視圖,而是在每次flipped值改變的時(shí)候去替換一張卡片。
我們擁有一個(gè)圖片名的數(shù)組,里面每個(gè)都會(huì)用到。先綁定自定義的幾個(gè)變量,如下
struct RotatingCard: View {
@State private var flipped = false
@State private var animate3d = false
@State private var rotate = false
@State private var imgIndex = 0
let images = ["diamonds-7", "clubs-8", "diamonds-6", "clubs-b", "hearts-2", "diamonds-b"]
var body: some View {
let binding = Binding<Bool>(get: { self.flipped }, set: { self.updateBinding($0) })
return VStack {
Spacer()
Image(flipped ? "back" : images[imgIndex]).resizable()
.frame(width: 265, height: 400)
.modifier(FlipEffect(flipped: binding, angle: animate3d ? 360 : 0, axis: (x: 1, y: 5)))
.rotationEffect(Angle(degrees: rotate ? 0 : 360))
.onAppear {
withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
self.animate3d = true
}
withAnimation(Animation.linear(duration: 8.0).repeatForever(autoreverses: false)) {
self.rotate = true
}
}
Spacer()
}
}
func updateBinding(_ value: Bool) {
// 如果卡片翻到前面了 更換卡片
if flipped != value && !flipped {
self.imgIndex = self.imgIndex+1 < self.images.count ? self.imgIndex+1 : 0
}
flipped = value
}
}
完整的代碼在頂部的gist的Example 7 中。
我們打算更換不同的卡片,而是改變圖片的名字,例子如下:
Color.clear.overlay(ViewSwapper(showFront: flipped))
.frame(width: 265, height: 400)
.modifier(FlipEffect(flipped: $flipped, angle: animate3d ? 360 : 0, axis: (x: 1, y: 5)))
struct ViewSwapper: View {
let showFront: Bool
var body: some View {
Group {
if showFront {
FrontView()
} else {
BackView()
}
}
}
}
讓視圖跟隨路徑
下面,我們來構(gòu)造一個(gè)完全不一樣的GeometryEffect(集合特效), 在這個(gè)例子中,將在一個(gè)特定的路線上移動(dòng)小飛機(jī)。會(huì)存在兩個(gè)問題。
1.如何在視圖上獲得這個(gè)坐標(biāo)空間上特定的點(diǎn)的坐標(biāo)。
2.小飛機(jī)的朝向也與路徑相同
這個(gè)動(dòng)畫中的可變參數(shù)是pct,它代表著飛機(jī)在路線上的位置。用值0到1來表示飛機(jī)跑完一整圈,我們將要使,0.25的值代表飛機(jī)已經(jīng)跑完四分之一圈了。
找到路線中的x和y值
為了通過給定的pct值來找到對應(yīng)飛機(jī)的x和y值。我們將要使用.trimmedPath() 修改器來修改Path結(jié)構(gòu)體。有一個(gè)方法是給定一個(gè)特定的百分比返回一個(gè)CGRect. 先定義兩個(gè)特別接近的起點(diǎn)和終點(diǎn),它將返回一個(gè)非常小的矩形,我們將用這個(gè)矩形的中心來當(dāng)做我們的x和y值。
func percentPoint(_ percent: CGFloat) -> CGPoint {
// 兩點(diǎn)之間的百分比差距
let diff: CGFloat = 0.001
let comp: CGFloat = 1 - diff
// 處理極值
let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
let f = pct > comp ? comp : pct
let t = pct > comp ? 1 : pct + diff
let tp = path.trimmedPath(from: f, to: t)
return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
}
校正方向
為了獲得飛機(jī)的轉(zhuǎn)向,我們用一點(diǎn)三角學(xué)的知識(shí),我們將獲得兩個(gè)點(diǎn)的x和y值,當(dāng)前點(diǎn)和稍微偏靠前的點(diǎn)。我們把兩個(gè)點(diǎn)連成一條線,然后通過三角函數(shù)的知識(shí),就能求出轉(zhuǎn)向角。
func calculateDirection(_ pt1: CGPoint,_ pt2: CGPoint) -> CGFloat {
let a = pt2.x - pt1.x
let b = pt2.y - pt1.y
let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
return CGFloat(angle)
}
把所有的都組合在一個(gè)
我們已經(jīng)獲得了所有可以達(dá)到目標(biāo)的工具,我們來實(shí)現(xiàn)這個(gè)效果吧:
struct FollowEffect: GeometryEffect {
var pct: CGFloat = 0
let path: Path
var rotate = true
var animatableData: CGFloat {
get { return pct }
set { pct = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
if !rotate {
let pt = percentPoint(pct)
return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y))
} else {
let pt1 = percentPoint(pct)
let pt2 = percentPoint(pct - 0.01)
let angle = calculateDirection(pt1, pt2)
let transform = CGAffineTransform(translationX: pt1.x, y: pt1.y).rotated(by: angle)
return ProjectionTransform(transform)
}
}
func percentPoint(_ percent: CGFloat) -> CGPoint {
// 兩點(diǎn)之間的百分比查
let diff: CGFloat = 0.001
let comp: CGFloat = 1 - diff
// 處理極值
let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
let f = pct > comp ? comp : pct
let t = pct > comp ? 1 : pct + diff
let tp = path.trimmedPath(from: f, to: t)
return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
}
func calculateDirection(_ pt1: CGPoint,_ pt2: CGPoint) -> CGFloat {
let a = pt2.x - pt1.x
let b = pt2.y - pt1.y
let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
return CGFloat(angle)
}
}
所有的代碼在文章頂部的gist的Example 8 中。
被布局忽略的
關(guān)于GeometryEffect的最后一個(gè)建議就是.ignoredByLayout()方法,先看看文檔怎么說:
Returns an effect producing the same geometry transform as “self” but that will only be applied while rendering its view, not while the view is performing its layout calculations. This is often used to disable layout changes during transitions, but that will only be applied while rendering its view, not while the view is performing its layout calculations. This is often used to disable layout changes during transitions.
在渲染視圖的時(shí)候返回一個(gè)與“Self”相同的幾何交換效果,計(jì)算布局的時(shí)候不返回,通常被用來在做動(dòng)畫的時(shí)候禁止布局改變。通常用于在過渡期間禁用布局更改。
馬上就介紹一下轉(zhuǎn)換,先舉一個(gè)例子來說明一下使用了.ignoredByLayout() 所帶來的明顯效果。下圖中的GeometryReader 會(huì)顯示兩個(gè)不同的位置。
struct ContentView: View {
@State private var animate = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 5)
.foregroundColor(.green)
.frame(width: 300, height: 50)
.overlay(ShowSize())
.modifier(MyEffect(x: animate ? -10 : 10))
RoundedRectangle(cornerRadius: 5)
.foregroundColor(.blue)
.frame(width: 300, height: 50)
.overlay(ShowSize())
.modifier(MyEffect(x: animate ? 10 : -10).ignoredByLayout())
}.onAppear {
withAnimation(Animation.easeInOut(duration: 1.0).repeatForever()) {
self.animate = true
}
}
}
}
struct MyEffect: GeometryEffect {
var x: CGFloat = 0
var animatableData: CGFloat {
get { x }
set { x = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: x, y: 0))
}
}
struct ShowSize: View {
var body: some View {
GeometryReader { proxy in
Text("x = \(Int(proxy.frame(in: .global).minX))")
.foregroundColor(.white)
}
}
}
下面會(huì)學(xué)到什么
今天舉的三個(gè)例子,都有些類似,都使用相同的協(xié)議來實(shí)現(xiàn)效果,GeometryEffect比較簡單,只用實(shí)現(xiàn)一個(gè)方法,但可以發(fā)揮很大的作用。
下面一節(jié),我們將介紹最后一個(gè)協(xié)議AnimatableModifier, AnimatableModifier可以做出很多炫酷的動(dòng)畫。
<video width='500px' autoplay preload='true' controls src="https://swiftui-lab.com/wp-content/uploads/2019/08/animations.mp4">
</video>