📽️

Core Animationでframe情報が更新されなくて困ったので対応方法を模索しました

2021/08/26に公開

iOSアプリで、アニメーションで開閉するボタンを実装したところ、Core Animationの動作で苦しめられました。
そこで調べたこと、得られた知見を書いていきます。

サンプル

説明のために、サンプルアプリを作成しました。
このようなアニメーションをするボタンです。

https://www.youtube.com/watch?v=12EkME-MLBc

そもそもiOS開発でアニメーションを実装するには

今回僕はiOSのアニメーション実装がはじめてだったので、そもそもどうしたらいいかから調べはじめました。
大方針としては3つあります。

  1. UIViewanimate()を使う
  2. Core Animationを使う
  3. UIViewPropertyAnimatorを使う

僕は最初2を選択しました。
理由は1と2しか知らなくて、要件を満たすアニメーションをするためには2しかないと思ったからです。
今見直してみると、animate()でもなんかできたようにも見えますが、どうでしょうね。

animate()については、Appleの公式見解だとあんま使って欲しくなさそうな雰囲気を感じます。

https://developer.apple.com/documentation/uikit/uiview

Apple discourages using these methods. Use the UIViewPropertyAnimator class to perform animations instead.

難易度的には1<3<2だと思います。

Core Animationの操作はレイヤー操作になるので癖があって難しいですが、
Appleのドキュメントによるとビッドマップ画像の操作なので、パフォーマンスがいいとのことです。
詳しくわかってませんが、UIViewの操作だとアニメーションの計算でヘビーな演算をしないといけないところを、
Core Animationだと単に画面の1ptを何色に変更する、みたいな計算だけですむので計算の負担が軽くて効率的、みたいなのかなと推測しています。

Core Animationの実装

Core Animation(CABasicAnimation)で実装した例がこちらです。

https://www.youtube.com/watch?v=LZ-65kwj2xM

    private func addAnimation() {
//        layer.position = CGPoint(x: 0, y: frame.minY)
//        layer.anchorPoint = CGPoint(x: 0, y: 0)
        let animation = CABasicAnimation(keyPath: #keyPath(bounds))

        animation.beginTime = CACurrentMediaTime() + 3.0
        animation.duration = 1.0
        animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)

        animation.fromValue = bounds
        var toValue = bounds
        toValue.size.height = 10
        animation.toValue = toValue
        animation.isRemovedOnCompletion = false

        animation.fillMode = CAMediaTimingFillMode.forwards
        layer.add(animation, forKey: nil)

        // DelegateやCATransactionだと別画面に遷移した後のViewで不整合が出たので、タイマー処理にした
        Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { [weak self] _ in
            self?.setTitle(nil, for: .normal)
            self?.completion()
        }
    }

簡単に解説すると、アニメーションの開始前の状態(fromValue)と開始後の状態(toValue)を設定してやります。
それをUIViewが持っているCALayeradd()します。
やろうと思えば、もっと複雑なアニメーションを設定することもできます。

アニメーションはデフォルト設定だと、終了後にアニメ開始前の状態に戻るようになっているので、
animation.isRemovedOnCompletion = falseanimation.fillMode = CAMediaTimingFillMode.forwardsを指定しています。

アニメーションの状態として、boundsを指定していますが、frameは指定できません。
ここにも無理だと書いてありますね。

コメントアウトしてる、positionとかanchorPointというのは、CALayerの制約で、
もし上記の高さの縮小する方向をコントロールしたいのであれば、正しく指定してやる必要があります。
サンプルだと上手く指定できなかったのでやめました。
(全然直感的に動いてくれません)

全体を見たい方はGistにあげたので、そちらで見てください。

https://gist.github.com/0si43/039e40aaa5b72db9c1903cd9adf3de6d

Core Animationで実装したときの問題点

この実装だと、一つ大きな問題が出てきます。
それはアニメーション後にタップ範囲が変わらないことです。

https://www.youtube.com/watch?v=UA5S-mqmVp4

↑タップするとボタンの背景色が変わるようにしました。
アニメーション終了すると、広告ボタンは消えているのに、タップ範囲が残ってしまいました。

このサンプルだと色が変わる(高さを小さくしたので目立たない)だけですが、実際はタップすると別画面への遷移が発生することになりました。

なぜこんなことが起きるか

なぜこんなことが起きるのかというと、Core AnimationはCALayerを操作して、Viewは操作しないためです。


ここから)

↑この図にある通り、UIViewの世界とCALayerの世界は処理的には完全分離されていて、
ディスプレイに最終的に映る画面にはレイヤー層の処理が加えられて出力する、ということになっています。
つまり、Core Animationで操作する状態変数は、あくまでUIViewのframe情報とは別モノなので、上手いこと開発者が調整する必要があります。

このレイヤー処理はViewの細かいプロパティ情報を知らなくても、複雑なアニメーションを軽量な計算で動かせるので、
iPhoneのなめらかなアニメーションの実現に一役買っていたと思われます。

画像をアニメーションして終わり、という要件ならこれでいいんですけど、
今回実現したかったのがボタンのアニメーションだったのでめちゃくちゃ困りました。

対処法はあるにはある

一応Core Animationでも頑張れば要件を実現できます。
アニメーション終了後をDelegateでとるか、CATransactionでトランザクション化するとCompletionが指定できるので、
ここで辻褄を合わせれば、なんとかなります。

タップイベントの範囲がおかしいなら、アニメーション終了後に自分のframeを更新する、という手があります。
ただ僕のケースだと、frame更新するとUIButtonのレイアウトが自動調整されて、意図していたデザインが実現できなかったので、厳しかったです。

あとサンプルアプリではなく、実際に僕がやってたアプリはタブバーで別ページに遷移できるアプリで、
Core Animationは別ページ遷移すると、遷移したタイミングでcompletionを即時実行して、アニメーションのDelayは残ります。
苦肉の策でTimerにしています。

UIViewPropertyAnimatorならframeが更新できる

血を吐きながら実装したんですが、その後「ホントにこんなやり方しかないん?」と思って調べていると、
UIViewPropertyAnimatorというiOS 10から追加されたクラスを発見しました。

こちらの実装例が、こちら。

https://www.youtube.com/watch?v=12EkME-MLBc

    /// ボタンが閉じるアニメーションを付与する
    private func addAnimation() {
        let animator = UIViewPropertyAnimator(duration: 1.0, curve: .easeOut) { [weak self] in
            guard let self = self else { return }
            self.frame.origin.y = self.frame.maxY - 50.0
        }
        animator.addCompletion { [weak self] _ in
            self?.setTitle(nil, for: .normal)
            self?.completion()
        }
        animator.startAnimation(afterDelay: 3.0)
    }

Core Animationで消耗した点がほとんどなくなりました。。。

ソース全体が見たい方は↓を参照ください。

https://gist.github.com/0si43/b2b0ad58c00c0e7281d7c1e0a417bc6e

Core Animationは難しい

コードの記述量の差からもわかるとおり、Core Animationを使うとコード的に難解になるのを実感しました。

https://www.youtube.com/watch?v=hsh9dx-mkKg

あまりに難解なので、サンプルコード書いてる途中でこんな動作が発生しました。
frame更新できない問題よりも、コードが難解になる問題もCore Animationはキツいですね。

リファレンスと一言

Discussion