iOS 動畫 - 窗景篇(二)

本文是系列文章的第二篇。

看過上一篇文章的同學,已經知道標題中的“景”指代 view,“窗”指代 view.mask,窗景篇就是在梳理 mask 及 mask 動畫。如果你還不熟悉 iOS 的 mask,建議先看一下第一篇

相對於景來說,窗的變化更多樣一些,所以本文我們重點來看一下窗的效果。

我們從3個維度來看:窗在動嗎?窗在變嗎?有幾個窗?

很多動畫就是這3個維度的單獨體現,或者組合後的效果。我們先看一下各個維度的單獨效果,然後再來看一下它們的組合效果。

一、窗動

前文中,我們用一個圓作爲窗,先貼張圖回憶一下:

我們大都做過基本的動畫,因此可以想到,只要動畫地改變圓 mask 的中心位置,就可以讓窗動起來。

效果如下面的動圖所示:

示意代碼如下:

/// viewDidLoad
// 景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 圓窗
let mask = CircleView()
mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
mask.center = CGPoint(x: 100, y: 100)
self.mask = mask
frontView.mask = mask

// 窗動
startAnimation()

/// startAnimation
// 動畫地改變 mask 的中心
private func startAnimation() {
    mask.layer.removeAllAnimations()
    
    let anim = CAKeyframeAnimation(keyPath: "position")
    let bound = UIScreen.main.bounds
    anim.values = [CGPoint(x: 100, y: 250), CGPoint(x:bound.width - 100 , y: 250), CGPoint(x: bound.midX, y: 450), CGPoint(x: 100, y: 250)]
    anim.duration = 4
    anim.repeatCount = Float.infinity
    mask.layer.add(anim, forKey: nil)
}

讓窗動起來非常簡單,這簡單的效果也可以成爲其他效果的基礎。

比如我們加入一個 pan(拖動) 手勢,實現這樣一個效果:

思路很簡單:

  1. 初始時一片黑色,窗的大小爲0
  2. pan 手勢開始時,開始顯示窗戶
  3. pan 手勢拖動時,移動窗
  4. pan 手勢結束時,窗的大小恢復爲0,迴歸一片黑色

示意代碼如下:

// 在剛纔窗動的代碼基礎上
// 添加 pan 手勢來控制 mask 的 center

@objc func onPan(_ pan: UIPanGestureRecognizer) {
    switch pan.state {
    case .began:
        // 拖動開始,顯示窗
        mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
        mask.center = pan.location(in: pan.view)
    case .changed:
        // 拖動過程,移動窗
        mask.center = pan.location(in: pan.view)
    default:
        // 其他,隱藏窗
        mask.frame = CGRect.zero
    }
}

好了,“窗動”先看到這,接下來,我們看一下“窗變”這個維度。

二、窗變

我們還是用圓窗示例,這次使用前後兩個 view,圓作爲 frontView 的 mask;

還是看一下前文的一張圖:

這次我們讓圓窗動態的變大(縮放),縮放也是基本的動畫,效果如下面的動圖所示:

示意代碼如下:

/// viewDidLoad
// back view
backView.frame = UIScreen.main.bounds
view.addSubview(backView)

// 景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 圓窗
let mask = CircleView()
mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
mask.center = CGPoint(x: frontView.bounds.midX, y: frontView.bounds.midY)
self.mask = mask
frontView.mask = mask

// 窗變
startAnimation()

/// startAnimation
// 動畫改變 mask 的大小
private func startAnimation() {
    mask.layer.removeAllAnimations()
    
    let scale: CGFloat = 5.0
    let anim = CABasicAnimation(keyPath: "transform.scale.xy")
    anim.fromValue = 1.0
    anim.toValue = scale
    anim.duration = 1
    mask.layer.add(anim, forKey: nil)
    
    // 真正改變 layer 的 transform,防止動畫結束後恢復原狀
    mask.layer.transform = CATransform3DMakeScale(scale, scale, 1)
}

我想你已經發現了,將這個效果和 iOS 轉場機制結合起來,就是一種很常見的轉場效果。

關於窗變,我們再舉一個常見的例子:進度環效果。
先看一下效果,如下面的動圖所示:

其實就是一個漸變的景,加一個圓環的窗,和前文我們看過的文字窗沒有什麼區別,如下圖所示:

只不過是窗從無逐漸地變化成了完整的圓環;最適合這種變化的,是 stroke 動畫。

stroke,也就是 CAShapeLayer 的 strokeStart 和 strokeEnd 屬性,網上有成熟的教程

爲了方便理解這個效果,本文只對 stroke 做個基本的介紹:

  1. 我們想畫一個圓環,首先是設計圓環的起點和終點,如果從起點開始畫線,畫到終點,就能畫出完整的圓環,我們可以把從起點(strokeStart)畫(stroke)到終點(strokeEnd)叫做路徑(path)
  2. 但我們現在只想畫出圓環的一部分,比如從圓環 1/4(0.25) 處,畫到 3/4(0.75) 處;那我們設置strokeStart = 0.25,strokeEnd = 0.75,這樣的話,圓環(path)就只會顯示 1/4 到 3/4 這部分了
  3. strokeStart、strokeEnd 屬性,就是相對於完整的 path來說,我們要顯示哪一段
  4. 我們想讓圓環一開始不顯示,那設置 strokeStart = 0,strokeEnd = 0 就可以了
  5. 我們想讓圓環最後完整顯示,設置 strokeStart = 0,strokeEnd = 1(也就是 100%) 就可以了
  6. 動畫過程就是 strokeEnd 從0到1的變化。

示意代碼如下:

/// ViewController
// 漸變景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 環窗
let mask = RingView()
mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
mask.center = CGPoint(x: frontView.bounds.midX, y: frontView.bounds.midY)
self.mask = mask
frontView.mask = mask

// 窗變
// 動畫地改變 mask 圓環的完成度(從圓環未開始,到圓環完全閉合)
startAnimation()


/// RingView(環 view)
// 爲環 view 設置 progress 可以改變 它的 strokeEnd
var progress: CGFloat = 0 {
    didSet {
        if progress < 0 {
            progress = 0
        } else if progress > 1 {
            progress = 1
        } else {}
        
        (layer as! CAShapeLayer).strokeEnd = progress
    }
}

我們可以使用 CAShapeLayer 的 path 畫出各式各樣的窗,配合 strokeStart、strokeEnd, 會有很多有趣的 stroke 窗變動畫。

接下來,我們看一下“多窗”這個維度,
由於單純的多窗沒有什麼效果,這次我們直接和“窗動”或者“窗變”組合起來看。

三、多窗

由於 view 只有一個 mask 屬性,所以我們所說的多窗,不是多個 mask,而是在 mask 上做文章。
比如,我們可以用一種粗糙但直觀的方式,來實現這樣一個效果:

實現思路如下:

  1. mask 有 6個 子view,相當於6扇小窗戶
  2. 子 view 一個接一個的變透明,窗戶依次打開

示意代碼如下:

/// ViewController
// back view
backView.frame = UIScreen.main.bounds
view.addSubview(backView)

// 景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 多窗(百葉窗)
// mask 的 子 view,依次隱藏
let mask = ShutterView(frame: frontView.bounds, count: 8)
frontView.mask = mask

mask.startAnimation()


/// ShutterView(多窗 view)
func startAnimation() {
    layers.enumerated().forEach {
        let anim = CABasicAnimation(keyPath: "opacity")
        anim.duration = 0.5
        anim.beginTime = $0.element.convertTime(CACurrentMediaTime(), from: nil) + anim.duration * Double($0.offset)
        anim.fromValue = 1
        anim.toValue = 0
        // 由於 layer 的動畫用 beginTime 做了延遲
        // 後面代碼修改 opacity 的真實值後,layer 開始就會顯示(opacity == 0)的狀態
        // 所以我們使用 backwards,來保證動畫執行前,layer 顯示 fromValue(opacity == 1) 的狀態
        anim.fillMode = CAMediaTimingFillMode.backwards
        $0.element.add(anim, forKey: nil)
        // 修改 opacity 的真實值,防止動畫完成後恢復原樣
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        $0.element.opacity = 0
        CATransaction.commit()
    }
}

以上是“多窗”和“窗變”(透明度變化)的組合,

看到一組類似的小窗,有的同學可能就想到了 CAReplicatorLayer 這種專精於複製子 layer 的類,

那接下來,我們用 CAReplicatorLayer 當窗試試,來實現一個“多窗”和“窗動”的組合。

四、多窗(CAReplicatorLayer)

網上已經有 CAReplicatorLayer 的成熟教程,在此我們只做個簡單的類比,讓沒接觸過的同學有個印象。

CAReplicatorLayer 就好比 UITableView,你可以給它指定一個 subLayer 和 數量,它可以把 subLayer 複製到你指定的數量,就像 UITableView 根據你指定的 Cell 類創建並管理一組 Cell 一樣。

UITableView 可以管理 Cell 的佈局,可以讓 Cell 一個接一個的排列,類似地,CAReplicatorLayer 也可以根據你的設置,讓 一組 subLayer 按規則地排列。

CAReplicatorLayer 還可以根據設置,讓一組 subLayer 有各種過渡效果,比如第一個 subLayer 背景色爲白色,中間的subLayer 背景色遞減,直到最後一個 subLayer 爲黑色。本文的效果只涉及 subLayer 位置,因此不再討論其他設置。

本例中,我們依然用漸變 view 作爲景,讓 CAReplicatorLayer 複製 3個子 layer(圓) 作爲窗,來實現一個 loading 動畫,效果如下面的動圖所示:

有了前面的經驗,大家很容易發現,這個動畫就是 3個小圓窗,在漸變景上面不斷交換各自的位置,如下圖所示:

示意代碼如下:

/// ViewController
// 漸變景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 多窗(3球窗,CAReplicatorLayer 窗)
let mask = TriangleLoadingView()
mask.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
mask.center = CGPoint(x: frontView.bounds.midX, y: frontView.bounds.midY)
self.mask = mask
frontView.mask = mask

// 窗動(3球旋轉)
mask.startAnimation()

/// TriangleLoadingView
// 創建3球窗
override init(frame: CGRect) {
    super.init(frame: frame)
    
    let layer = (self.layer as! CAReplicatorLayer)
    layer.backgroundColor = UIColor.clear.cgColor
    layer.instanceCount = 3
    // 3個小球
    // 每個以本 view 的 layer(CAReplicatorLayer)中心爲原點,以 z 軸爲旋轉軸
    // 以上一個 cellLayer 的狀態 爲初始態,順時針旋轉 120°
    // 形成一個等邊三角形
    layer.instanceTransform = CATransform3DMakeRotation(CGFloat.pi / 3 * 2, 0, 0, 1)
    layer.addSublayer(cellLayer)
}

// 定位小球
override func layoutSubviews() {
    super.layoutSubviews()
    
    // 第1個小球,在本 view 的頂部,水平居中
    cellLayer.position = CGPoint(x: bounds.midX, y: Constants.cellRadius)
}

// 執行動畫(3球旋轉)
func startAnimation() {
    cellLayer.removeAllAnimations()
    
    let anim = CABasicAnimation(keyPath: "position")
    let from = cellLayer.position
    anim.fromValue = from
    // 使用一點等邊三角形的知識
    // r:等邊三角形的外徑(外接圓的半徑)
    let r = bounds.midY -  Constants.cellRadius
    // 根據等邊三角形的上頂點的座標和外徑,求右下頂點的座標
    let radian = CGFloat.pi / 6
    anim.toValue = CGPoint(x: from.x + r * cos(radian), y: from.y + r + r * sin(radian))
    anim.duration = 1
    anim.repeatCount = Float.infinity
    cellLayer.add(anim, forKey: nil)
    
    // 注:我們實現了圓窗從上頂點到右下頂點的移動
    // CAReplicatorLayer 就可以根據我們之前設置的 instanceTransform,自動幫我們完成其他兩種頂點間的移動
}

看到 CAReplicatorLayer,有的同學就想到了 CAEmitterLayer,也就是實現粒子效果的 layer,
粒子也能當窗嗎?

當然能,一切 view(layer)都可以當做窗,接下來我們來看一個 CAEmitterLayer 實現的 “多窗”、“窗動”、“窗變” 3個維度的組合。

五、粒子窗

CAEmitterLayer 的知識我們也不展開了,直接看一個效果,如下面動圖所示:

實現思路很簡單:

  1. 用一張圖片作爲景
  2. 用一個從底部向上發射(窗動)不斷變大(窗變)的心形粒子(多窗)的 view 作爲窗

關於CAEmitterLayer的使用 網上有成熟的教程

示意代碼如下:

/// ViewController
// back view
backView.frame = UIScreen.main.bounds
view.addSubview(backView)

// 景
frontView.frame = UIScreen.main.bounds
view.addSubview(frontView)

// 粒子窗
let mask = EmitterView()
mask.frame = frontView.bounds
frontView.mask = mask

/// EmitterView
/// 配置粒子窗
private func configLayer() {
    // 心形粒子
    let cell = CAEmitterCell()
    // 樣式
    cell.contents = UIImage(named: "love")?.cgImage
    cell.scale = 0.5
    cell.scaleSpeed = 2
    // 產生粒子的速率
    cell.birthRate = 20
    // 存活時長
    cell.lifetime = 3
    // 方向
    cell.emissionLongitude = CGFloat(Float.pi / 2)
    cell.emissionRange = CGFloat.pi / 3
    // 速度
    cell.velocity = -250
    cell.velocityRange = 50

    // 發射器
    let emitterLayer = (layer as! CAEmitterLayer)
    emitterLayer.emitterPosition = CGPoint(x: UIScreen.main.bounds.midX, y: UIScreen.main.bounds.height)
    emitterLayer.birthRate = 1
    emitterLayer.emitterSize = CGSize(width: UIScreen.main.bounds.width, height: 0)
    emitterLayer.emitterShape = CAEmitterLayerEmitterShape.point
    emitterLayer.emitterCells = [cell]
}

尾聲

這一篇,我們以窗爲例,從“窗動”、“窗變”、“多窗” 3個維度入手,梳理了一些 mask 動畫的例子。
窗的思路已經打開,那麼更爲簡單的景,我們就不再單獨開篇。

在下一篇文章裏,我們將一起看一個初看複雜、其實簡單的效果。文章的重點並不是講效果本身,而是想幫大家回憶起一個道理:看上去複雜的東西,未必就真的複雜。

本文所有示例,在 GitHub 庫 裏都有完整的代碼。

感謝您的閱讀,我們下篇文章見。

傳送門

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章