SwiftUI動畫(1)之Animatable

相信大家都已經(jīng)對SwiftUI有了基本的了解,在SwiftUI寫動畫,相對來說變得更加簡單了,接下來,會用3篇文章,帶領大家一覽SwiftUI動畫的魅力。

1. 顯式和隱式動畫

在SwiftUI中有兩種類型的動畫,顯式和隱式。

隱式動畫指的就是用animation()modifier的view,當該view的可動畫的參數(shù)變化的時候,系統(tǒng)會自動進行動畫,這些所謂的可動畫的參數(shù)包括size,offset,color,scale等等。

顯式動畫指的是withAnimation { ... }閉包中指定的參數(shù),所有依賴這些參數(shù)的view,都會執(zhí)行動畫。

我們先看個例子,下邊的動畫使用了隱式動畫:

tower1.gif

代碼如下:

struct Example1: View {
    @State private var half = false
    @State private var dim = false
    
    var body: some View {
        Image("tower")
            .scaleEffect(half ? 0.5 : 1.0)
            .opacity(dim ? 0.2 : 1.0)
            .animation(.easeInOut(duration: 1.0))
            .onTapGesture {
                self.dim.toggle()
                self.half.toggle()
            }
    }
}

從上邊的代碼中,我們可以看出動畫依賴half,dim這2個參數(shù),我們并沒有直接告訴view這2個參數(shù)要動畫,系統(tǒng)會自動把舊值到新值的變化做動畫。

我們把代碼做一點簡單的改變:

struct Example2: View {
    @State private var half = false
    @State private var dim = false
    
    var body: some View {
        Image("tower")
            .scaleEffect(half ? 0.5 : 1.0)
            .opacity(dim ? 0.5 : 1.0)
            .onTapGesture {
                self.half.toggle()
                
                withAnimation(.easeInOut(duration: 1.0)) {
                    self.dim.toggle()
                }
        }
    }
}

我們?nèi)サ袅?code>.animation(.easeInOut(duration: 1.0)),新增了withAnimation閉包,我們把self.dim.toggle()放到閉包中,這就是顯式的告訴系統(tǒng),view的透明度要執(zhí)行xxx動畫,所有依賴dim參數(shù)的view,在dim改變的時候,都會執(zhí)行動畫,效果如下:

tower2.gif

仔細看上圖的動畫過程,就會發(fā)現(xiàn),只有透明度指定了動畫,縮放并沒有執(zhí)行動畫,這就說明,我們顯式的告訴系統(tǒng)dim需要動畫,它就只為dim執(zhí)行動畫,非常的聽話。

此時此刻,我有一個問題,我用隱式動畫如何實現(xiàn)上邊這種動畫呢?也非常簡單,先看代碼:

struct Example2: View {
    @State private var half = false
    @State private var dim = false
    
    var body: some View {
        Image("tower")
            .opacity(dim ? 0.2 : 1.0)
            .animation(.easeInOut(duration: 1.0))
            .scaleEffect(half ? 0.5 : 1.0)
            .onTapGesture {
                self.dim.toggle()
                self.half.toggle()
        }
    }
}

animationmodifier作用于view時,他的順序時很重要的,在上邊的代碼中,它只對它前邊的內(nèi)容生效,當然這個順序我們其實時可以任意調(diào)整的,我們要想使用隱式動畫禁用某些動畫時,只需要.animation(nil)就行了。

struct Example2: View {
    @State private var half = false
    @State private var dim = false
    
    var body: some View {
        Image("tower")
            .opacity(dim ? 0.2 : 1.0)
            .animation(nil)
            .scaleEffect(half ? 0.5 : 1.0)
                .animation(.easeInOut(duration: 1.0))
            .onTapGesture {
                self.dim.toggle()
                self.half.toggle()
        }
    }
}

2.How Do Animations Work

SwiftUI動畫背后的原理在于Animatable協(xié)議,它要求我們實現(xiàn)一個計算屬性animatableData,該屬性遵守VectorArithmetic協(xié)議,VectorArithmetic的目的是讓系統(tǒng)可以在需要變化的動畫數(shù)據(jù)中間插入很多值,這些值的計算依賴動畫的時間函數(shù)。

本質(zhì)上,在SwiftUI中執(zhí)行動畫,就是系統(tǒng)渲染View很多次,每一次渲染,都改變一點點參數(shù),當然,這個參數(shù)指的是需要動畫的原值到終值。

舉個例子,如果我們線性的把透明度從0.3改成0.8,由于0.3是Double類型,實現(xiàn)了VectorArithmetic協(xié)議,因此系統(tǒng)可以在0.3到0.8之間插入很對中間的值,這些值的計算依賴時間函數(shù)和動畫時長。在本例中,它是線性的,系統(tǒng)在插值的時候的算法類似于下邊的代碼:

let from:Double = 0.3
let to:Double = 0.8

for i in 0..<6 {
    let pct = Double(i) / 5
    
    var difference = to - from
    difference.scale(by: pct)
    
    let currentOpacity = from + difference
    
    print("currentOpacity = \(currentOpacity)")
}
currentOpacity = 0.3
currentOpacity = 0.4
currentOpacity = 0.5
currentOpacity = 0.6
currentOpacity = 0.7
currentOpacity = 0.8

本質(zhì)上,系統(tǒng)會為這些插入的值,都生成一個View,在duration的時間內(nèi)把這些Views,播放出來,這就是我們看到的動畫效果。

關于時間函數(shù),我們用下邊的這張圖來舉例, 這是一個圖片的scale,也就是縮放效果,可以看到,不同的函數(shù)下,系統(tǒng)插入的值不同,根據(jù)插入值計算縮放后的圖片也是不同的。

image.png

3. Why Do I Care About Animatable?

那么為什么我們需要如此關注Animatable這個協(xié)議呢? 像opacity,scale,這些系統(tǒng)自動會執(zhí)行動畫,完全不需要我們關心。

是的,像這些基本的效果,系統(tǒng)是知道該如何做動畫的,但在平時的開發(fā)中,我們要做的動畫往往不是這么簡單,比如說,path的變換,漸變色的切換等等,這些例子會在后續(xù)的文章中都介紹到,其最核心的思想就是animatableData。大家繼續(xù)閱讀就是了。

4. Animating Shape Paths

這一小節(jié),我們要做的事情就是利用Animatable來實現(xiàn)正多邊形的繪制,類似下邊這樣:

polygons.png

上圖中,只展示了正三邊形和正四邊形的例子,我們馬上就會把它擴展到隨意n邊形。

在開始擼碼之前,我們先簡單介紹下實現(xiàn)該功能需要的一點三角函數(shù)的知識,我不會在這里做詳細的介紹,更詳細的可以點擊這里

有一個基本定理,在一個圓中,我們可以畫出任何n正邊形,這一點很重要,在畫正邊形之前,我們需要先確定該正邊形外圓的半徑,如下圖:

heptagon-2.png

有了這個基本概念后,我們就可以動手來實現(xiàn)了:

heptagon-3.png
  • 圓點的位置我們已經(jīng)知道,通常是圖形的中心點
  • 半徑很好計算,我們要繪制正邊形的背景通常是正方形或者長方形,因此取最短邊的一半作為半徑比較合適
  • 正邊形各個定點到圓點形成的夾角很好計算

有點圓點,夾角,半徑,我們就能夠確定每個定點的point,因此就能輕松畫出正多邊形的path。我們這些例子中的第一個頂點在圓心的正右方,并不是上圖中對應的位置。

我們把上邊的思想寫成代碼,如下:

struct PolygonShape: Shape {
    var sides: Int
    
    func path(in rect: CGRect) -> Path {        
        // hypotenuse
        let h = Double(min(rect.size.width, rect.size.height)) / 2.0
        
        // center
        let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
        
        var path = Path()
                
        for i in 0..<sides {
            let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180

            // Calculate vertex position
            let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h))
            
            if i == 0 {
                path.move(to: pt) // move to first vertex
            } else {
                path.addLine(to: pt) // draw line to next vertex
            }
        }
        
        path.closeSubpath()
        
        return path
    }
}

現(xiàn)在大家應該能夠清楚的理解上邊代碼的實現(xiàn)方式了吧?用起來也很簡單:

PolygonShape(sides: isSquare ? 4 : 3)
    .stroke(Color.blue, lineWidth: 3)
    .animation(.easeInOut(duration: duration))

當我們改變siders的時候,你以為這么簡單就能指定動畫了? 還是太年輕了,實際效果為:

Kapture 2020-05-27 at 11.46.25.gif

原因很簡單,系統(tǒng)不知道它該如何動畫?它只知道在siders改變的時候,重新繪制圖形,為了解決這個問題,我們需要做2件事情:

  • 需要把Int類型的siders改成Double類型,這樣才能在其值改變的時候,往中間插入很多值
  • 通過animatableData告訴系統(tǒng)哪些值需要插值

幸運的是,Shape已經(jīng)遵守了Animatable協(xié)議,因此,代碼如下:

struct PolygonShape: Shape {
    var sides: Double

    var animatableData: Double {
        get { return sides }
        set { sides = newValue }
    }

    ...
}

那么問題又來了,假設我們siders從3變?yōu)?,系統(tǒng)把siders分割成3.1, 3.2, 3.3... 3.9,4.0,這個時候我們應該如何根據(jù)這些數(shù)值來畫路徑呢?

看下核心代碼:

    func path(in rect: CGRect) -> Path {
        
        // hypotenuse
        let h = Double(min(rect.size.width, rect.size.height)) / 2.0
        
        // center
        let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
        
        var path = Path()
        
        let extra: Int = Double(sides) != Double(Int(sides)) ? 1 : 0
        
        for i in 0..<Int(sides) + extra {
            let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180
            
            // Calculate vertex
            let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h))
            
            if i == 0 {
                path.move(to: pt) // move to first vertex
            } else {
                path.addLine(to: pt) // draw line to next vertex
            }
        }
        
        path.closeSubpath()
        
        return path
    }

let extra: Int = Double(sides) != Double(Int(sides)) ? 1 : 0這行代碼保證了像3.4這樣大于3的數(shù)能夠畫出4個頂點。

for i in 0..<Int(sides) + extra這里的循環(huán),循環(huán)多少次就會產(chǎn)生多少的頂點,這一點很重要。

let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180,不管siders是多少,(360.0 / Double(sides))都是相同的值,也就是說,每次遍歷旋轉的角度是相同的。

SwiftUI角度旋轉是順時針方向的,水平x軸為0度。我們看下邊幾個截圖:

3.2

上邊這張圖是siders等于3.2的時候,繪制的路徑,我們在該圖的基礎上添加一些說明:

企業(yè)微信截圖_13b017b0-c49e-4bd6-b08b-e10c7d504c8a_副本.png

繪圖的順序為1 > 2 > 3 > 4, 角1,角2, 角3是相同的,繪制這個圖,for循環(huán)了4次,大家仔細想想,這里 角1,角2, 角3相加不等于360度是正常的。

很明顯,假設當siders增大一點到3.4的時候,由于(360.0 / Double(sides))的原因,這時候角1,角2, 角3會變小一些,正好1和4之間的線段會增長一點。如下圖:

企業(yè)微信截圖_e42e83b5-6d66-4a28-8318-9ca413e1a62e.png

好了,我們已經(jīng)分析的很詳細了,大家如果還有不明白的地方,可以留言。只需要增加一點點代碼就能動起來了“

struct Example1PolygonShape: Shape {
    var sides: Double
    
    var animatableData: Double {
        get { return sides }
        set { sides = newValue }
    }
    
    func path(in rect: CGRect) -> Path {
                ...
    }
}
Kapture 2020-05-27 at 19.13.26.gif

我們在上邊的基礎上再擴展一點東西出來,如果我想同時執(zhí)行2種動畫,那該如何呢? 其實非常簡單。animatableData只要求set和get實現(xiàn)了VectorArithmetic協(xié)議的值就行,我們上邊用到的Double就實現(xiàn)了,如果我們兩同時執(zhí)行2種動畫,我們需要使用AnimatablePair<First, Second>.

很明顯,它封裝了2個參數(shù),我們的代碼就會變成這樣:

struct PolygonShape: Shape {
    var sides: Double
    var scale: Double
    
    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(sides, scale) }
        set {
            sides = newValue.first
            scale = newValue.second
        }
    }

    ...
}

繪制路徑的方法也只需要改一點點就可以了,利用scale計算半徑:

  func path(in rect: CGRect) -> Path {
      let h = Double(min(rect.size.width, rect.size.height) / 2.0) * scale
      ...
  }

如此簡單,再看下效果:

Kapture 2020-05-28 at 17.44.41.gif

也許你現(xiàn)在有了一個新的疑問,如果我們同時執(zhí)行超過兩個動畫,應該怎么辦? 答案也同樣很簡單,

AnimatablePair<AnimatablePair<CGFloat, CGFloat>, AnimatablePair<CGFloat, CGFloat>>

基于此方法,可以引申到n個值,在系統(tǒng)中CGPoint,CGSize和CGRect都可以執(zhí)行動畫,是因為他們都實現(xiàn)了Animatable協(xié)議。

extension CGPoint : Animatable {
    public typealias AnimatableData = AnimatablePair<CGFloat, CGFloat>
    public var animatableData: CGPoint.AnimatableData
}

extension CGSize : Animatable {
    public typealias AnimatableData = AnimatablePair<CGFloat, CGFloat>
    public var animatableData: CGSize.AnimatableData
}

extension CGRect : Animatable {
    public typealias AnimatableData = AnimatablePair<CGPoint.AnimatableData, CGSize.AnimatableData>
    public var animatableData: CGRect.AnimatableData
}

在這一小節(jié)的最后,我們再看一個更加酷炫的效果:

Kapture 2020-05-28 at 18.28.53.gif

實現(xiàn)上邊的的效果也很簡單就是用上邊的方法繪制完圖形后,再讓每個頂點分別同別的頂點連線,核心代碼為函數(shù)drawVertexLines。代碼如下:

  func path(in rect: CGRect) -> Path {
        ...
        
        drawVertexLines(path: &path, vertexs: vertex, n: 0)
        
        return path
    }
    
    func drawVertexLines(path: inout Path, vertexs: [CGPoint], n: Int) {
        if vertexs.count - n < 3 {
            return
        }
        
        for i in (n+2)..<min(n + vertexs.count - 1, vertexs.count) {
            path.move(to: vertexs[n])
            path.addLine(to: vertexs[i])
        }
        
        drawVertexLines(path: &path, vertexs: vertexs, n: n+1)
    }

5.Making Your Own Type Animatable (with VectorArithmetic)

在上邊的這些小節(jié)中,我們都使用了SwiftUI系統(tǒng)提供的數(shù)據(jù)結構,大部分情況下這些結構足矣,但我們還想在此基礎之上,做出一些更加復雜的東西。

舉個例子,我們們想使用我們自定義的struct來做動畫,只要講到動畫,就離不開一個值從某一個值到另一個值的變化,我們這個例子就是時鐘的一個動畫,先看下最后的效果:

Kapture 2020-05-29 at 14.23.25.gif

要想描述某一刻的時間,我們需要3個屬性,時,分,秒,因此我們需要把它們封裝到一個結構體中,當需要切換時間的時候,直接在變化的兩個結構體中間插值。

小提示:Angle, CGPoint, CGRect, CGSize, EdgeInsets, StrokeStyleUnitPoint,這些都實現(xiàn)了Animatable協(xié)議,AnimatablePair, CGFloat, Double, EmptyAnimatableDataFloat,這些都實現(xiàn)了VectorArithmetic協(xié)議。

我們先寫ClockTime結構體,代碼如下:

struct ClockTime {
    var hours: Int
    var minutes: Int
    var seconds: Double
    
    init(_ h: Int, _ m: Int, _ s: Double) {
        self.hours = h
        self.minutes = m
        self.seconds = s
    }
    
    init(_ seconds: Double) {
        let hours = Int(seconds) / 3600
        let minutes = (Int(seconds) - (hours * 3600)) / 60
        let seconds = seconds - Double(hours * 3600) - Double(minutes * 60)
        
        self.hours = hours
        self.minutes = minutes
        self.seconds = seconds
    }
    
    func asSeconds() -> Double {
        return Double(self.hours * 3600) + Double(self.minutes * 60) + self.seconds
    }
    
    func asString() -> String {
        return String(format: "%2i", self.hours) +
            " : " +
            String(format: "%02i", self.minutes) +
            " : " +
            String(format: "%02.0f", self.seconds)
    }
}

這里的代碼非常簡單,就是初始化和一些函數(shù),大家應該能夠理解,如果要對ClockTime做加減法,其實都是對兩個時間的總秒數(shù)做加減法。

我們讓ClockTime實現(xiàn)VectorArithmetic協(xié)議:

extension ClockTime: VectorArithmetic {
    static func - (lhs: ClockTime, rhs: ClockTime) -> ClockTime {
        return ClockTime(lhs.asSeconds() - rhs.asSeconds())
    }
    
    static func + (lhs: ClockTime, rhs: ClockTime) -> ClockTime {
        return ClockTime(lhs.asSeconds() + rhs.asSeconds())
    }
    
    mutating func scale(by rhs: Double) {
        var s = Double(self.asSeconds())
        s.scale(by: rhs)
        
        let time = ClockTime(s)
        self.hours = time.hours
        self.minutes = time.minutes
        self.seconds = time.seconds
    }
    
    var magnitudeSquared: Double {
        1
    }
    
    static var zero: ClockTime {
        ClockTime(0, 0, 0)
    }
}

其實,類似上邊的代碼,基本上算是固定寫法,但可以發(fā)現(xiàn)一些新的想法,SwiftUI系統(tǒng)內(nèi)部在做插值的時候,會用到VectorArithmetic協(xié)議中的方法。

關于圖形繪制方面,還是上邊的那一套,代碼如下:

struct ClockShape: Shape {
    var time: ClockTime
    
    var animatableData: ClockTime {
        get {
            time
        }
        set {
            time = newValue
        }
    }
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        
        let radius = min(rect.size.width / 2.0, rect.size.height / 2.0)
        let center = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
        
        let hHypotenuse = Double(radius) * 0.5
        let mHypotenuse = Double(radius) * 0.6
        let sHypotenuse = Double(radius) * 0.8
        
        let hAngle: Angle = .degrees(Double(time.hours) / 12 * 360 - 90)
        let mAngle: Angle = .degrees(Double(time.minutes) / 60 * 360 - 90)
        let sAngle: Angle = .degrees(Double(time.seconds) / 60 * 360 - 90)
        
        let hoursNeedle = CGPoint(x: center.x + CGFloat(hHypotenuse * cos(hAngle.radians)), y: center.y + CGFloat(hHypotenuse * sin(hAngle.radians)))
        let minutesNeedle = CGPoint(x: center.x + CGFloat(mHypotenuse * cos(mAngle.radians)), y: center.y + CGFloat(mHypotenuse * sin(mAngle.radians)))
        let secondsNeedle = CGPoint(x: center.x + CGFloat(sHypotenuse * cos(sAngle.radians)), y: center.y + CGFloat(sHypotenuse * sin(sAngle.radians)))
        
        /// 畫圓
        path.addArc(center: center, radius: radius,
                    startAngle: .degrees(0), endAngle: .degrees(360),
                    clockwise: true)
        
        /// 表盤刻度
        let numberLength: CGFloat = 5.0
        let numberPadding: CGFloat = 12.0
        let centerToNumber: CGFloat = radius - numberLength - numberPadding
        
        
        for i in 0..<12 {
            let angle: Angle = .degrees(360.0 / 12.0 * Double(i))

            let inPt = CGPoint(x: center.x + centerToNumber * CGFloat(cos(angle.radians)), y: center.y - centerToNumber * CGFloat(sin(angle.radians)))

            let outPt = CGPoint(x: center.x + (centerToNumber + numberLength) * CGFloat(cos(angle.radians)), y: center.y - (centerToNumber + numberLength) * CGFloat(sin(angle.radians)))

            path.move(to: inPt)
            path.addLine(to: outPt)
        }
        
        
        /// 時針
        path.move(to: center)
        path.addLine(to: hoursNeedle)
        path = path.strokedPath(StrokeStyle(lineWidth: 3, lineCap: .round))
        
        /// 分針
        path.move(to: center)
        path.addLine(to: minutesNeedle)
        path = path.strokedPath(StrokeStyle(lineWidth: 3, lineCap: .round))
        
        /// 秒針
        path.move(to: center)
        path.addLine(to: secondsNeedle)
        path = path.strokedPath(StrokeStyle(lineWidth: 1, lineCap: .round))

        return path
    }
}

6.SwiftUI + Metal

如果我們想在SwiftUI中實現(xiàn)特別復雜的動畫,并在真機上運行,可能會發(fā)現(xiàn),動畫不一定那么流暢,這種情況比較適合開啟Metal,開啟Metal非常簡單,代碼如下:

FlowerView().drawingGroup()

According to WWDC 2019, Session 237 (Building Custom Views with SwiftUI): A drawing group is a special way of rendering but only for things like graphics. It will basically flatten the SwiftUI view into a single NSView/UIView and render it with metal. Jump the WWDC video to 37:27 for a little more detail.

Kapture 2020-05-29 at 14.36.12.gif

可以看下上圖的效果,開啟了Metal后流暢了很多。至于代碼,我們這里就不粘貼了,大家可以去原作者網(wǎng)站去看,上圖中整個圖形是旋轉的,但是花瓣并沒有做額外的旋轉,而是控制了繪制花瓣的寬度來實現(xiàn)的,這有助于大家理解代碼。

總結

SwiftUI動畫的本質(zhì)就是插值,凡是實現(xiàn)了Animatable協(xié)議的對象,系統(tǒng)就知道如何執(zhí)行動畫,這是一個核心思想。

*注:上邊的內(nèi)容參考了網(wǎng)站https://swiftui-lab.com/swiftui-animations-part1/,如有侵權,立即刪除。

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容