Stories

Detail Return Return

高級 SwiftUI 動畫 — Part 3:AnimatableModifier - Stories Detail

前言

之前的兩篇文章animating paths 和 transform matrices 對 Animatable 協議使用做了介紹,今天這篇文章將為大家介紹 AnimatableModifier,使用它可以完成更多的動畫工作。

AnimatableModifier 是一個 ViewModifier,符合 Animatable 協議,如果對這個協議不瞭解可以閲讀之前發佈的兩篇文章。

AnimatableModifier 無法實現動畫

如果是第一次使用 AnimatableModifier,可能會遇到問題。寫一個簡單的動畫,但是沒有動畫效果。 我又試了幾次,也沒有成功。因此我認為該功能不存並且放棄使用。幸運的是,後來我堅持了下來。事實證明,我的第一個 modifier 非常好,但是 animatable modifiers 在容器中不起作用。 我在第二次嘗試時,動畫視圖不在容器內。

例如,以下 modifier 可以成功實現動畫:

MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))

但是相同的代碼,在 VStack 中就沒有動畫了:

VStack {
    MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))
}

這個問題在官方解決之前,經過嘗試,可以在 VStack 中改成下面的代碼,就可以實現動畫:

VStack {
    Color.clear.overlay(MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))).frame(width: 100, height: 100)
}

這樣寫是使用一個透明視圖佔據實際視圖空間,動畫被放在透明視圖上,使用 .overlay()。有點不方便的是,我們需要知道實際視圖有多大,所以我們可以在它後面設置透明視圖的框架。在下面的示例中可以開到實現代碼。

動畫文本

首先需要製作一些文字動畫。對於這個例子,我們將創建一個進度加載指示器。

可能很多人都認為應該使用動畫路徑實現。但是,內部標籤就無法設置動畫,使用 AnimatableModifier 可以實現。

完整的代碼作為 示例10 在文末鏈接中。關鍵代碼如下:

struct PercentageIndicator: AnimatableModifier {
    var pct: CGFloat = 0
    
    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        content
            .overlay(ArcShape(pct: pct).foregroundColor(.red))
            .overlay(LabelView(pct: pct))
    }
    
    struct ArcShape: Shape {
        let pct: CGFloat
        
        func path(in rect: CGRect) -> Path {

            var p = Path()

            p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
                     radius: rect.height / 2.0 + 5.0,
                     startAngle: .degrees(0),
                     endAngle: .degrees(360.0 * Double(pct)), clockwise: false)

            return p.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 10))
        }
    }
    
    struct LabelView: View {
        let pct: CGFloat
        
        var body: some View {
            Text("\(Int(pct * 100)) %")
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(.white)
        }
    }
}

在示例代碼中可以看到,沒有使 ArcShape animatable 。 因為 modifier 已經多次創建形狀,具有不同的 pct 值。

動畫漸變

在實現漸變動畫時,可能會遇到一些限制。比如,可以為起點和終點設置動畫,但是不能為漸變顏色設置動畫。使用 AnimatableModifier 可以避免出現這種情況。

很容易就可以實現這個功能,在這個基礎上可以實現更多複雜的動畫。如果需要插入中間顏色,我們只需要計算 RGB 值的平均值。另外需要注意,modifier 假設輸入顏色數組都包含相同數量的顏色。

完整的代碼作為 示例11 在文末鏈接中。關鍵代碼如下:

struct AnimatableGradient: AnimatableModifier {
    let from: [UIColor]
    let to: [UIColor]
    var pct: CGFloat = 0
    
    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        var gColors = [Color]()
        
        for i in 0..<from.count {
            gColors.append(colorMixer(c1: from[i], c2: to[i], pct: pct))
        }
        
        return RoundedRectangle(cornerRadius: 15)
            .fill(LinearGradient(gradient: Gradient(colors: gColors),
                                 startPoint: UnitPoint(x: 0, y: 0),
                                 endPoint: UnitPoint(x: 1, y: 1)))
            .frame(width: 200, height: 200)
    }
    
    // This is a very basic implementation of a color interpolation
    // between two values.
    func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
        guard let cc1 = c1.cgColor.components else { return Color(c1) }
        guard let cc2 = c2.cgColor.components else { return Color(c1) }
        
        let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
        let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
        let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)

        return Color(red: Double(r), green: Double(g), blue: Double(b))
    }
}

更多文本動畫

這個示例中,將再次實現一個文本動畫。但是是逐步進行,一次放大一個字符

完整的代碼作為 示例12 在文末鏈接中。關鍵代碼如下:

struct WaveTextModifier: AnimatableModifier {
    let text: String
    let waveWidth: Int
    var pct: Double
    var size: CGFloat
    
    var animatableData: Double {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        
        HStack(spacing: 0) {
            ForEach(Array(text.enumerated()), id: \.0) { (n, ch) in
                Text(String(ch))
                    .font(Font.custom("Menlo", size: self.size).bold())
                    .scaleEffect(self.effect(self.pct, n, self.text.count, Double(self.waveWidth)))
            }
        }
    }
    
    func effect(_ pct: Double, _ n: Int, _ total: Int, _ waveWidth: Double) -> CGFloat {
        let n = Double(n)
        let total = Double(total)
        
        return CGFloat(1 + valueInCurve(pct: pct, total: total, x: n/total, waveWidth: waveWidth))
    }
    
    func valueInCurve(pct: Double, total: Double, x: Double, waveWidth: Double) -> Double {
        let chunk = waveWidth / total
        let m = 1 / chunk
        let offset = (chunk - (1 / total)) * pct
        let lowerLimit = (pct - chunk) + offset
        let upperLimit = (pct) + offset
        guard x >= lowerLimit && x < upperLimit else { return 0 }
        
        let angle = ((x - pct - offset) * m)*360-90
        
        return (sin(angle.rad) + 1) / 2
    }
}

extension Double {
    var rad: Double { return self * .pi / 180 }
    var deg: Double { return self * 180 / .pi }
}

計數器動畫

如果你沒有用過或者對 AnimatableModifier 不瞭解,下面這個示例基本上是無法實現的。下面我們來介紹一下如何創建一個計數器動畫:

這個練習的訣竅是為每個數字使用 5 個文本視圖,並使用 .spring() 動畫上下移動它們。 我們還需要使用 .clipShape() 修飾符來隱藏在邊框之外繪製的部分。 為了更好地理解它是如何工作的,您可以評論 .clipShape() 並大大減慢動畫的速度。 完整代碼在本頁頂部鏈接的 gist 文件中以 Example13 的形式提供。

這個動畫實現的主要內容是每個數字使用 5 個文本視圖,並使用 .spring() 動畫上下移動它們。然後使用 .clipShape() 修飾符來隱藏邊框之外區域。如果想跟清晰的理解他們是如何實現的,可以通過 .clipShape() 讓動畫速度變慢。

完整的代碼作為 示例13 在文末鏈接中。關鍵代碼如下:

struct MovingCounterModifier: AnimatableModifier {
        @State private var height: CGFloat = 0

        var number: Double
        
        var animatableData: Double {
            get { number }
            set { number = newValue }
        }
        
        func body(content: Content) -> some View {
            let n = self.number + 1
            
            let tOffset: CGFloat = getOffsetForTensDigit(n)
            let uOffset: CGFloat = getOffsetForUnitDigit(n)

            let u = [n - 2, n - 1, n + 0, n + 1, n + 2].map { getUnitDigit($0) }
            let x = getTensDigit(n)
            var t = [abs(x - 2), abs(x - 1), abs(x + 0), abs(x + 1), abs(x + 2)]
            t = t.map { getUnitDigit(Double($0)) }
            
            let font = Font.custom("Menlo", size: 34).bold()
            
            return HStack(alignment: .top, spacing: 0) {
                VStack {
                    Text("\(t[0])").font(font)
                    Text("\(t[1])").font(font)
                    Text("\(t[2])").font(font)
                    Text("\(t[3])").font(font)
                    Text("\(t[4])").font(font)
                }.foregroundColor(.green).modifier(ShiftEffect(pct: tOffset))
                
                VStack {
                    Text("\(u[0])").font(font)
                    Text("\(u[1])").font(font)
                    Text("\(u[2])").font(font)
                    Text("\(u[3])").font(font)
                    Text("\(u[4])").font(font)
                }.foregroundColor(.green).modifier(ShiftEffect(pct: uOffset))
            }
            .clipShape(ClipShape())
            .overlay(CounterBorder(height: $height))
            .background(CounterBackground(height: $height))
        }
        
        func getUnitDigit(_ number: Double) -> Int {
            return abs(Int(number) - ((Int(number) / 10) * 10))
        }
        
        func getTensDigit(_ number: Double) -> Int {
            return abs(Int(number) / 10)
        }
        
        func getOffsetForUnitDigit(_ number: Double) -> CGFloat {
            return 1 - CGFloat(number - Double(Int(number)))
        }
        
        func getOffsetForTensDigit(_ number: Double) -> CGFloat {
            if getUnitDigit(number) == 0 {
                return 1 - CGFloat(number - Double(Int(number)))
            } else {
                return 0
            }
        }

    }

動畫文本顏色

通常情況下是通過 .foregroundColor() 為動畫添加顏色,但是在文本類動畫中使用沒有效果,不知道是缺少什麼配置還是什麼原因。我通過下面的方法實現給文本動畫添加顏色。

完整的代碼作為 示例14 在文末鏈接中。關鍵代碼如下:

struct AnimatableColorText: View {
    let from: UIColor
    let to: UIColor
    let pct: CGFloat
    let text: () -> Text
    
    var body: some View {
        let textView = text()
        
        return textView.foregroundColor(Color.clear)
            .overlay(Color.clear.modifier(AnimatableColorTextModifier(from: from, to: to, pct: pct, text: textView)))
    }
    
    struct AnimatableColorTextModifier: AnimatableModifier {
        let from: UIColor
        let to: UIColor
        var pct: CGFloat
        let text: Text
        
        var animatableData: CGFloat {
            get { pct }
            set { pct = newValue }
        }

        func body(content: Content) -> some View {
            return text.foregroundColor(colorMixer(c1: from, c2: to, pct: pct))
        }
        
        // This is a very basic implementation of a color interpolation
        // between two values.
        func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
            guard let cc1 = c1.cgColor.components else { return Color(c1) }
            guard let cc2 = c2.cgColor.components else { return Color(c1) }
            
            let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
            let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
            let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)

            return Color(red: Double(r), green: Double(g), blue: Double(b))
        }

    }
}

版本相關問題

通過上面介紹可以看出 AnimatableModifier 非常強大,但是還存在一些問題。另外在 Xcode 和 iOS/macOS 某些版本中,App 在啓動時會崩潰。而且是在部署時,正常開發編譯中是不會發生這種情況。

dyld: Symbol not found: _$s7SwiftUI18AnimatableModifierPAAE13_makeViewList8modifier6inputs4bodyAA01_fG7OutputsVAA11_GraphValueVyxG_AA01_fG6InputsVAiA01_L0V_ANtctFZ
  Referenced from: /Applications/MyApp.app/Contents/MacOS/MyApp
  Expected in: /System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI

例如,如果 App 在 Xcode 11.3 上部署並在 macOS 10.15.0 上執行,就會出現 “Symbol not found” 錯誤。然而,在 macOS 10.15.1 上運行相同的可執行文件可以正常工作。

關於我們

Swift社區是由 Swift 愛好者共同維護的公益組織,我們在國內以微信公眾號的運營為主,我們會分享以 Swift實戰SwiftUlSwift基礎為核心的技術內容,也整理收集優秀的學習資料。

特別感謝 Swift社區 編輯部的每一位編輯,感謝大家的辛苦付出,為 Swift社區 提供優質內容,為 Swift 語言的發展貢獻自己的力量。

user avatar HarmonyOS5 Avatar hightopo Avatar
Favorites 2 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.