1. 程式人生 > IOS開發 >用 SwiftUI 實現酷炫的顏色切換動畫

用 SwiftUI 實現酷炫的顏色切換動畫

用 SwiftUI 實現酷炫的顏色切換動畫

老鐵們,是時候燥起來了!本文中我們將學習如何使用 SwiftUI 中的 PathsAnimatableData 來製作顏色切換動畫。

這些快速切換的動畫是怎麼實現的呢?讓我們來看下文吧!

基礎

要實現動畫的關鍵是在 SwiftUI 中建立一個實現 Shape 協議的結構體。我們把它命名為 SplashShape

。在 Shape 協議中,有一個方法叫做 path(in rect: CGRect) -> Path,這個方法可以用來設定圖形的外觀。我們就用這個方法來實現本文中的各種動畫。

建立 SplashShape 結構體

下面我們建立一個叫做 SplashStruct 的結構體,它繼承於 Shape 協議。

import SwiftUI

struct SplashShape: Shape {
    
    func path(in rect: CGRect) -> Path {
        return Path()
    }
}
複製程式碼

我們首先建立兩種動畫型別:leftToRight

rightToLeft,效果如下所示:

`leftToRight` & `rightToLeft`

Splash 動畫

我們建立一個名為 SplashAnimation列舉來定義動畫型別,便於以後更方便地擴充套件新動畫(文章末尾可以驗證!)。

import SwiftUI

struct SplashShape: Shape {
    
    public enum SplashAnimation {
        case leftToRight
        case rightToleft
    }
    
    func path(in rect: CGRect) -> Path {
        return
Path() } } 複製程式碼

path() 方法中,我們可以選擇需要使用的動畫,並且返回動畫的 Path。但是首先,我們必須建立變數來儲存動畫型別,記錄動畫過程。

import SwiftUI

struct SplashShape: Shape {
    
    public enum SplashAnimation {
        case leftToRight
        case rightToleft
    }
    
    var progress: CGFloat
    var animationType: SplashAnimation
    
    func path(in rect: CGRect) -> Path {
        return Path()
    }
}
複製程式碼

progress 的取值範圍在 01 之間,它代表整個動畫的完成進度。當我們編寫 path() 方法時,它就會派上用場。

編寫 path() 方法

跟之前說的一樣,為了返回正確的 Path,我們需要明確正在使用哪一種動畫。在 path() 方法中編寫 switch 語句,並且用上我們之前定義的 animationType

func path(in rect: CGRect) -> Path {
   switch animationType {
       case .leftToRight:
           return Path()
       case .rightToLeft:
           return Path()
   }
}
複製程式碼

現在這個方法只會返回空 paths。我們需要建立產生真實動畫的方法。

實現動畫方法

path() 方法的下面,建立兩個新的方法:leftToRight()rightToLeft(),每個方法表示一種動畫型別。在每個方法體內,我們會建立一個矩形形狀的 Path,它會根據 progress 變數的值隨時間發生變換。

func leftToRight(rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: 0,y: 0)) // Top Left
    path.addLine(to: CGPoint(x: rect.width * progress,y: 0)) // Top Right
    path.addLine(to: CGPoint(x: rect.width * progress,y: rect.height)) // Bottom Right
    path.addLine(to: CGPoint(x: 0,y: rect.height)) // Bottom Left
    path.closeSubpath() // Close the Path
    return path
}

func rightToLeft(rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: rect.width,y: 0))
    path.addLine(to: CGPoint(x: rect.width - (rect.width * progress),y: rect.height))
    path.addLine(to: CGPoint(x: rect.width,y: rect.height))
    path.closeSubpath()
    return path
}
複製程式碼

然後在 path() 方法中呼叫上面兩個新方法。

func path(in rect: CGRect) -> Path {
   switch animationType {
       case .leftToRight:
           return leftToRight(rect: rect)
       case .rightToLeft:
           return rightToLeft(rect: rect)
   }
}
複製程式碼

動畫資料

為了確保 Swift 知道在更改 progress變數時如何對 Shape 進行動畫處理,我們需要指定一個響應動畫的變數。在 progressanimationType 變數下面,定義 animatableData。這是一個基於Animatable 協議 的變數,它可以通知 SwiftUI 在資料改變時,對檢視進行動畫處理。

var progress: CGFloat
var animationType: SplashAnimation

var animatableData: CGFloat {
    get { return progress }
    set { self.progress = newValue}
}
複製程式碼

`SplashShape` animating as `progress` changes.

顏色切換時產生動畫

到目前為止,我們已經建立了一個 Shape,它將隨著時間的變化而變化。接下來,我們需要將它新增到檢視中,並在檢視顏色改變時自動對其進行動畫處理。這時候我們引入 SplashView。我們將建立一個 SplashView 來自動更新 SplashShapeprogress 變數。當 SplashView 接收到新的 Color 時,它將觸發動畫。

首先,我們建立 SplashView 結構體。

import SwiftUI

struct SplashView: View {

    var body: some View {
        // SplashShape Here
    }

}
複製程式碼

SplashShape 需要使用 SplashAnimation 列舉作為引數,所以我們會把它作為引數傳遞給 SplashView。另外,我們要在檢視的背景顏色變化時設定動畫,所以我們也要傳遞 Color 引數。這些細節會在我們的初始化方法中詳細說明。

ColorStore 是自定義的 ObservableObject。它用來監聽 SplashView 結構體中 Color 值的改變,以便我們可以初始化 SplashShape 動畫,並最終改變背景顏色。我們稍後展示它的工作原理。

struct SplashView: View {
    
    var animationType: SplashShape.SplashAnimation
    @State private var prevColor: Color // Stores background color
    @ObservedObject var colorStore: ColorStore // Send new color updates

    
    init(animationType: SplashShape.SplashAnimation,color: Color) {
        self.animationType = animationType
        self._prevColor = State<Color>(initialValue: color)
        self.colorStore = ColorStore(color: color)
    }

    var body: some View {
        // SplashShape Here
    }

}

class ColorStore: ObservableObject {
    @Published var color: Color
    
    init(color: Color) {
        self.color = color
    }
}
複製程式碼

構建 SplashView body

body 內部,我們需要返回一個 Rectangle,它和 SplashView 當前的顏色保持一致。然後使用之前定義的 ColorStore,以便於我們接收更新的顏色值來驅動動畫。

var body: some View {
    Rectangle()
        .foregroundColor(self.prevColor) // Current Color
        .onReceive(self.colorStore.$color) { color in
            // Animate Color Update Here
        }
}
複製程式碼

當顏色改變時,我們需要記錄 SplashView 中正在改變的顏色和進度。為此,我們定義 layers 變數。

@State var layers: [(Color,CGFloat)] = [] // New Color & Progress
複製程式碼

現在回到 body 變數內部,我們給 layers 變數新增新接收的 Colors 。新增的時候我們把進度設定為 0。然後,在半秒之內的動畫過程中,我們把進度設定為 1

var body: some View {
    Rectangle()
        .foregroundColor(self.prevColor) // Current Color
        .onReceive(self.colorStore.$color) { color in
            // Animate Color Update Here
            self.layers.append((color,0))
            
            withAnimation(.easeInOut(duration: 0.5)) {
                self.layers[self.layers.count-1].1 = 1.0
            }
        }
}
複製程式碼

現在在這段程式碼中,layers 變數中添加了更新後的顏色,但是顏色並沒有展示出來。為了展示顏色,我們需要在 body 變數內部為 Rectangle 的每一個圖層新增一個覆蓋層。

var body: some View {
    Rectangle()
        .foregroundColor(self.prevColor)
        .overlay(
            ZStack {
                ForEach(layers.indices,id: \.self) { x in
                    SplashShape(progress: self.layers[x].1,animationType: self.animationType)
                        .foregroundColor(self.layers[x].0)
                }

            },alignment: .leading)
        .onReceive(self.colorStore.$color) { color in
            // Animate color update here
            self.layers.append((color,0))

            withAnimation(.easeInOut(duration: 0.5)) {
                self.layers[self.layers.count-1].1 = 1.0
            }
        }
}
複製程式碼

測試效果

你可以在模擬器中執行下面的程式碼。這段程式碼的意思是,當你點選 ContentView 中的按鈕時,它會計算 index 來選擇 SplashView 中的顏色,同時也會觸發 ColorStore 內部的更新。所以,當 SplashShape 圖層新增到 SplashView 時,就會觸發動畫。

import SwiftUI

struct ContentView: View {
    var colors: [Color] = [.blue,.red,.green,.orange]
    @State var index: Int = 0
    
    @State var progress: CGFloat = 0
    var body: some View {
        VStack {
           
            SplashView(animationType: .leftToRight,color: self.colors[self.index])
                .frame(width: 200,height: 100,alignment: .center)
                .cornerRadius(10)
                .shadow(color: Color.black.opacity(0.2),radius: 10,x: 0,y: 4)
            
            Button(action: {
                self.index = (self.index + 1) % self.colors.count
            }) {
                Text("Change Color")
            }
            .padding(.top,20)
        }
  
    }
}
複製程式碼

Color Changing Goodness!

還沒有完成!

我們還有一個功能沒實現。現在我們持續地把圖層新增到 SplashView 上,但是沒有刪除它們。因此,我們需要在動畫完成時把這些圖層清理掉。

SplashView 結構體 body 變數的 onReceive() 方法內部,做如下改變:

.onReceive(self.colorStore.$color) { color in
    self.layers.append((color,0))

    withAnimation(.easeInOut(duration: 0.5)) {
        self.layers[self.layers.count-1].1 = 1.0
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            self.prevColor = self.layers[0].0 // Finalizes background color of SplashView
            self.layers.remove(at: 0) // removes itself from layers array
        }
    }
}
複製程式碼

這行程式碼能讓我們刪除 layers 陣列中使用過的值,並確保 SplashView 基於最新更新的值顯示正確的背景色。

展示成果!

您完成了本教程的案例嗎?您可以給我發一個截圖或者連結來展示你的成果。TrailingClosure.com 將會為使用者的成果製作專題。您可以通過 Twitter @TrailingClosure聯絡我們,或者給我們發郵件 [email protected]

GitHub 原始碼

您可以在我的 Github 上檢視本教程的原始碼!除了顯示的示例外,還包括 SplashShapeSplashView 的完整原始碼。 ....但是等等,還有更多!

彩蛋!

如果你熟悉我之前的教程,你應該瞭解我喜歡彩蛋 ?。在本文開頭,我說過會實現更多動畫。此刻終於來了…… 擊鼓……。

Splash 動畫 ?

哈哈哈!!還記得嗎?我說過會新增更多動畫種類。

enum SplashAnimation {
    case leftToRight
    case rightToLeft
    case topToBottom
    case bottomToTop
    case angle(Angle)
    case circle
}

func path(in rect: CGRect) -> Path {

    switch self.animationType {
        case .leftToRight:
            return leftToRight(rect: rect)
        case .rightToLeft:
            return rightToLeft(rect: rect)
        case .topToBottom:
            return topToBottom(rect: rect)
        case .bottomToTop:
            return bottomToTop(rect: rect)
        case .angle(let splashAngle):
            return angle(rect: rect,angle: splashAngle)
        case .circle:
            return circle(rect: rect)
    }

}
複製程式碼

你肯定會想…… “哇,彩蛋也太多了……”。不必苦惱。我們只需要在 SplashShapepath() 方法中新增幾個方法,就能搞定。

下面我們逐個動畫來搞定……

topToBottom 和 bottomToTop 動畫

這些方法與 leftToRightrightToLeft 非常相似,它們從 shape 的底部或頂部開始建立 path ,並使用 progress 變數隨時間對其進行變換。

func topToBottom(rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: 0,y: 0))
    path.addLine(to: CGPoint(x: rect.width,y: rect.height * progress))
    path.addLine(to: CGPoint(x: 0,y: rect.height * progress))
    path.closeSubpath()
    return path
}

func bottomToTop(rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: 0,y: rect.height - (rect.height * progress)))
    path.addLine(to: CGPoint(x: 0,y: rect.height - (rect.height * progress)))
    path.closeSubpath()
    return path
}
複製程式碼

circle 動畫

如果你還記得小學幾何知識,就應該瞭解勾股定理。a^2 + b^2 = c^2

`c` is the radius of the final circle our path needs to draw

ab 可以視為矩形的 高度寬度,我們能夠根據它們求得 c,即覆蓋整個矩形所需的圓的半徑。我們以此為基礎構建圓的 path,並使用 progress 變數隨時間對它進行變換。

func circle(rect: CGRect) -> Path {
    let a: CGFloat = rect.height / 2.0
    let b: CGFloat = rect.width / 2.0

    let c = pow(pow(a,2) + pow(b,2),0.5) // a^2 + b^2 = c^2  --> Solved for 'c'
    // c = radius of final circle

    let radius = c * progress
    // Build Circle Path
    var path = Path()
    path.addArc(center: CGPoint(x: rect.midX,y: rect.midY),radius: radius,startAngle: Angle(degrees: 0),endAngle: Angle(degrees: 360),clockwise: true)
    return path

}
複製程式碼

Animating Using a Circle Path

angle 動畫

這個動畫知識點有點多。你需要使用切線計算角度的斜率,然後根據這個斜率建立一條直線。在矩形上移動這條直線時,根據它來繪製一個直角三角形。參見下圖,各種彩色的線表示該線隨時間移動時,覆蓋整個矩形的狀態。

The line moves in order from the red,blue,green,then purple. to cover the rectangle

方法如下:

func angle(rect: CGRect,angle: Angle) -> Path {
        
    var cAngle = Angle(degrees: angle.degrees.truncatingRemainder(dividingBy: 90))

    // Return Path Using Other Animations (topToBottom,leftToRight,etc) if angle is 0,90,180,270
    if angle.degrees == 0 || cAngle.degrees == 0 { return leftToRight(rect: rect)}
    else if angle.degrees == 90 || cAngle.degrees == 90 { return topToBottom(rect: rect)}
    else if angle.degrees == 180 || cAngle.degrees == 180 { return rightToLeft(rect: rect)}
    else if angle.degrees == 270 || cAngle.degrees == 270 { return bottomToTop(rect: rect)}


    // Calculate Slope of Line and inverse slope
    let m = CGFloat(tan(cAngle.radians))
    let m_1 = pow(m,-1) * -1
    let h = rect.height
    let w = rect.width

    // tan (angle) = slope of line
    // y = mx + b ---> b = y - mx   ~ 'b' = y intercept
    let b = h - (m_1 * w) // b = y - (m * x)

    // X and Y coordinate calculation
    var x = b * m * progress
    var y = b * progress

    // Triangle Offset Calculation
    let xOffset = (angle.degrees > 90 && angle.degrees < 270) ? rect.width : 0
    let yOffset = (angle.degrees > 180 && angle.degrees < 360) ? rect.height : 0

    // Modify which side the triangle is drawn from depending on the angle
    if angle.degrees > 90 && angle.degrees < 180 { x *= -1 }
    else if angle.degrees > 180 && angle.degrees < 270 { x *= -1; y *= -1 }
    else if angle.degrees > 270 && angle.degrees < 360 { y *= -1 }

    // Build Triangle Path
    var path = Path()
    path.move(to: CGPoint(x: xOffset,y: yOffset))
    path.addLine(to: CGPoint(x: xOffset + x,y: yOffset))
    path.addLine(to: CGPoint(x: xOffset,y: yOffset + y))
    path.closeSubpath()
    return path

}
複製程式碼

Angles 45°,135°,225°,315°

請支援我!

您可以用此連結進行訂閱。如果您不是在 TrailingClosure.com上閱讀本文,以後可以來逛逛!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄