ポップなクリックアニメーションを実現するWCLShineButtonを紐解く!

WCLShineButtonはクリックした際にポップなアニメーションをフィードバックできるボタンを簡単に実装することが出来るライブラリです。今回は使い方からライブラリ内のアニメーション実装方法まで詳しく解説していきます。

使い方

WCLShineParamsのインスタンスを生成し、アニメーションに関するパラメーターを設定します。
これをWCLShineButtonのインスタンス生成時に引数として渡せば、あとは良しなに設定してくれるため比較的シンプルに使うことが出来ます。

var param1 = WCLShineParams()
param1.bigShineColor = UIColor(rgb: (153,152,38))
param1.smallShineColor = UIColor(rgb: (102,102,102))

let bt1 = WCLShineButton(frame: .init(x: 100, y: 100, width: 60, height: 60), params: param1)
bt1.fillColor = UIColor(rgb: (153,152,38))
bt1.color = UIColor(rgb: (170,170,170))
bt1.addTarget(self, action: #selector(action), for: .valueChanged)

view.addSubview(bt1)

以下、設定可能なパラメーター。

アニメーションの構成

① ボタン中央から白背景の円が出現
② 円が最大まで大きくなったタイミングで③④が同時にスタート
③ ボタンがポヨンと弾ける
④ ボタン周辺のパーティクルが花火のように散る

全体の構成

WCLShineButton
全てのレイヤーを管理するクラス
WCLShineClickLayer
画像部分を管理するクラス
WCLShineLayer
中央から広がる白背景の円を管理するクラス
WCLShineAngleLayer
周りで弾けるパーティクルを管理するクラス
WCLShineParams
アニメーションに関するパラメータを管理する構造体

アニメーションの実装

WCLShineClickLayer, WCLShineLayer, WCLShineAngleLayerにはそれぞれstartAnimメソッドが実装されており、これらを順番に呼び出すことでクリックアニメーションを表現しています。
ではアニメーションの構成順に確認していきます。

まずは、中央から白背景の円が広がる部分を管理しているWCLShineLayerから確認していきます。(一部省略)

func startAnim() {
    let anim = CAKeyframeAnimation(keyPath: "path")
    anim.duration = params.animDuration * 0.1
    let size = frame.size
    let fromPath = UIBezierPath(arcCenter: CGPoint(x: size.width/2, y: size.height/2), radius: 1, startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: false).cgPath
    let toPath = UIBezierPath(arcCenter: CGPoint(x: size.width/2, y: size.height/2), radius: size.width/2 * CGFloat(params.shineDistanceMultiple), startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: false).cgPath
    anim.values = [fromPath, toPath]
    anim.timingFunctions = [CAMediaTimingFunction(name: .easeOut)]
    anim.isRemovedOnCompletion = false
    anim.fillMode = .forwards
    shapeLayer.add(anim, forKey: "path")
}

CAKeyframeAnimationインスタンスを生成し、アニメーション時間・初期値・最終値・変化のタイプ等を設定したのち、レイヤーにアニメを実装しています。

let fromPath = UIBezierPath(arcCenter: CGPoint(x: size.width/2, y: size.height/2), radius: 1, startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: false).cgPath
let toPath = UIBezierPath(arcCenter: CGPoint(x: size.width/2, y: size.height/2), radius: size.width/2 * CGFloat(params.shineDistanceMultiple), startAngle: 0, endAngle: CGFloat.pi * 2.0, clockwise: false).cgPath

この部分で円を等倍からparams.shineDistanceMultiple(初期値: 1.5)倍まで膨らませています。

次に、画像の表示とボタンがポヨンと弾けるアニメを制御しているWCLShineClickLayerを見てみましょう。

func startAnim() {
    let anim = CAKeyframeAnimation(keyPath: "transform.scale")
    anim.duration = animDuration
    anim.values = [0.4, 1, 0.9, 1]
    anim.calculationMode = .cubic
    if image.isDefaultAndSelect() {
        add(anim, forKey: "scale")
    } else {
        maskLayer.add(anim, forKey: "scale")
    }
}

こちらもシンプルな実装ですが、値の変化値を以下のようにすることでボタンが跳ねる(弾ける??)ような動きを表現しています。

anim.values = [0.4, 1, 0.9, 1]

拡大→縮小→拡大とすることで、このような動きを表現しているんですね。
これは新しい発見でした。

最後にボタンの周辺に登場する花火のようなパーティクルを制御しているWCLShineAngleLayerを確認します。(一部省略)

func startAnim() {
    let radius = frame.size.width/2 * CGFloat(params.shineDistanceMultiple*1.4)
    var startAngle: CGFloat = 0
    let angle = CGFloat(Double.pi*2/Double(params.shineCount)) + startAngle
    if params.shineCount%2 != 0 {
        startAngle = CGFloat(Double.pi*2 - (Double(angle)/Double(params.shineCount)))
    }

    for i in 0..<params.shineCount {
        let bigShine = shineLayers[i]
        let bigAnim = getAngleAnim(shine: bigShine, angle: startAngle + CGFloat(angle)*CGFloat(i), radius: radius)
        let smallShine = smallShineLayers[i]
        var radiusSub = frame.size.width*0.15*0.66
        if params.shineSize != 0 {
            radiusSub = params.shineSize*0.66
        }
        let smallAnim = getAngleAnim(shine: smallShine, angle: startAngle + CGFloat(angle)*CGFloat(i) - CGFloat(params.smallShineOffsetAngle)*CGFloat(Double.pi)/180, radius: radius-radiusSub)
        bigShine.add(bigAnim, forKey: "path")
        smallShine.add(smallAnim, forKey: "path")
    }

    let angleAnim = CABasicAnimation(keyPath: "transform.rotation")
    angleAnim.duration = params.animDuration * 0.87
    angleAnim.timingFunction = CAMediaTimingFunction(name: .linear)
    angleAnim.fromValue = 0
    angleAnim.toValue = CGFloat(params.shineTurnAngle) * CGFloat.pi / 180
    angleAnim.delegate = self
    add(angleAnim, forKey: "rotate")
}

こちらは2つのアニメが組み合わせてあるのが分かります。
①bigShineとsmallShineには、それぞれCABasicAnimationのpathアニメで、パーティクルが小さくなりつつ中心から遠ざかるような動きを実装している。
②bigShineとsmallShineを管理しているCALayerに回転アニメーションを指定することで、全てのパーティクルがボタンの中心を軸に回転しているように見せている。

②は簡単な実装ですが、①は少し難しいロジックが含まれています。
パーティクル自体を小さくするのは簡単ですが、中心から遠ざかる動きは数学的な知識が必要です。
詳しく知りたい方は、WCLShineAngleLayerのgetAngleAnimメソッド並びにgetShineCenterメソッドをコードリーディングしてみてください。

アニメーションの流れと実装場所

それぞれのレイヤーのアニメ部分を確認したところで、全体的なアニメーションの実装部分を見ていきます。再度、アニメの流れを確認しましょう。

① ボタン中央から白背景の円が出現
② 円が最大まで大きくなったタイミングで③④が同時にスタート
③ ボタンがポヨンと弾ける
④ ボタン周辺のパーティクルが花火のように散る

全てのレイヤーを含有するWCLShineButtonを見てみましょう。

private func initLayers() {
    clickLayer.animDuration = params.animDuration / 3
    shineLayer.params = params
    clickLayer.frame = bounds
    shineLayer.frame = bounds
    layer.addSublayer(clickLayer)
    layer.addSublayer(shineLayer)
}

初期化部分でWCLShineLayerとWCLShineClickLayerのふたつのレイヤーが被せられています。
WCLShineClickLayerは画像部分の表示のため、最初から必要ですね。
WCLShineLayerはどこで使われているかというと…

override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesEnded(touches, with: event)
    if clickLayer.clicked == false {
        shineLayer.endAnim = { [weak self] in
            self?.clickLayer.clicked = !(self?.clickLayer.clicked ?? false)
            self?.clickLayer.startAnim()
            self?.isSelected = self?.clickLayer.clicked ?? false
            self?.sendActions(for: .valueChanged)
        }
        shineLayer.startAnim()

    } else {
        clickLayer.clicked = !clickLayer.clicked
        isSelected = clickLayer.clicked
        sendActions(for: .valueChanged)
    }
}

ボタンがクリックされたタイミングで、

shineLayer.startAnim()

が実行されていますね。これで①②が実行されました。

①②が終了したタイミングで、clickLayerのアニメーションを呼び出すのはどうやって実装しているんでしょうか??

shineLayer.endAnim = { [weak self] in
    self?.clickLayer.clicked = !(self?.clickLayer.clicked ?? false)
    self?.clickLayer.startAnim()
    self?.isSelected = self?.clickLayer.clicked ?? false
    self?.sendActions(for: .valueChanged)
}

この部分ですね!
shineLayerのendAnimにclickLayerのアニメを登録しています。
では、次にWCLShineClickLayerを確認してみると…(一部省略)

func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
    if flag {
        let angleLayer = WCLShineAngleLayer(frame: bounds, params: params)
        addSublayer(angleLayer)
        angleLayer.startAnim()
        endAnim?()
    }
}

CAAnimationDelegateのanimationDidStopメソッドを使用することで、アニメーション終了時にendAnimを実行していますね。

同じタイミングでWCLShineAngleLayerのアニメも実行されています。
angleLayerはこのタイミングで生成→追加→アニメ実行が一度に行われていたんですね。

これで③④のアニメも全て実行されました!
以上が実装の解説となります。

感想

今回は比較的コード量も少なく、読みやすいライブラリでした。
その中でも普段何気なく触っているアニメーションの実装方法や、全体のパラメーターを構造体として切り出し設定をしやすくする方法を学ぶことができました。

今回は触れませんでしたが、WCLShineImageはボタン画像を簡単に変更できるような工夫もされており他の実装にも活かせるものがあるので、時間がある人は是非読んでみてください。


いいなと思ったら応援しよう!