還記得在第1課(4-1a)一開始學習用Animation物件產生動畫效果時,需要兩個步驟,第一步是指定一個「時間曲線」來產出Animation物件實例,第二步再用全域函式 withAnimation() 或修飾語 .animation() 來啟動動畫效果。

第一步驟所用的時間曲線,背後就是以「三階貝茲曲線」來定義,根據曲線的不同,產生節奏有快有慢的動畫效果。本節就用畫布 Canvas 與 addCurve() 來畫出各種時間曲線,這需要用到一個簡單的數學技巧:正規化。

所謂正規化(Normalization)或稱一般化,其實就是將某些條件標準化。例如用比例的概念,將(x, y)座標範圍限定在 0 到 1 之間,如 (0.5, 0.5) 代表視框寬、高各一半的位置,也就是中心點。這樣不管畫布實際寬高尺寸多大,都能夠用正規化座標來表示整個畫框範圍,如下圖:

截圖 2023-02-23 上午10.26.57.png

正規化的座標值(x, y)就相當於寬高比例,所以要從正規化座標還原回螢幕座標,算法就是乘以寬、高,並將正規化(數學座標)以「左下角」為原點,轉回以螢幕「左上角」為原點。如上圖所示。

這種方法在向量繪圖中特別有用,因為(x, y)座標值只需指定0到1之間的實數,就能縮放到任何尺寸的螢幕或視框之中。因此不管是 Apple 的系統圖示(SF Symbols)或是網頁SVG向量圖格式,都可使用正規化座標。

三階貝茲曲線也可以正規化,通常將起點設為(0, 0),終點設為(1, 1),所以只要再指定兩個控制點的正規化座標,就可決定一條曲線。例如 Animation.default 用的曲線稱為 easeInOut (緩入緩出),兩個控制點的座標分為是 (0.42, 0.0) 與 (0.58, 1.0),這樣是不是更方便,且通用性更高!

以下範例程式參考某網站的數據,列出12種時間曲線。不同的時間曲線,差別只在於兩個控制點的正規化座標不同而已:

// 4-7b 時間曲線
// Created by Heman, 2022/05/12
import PlaygroundSupport
import SwiftUI

struct 三階貝茲曲線: View {
    var unitX1 = 0.0                // unitX = x/寬
    var unitY1 = 1.0                // unitY = y/高
    var unitX2 = 0.0
    var unitY2 = 1.0
    var 說明 = "三階貝茲曲線"
    var body: some View {
        Canvas { 圖層, 尺寸 in
            let 寬 = 尺寸.width
            let 高 = 尺寸.height
            let 左上角 = CGPoint.zero
            let 左下角 = CGPoint(x: 0, y: 高)
            let 右上角 = CGPoint(x: 寬, y: 0)
            let 控制點1 = CGPoint(          // 以左下角為原點的數學座標
                x: 寬 * unitX1,            // 轉換為螢幕座標(左上角為原點)
                y: 高 - 高 * unitY1)
            let 控制點2 = CGPoint(
                x: 寬 * unitX2,
                y: 高 - 高 * unitY2)
            
            var 畫筆 = Path()               // 對角線
            畫筆.move(to: 左下角)
            畫筆.addLine(to: 右上角)
            圖層.stroke(畫筆, with: .color(.gray))
            
            畫筆 = Path()
            for 圓心 in [控制點1, 控制點2, 左下角, 右上角] {
                畫筆.move(to: 圓心)
                畫筆.addArc(                // 小圓點
                    center: 圓心, 
                    radius: 5, 
                    startAngle: .zero, 
                    endAngle: .degrees(360), 
                    clockwise: false)
            }
            圖層.fill(畫筆, with: .color(.cyan))
            
            畫筆 = Path()                   // 三階貝茲曲線
            畫筆.move(to: 左下角)
            畫筆.addCurve(to: 右上角, control1: 控制點1, control2: 控制點2)
            圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 3)
            
            var 文字 = 圖層.resolve(Text(說明))  // 說明文字
            let 文字尺寸 = 文字.measure(in: 尺寸)
            let 文字框 = CGRect(
                x: 寬 - 文字尺寸.width - 10,
                y: 高 - 文字尺寸.height - 10,
                width: 文字尺寸.width,
                height: 文字尺寸.height)
            // print(文字尺寸, 文字框)
            文字.shading = .color(.gray)
            圖層.draw(文字, in: 文字框)
            
        }
    }
}

// Reference to <https://easings.net/> for parameters of timing curves.
struct 畫布: View {
    let 長寬 = 125.0
    var body: some View {
        VStack {
            Label("[SwiftUI]4-7b 時間曲線", systemImage: "swift")
                .font(.title)
                .foregroundColor(.orange)
                .padding()
            HStack {
                三階貝茲曲線(unitX1: 0.42, unitY1: 0.0, unitX2: 1.0, unitY2: 1.0, 說明: "easeIn")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeIn
                三階貝茲曲線(unitX1: 0.0, unitY1: 0.0, unitX2: 0.58, unitY2: 1.0, 說明: "easeOut")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeOut
                三階貝茲曲線(unitX1: 0.42, unitY1: 0.0, unitX2: 0.58, unitY2: 1.0, 說明: "easeInOut")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeInOut
            }
            HStack {
                三階貝茲曲線(unitX1: 0.32, unitY1: 0.0, unitX2: 0.67, unitY2: 0.0, 說明: "easeInCubic")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeInCubic
                三階貝茲曲線(unitX1: 0.33, unitY1: 1.0, unitX2: 0.68, unitY2: 1.0, 說明: "easeOutCubic")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeOutCubic
                三階貝茲曲線(unitX1: 0.65, unitY1: 0.0, unitX2: 0.35, unitY2: 1.0, 說明: "easeInOutCubic")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeInOutCubic
            }
            HStack {
                三階貝茲曲線(unitX1: 0.64, unitY1: 0.0, unitX2: 0.78, unitY2: 0.0, 說明: "easeInQuint")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeInQuint
                三階貝茲曲線(unitX1: 0.22, unitY1: 1.0, unitX2: 0.36, unitY2: 1.0, 說明: "easeOutQuint")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeOutQuint
                三階貝茲曲線(unitX1: 0.83, unitY1: 0.0, unitX2: 0.17, unitY2: 1.0, 說明: "easeInOutQuint")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeInOutQuint
            }
            HStack {
                三階貝茲曲線(unitX1: 0.55, unitY1: 0.0, unitX2: 1.0, unitY2: 0.45, 說明: "easeInCirc")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeInCirc
                三階貝茲曲線(unitX1: 0.0, unitY1: 0.55, unitX2: 0.45, unitY2: 1.0, 說明: "easeOutCirc")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeOutCirc
                三階貝茲曲線(unitX1: 0.85, unitY1: 0.0, unitX2: 0.15, unitY2: 1.0, 說明: "easeInOutCirc")
                    .frame(width: 長寬, height: 長寬)
                    .border(.red)  // easeInOutCirc
            }
        }
    }
}

// PlaygroundPage.current.setLiveView(三階貝茲曲線(unitX1: 0.0, unitY1: 0.75, unitX2: 0.25, unitY2: 1.0))
PlaygroundPage.current.setLiveView(畫布())

執行結果顯示出來的時間曲線如下圖:

截圖 2022-05-13 下午3.08.07.png

下次如果想自己定義 Animation 時間曲線,應該就知道怎麼做了。如下例:

let 動畫效果 = Animation.timingCurve(0.85, 0, 0.15, 1, duration: 1.0)