先日のiOSDC 2022にて「アニメーションAPIのすべて」という発表をしました。
きっかけはDroidKaigi 2021で荒木佑一さんの「動かす」という発表です。
Androidのさまざまなアニメーション APIについてコードや具体的な例を用いて解説する内容です。最後にスライド自体がAndroidアプリとして作られていて、サンプルのアニメーションはすべて実際に動いていたものだった、と明かされるところが非常におもしろいと思ったのです。ぜひこれのiOS版をやろうとそのとき考えたのでした。
ちなみに、荒木さんはそれ以前のDroidKaigiや別のカンファレンスでも「動かす」シリーズで話されているので資料などを探して読んでみるとどれもおもしろいです。
ということで1年間あたためていたアイデアが無事採択されたことはよかったのですが、さすがにこの内容を40分にまとめるのは簡単ではありませんでした。最初は漠然とAPIを一つずつ例と一緒に使い方を説明して、できるだけ高レベルなAPIを使いましょう、でいいかと思ってましたが、全然時間が足りないしたぶん例だけ並べても使い分けを判断できるようには伝わないなと思って半分はアニメーション自体の仕組みの説明に割くことにしました。
ただ、そうすると当然個々のAPIの具体的な説明はさらに減らすことになるので、なんとか40分のプレゼンテーションの形にまとめたものの、それぞれの説明を完全にはできなかったなと思ってこの記事で少し補足します。
APIの使い分けについて
次のようにUIViewはCALayerのラッパー、UIView.animate()
系のメソッドはCore AnimationのアニメーションAPIのラッパー、UIViewPropertyAnimator
はさらにUIView.animate()
系のラッパー、ということで基本的には高レベルのAPIを使えばいいです。ただUIViewPropertyAnimator
とUIView.animate()
を比べるとコードのシンプルさが全然違うので、UIView.animate()
で十分書ける場合はそちらを使う方がいいでしょう。
Core Animationのアニメーションはなぜ効率的で高速なのか
Core Animationとは、のくだりでCore Animationは画面表示のための合成エンジン、ということを言ったのですが、それで済ませてしまってなぜiOSにCore Animationが導入され名前にもあるとおりアニメーションをスムーズに効率よく実行できるのかという説明は省いたのでここで補足します。 この説明に入るとCore Animationのレンダリングシステムについての話をずっとすることになり、時間がなくなるので仕方なく省きました。 もし詳しく聞きたいということがあれば、企画してもらえれば詳しく話すことも可能です、たぶん。
簡単にいうと、下のようにCALayer
を使わない場合は描画をすべてビューが担当するので、ビューをアニメーションさせた場合に毎フレームDrawコールが呼ばれる、というような挙動になります。これはMacでCALayer
を使わないNSViewを使って実験してみるとわかります。iOSは発表中に説明した通り、CALayerは必ず作られるのでCALayerを使わない画面表示ということは不可能です。
いっぽう、MacはCore Animationを使うかどうかは選択できるのでCALayer
を使わないNSView
が使えます。その場合、例えばビューに背景色を設定するというだけのことでもdrawRect()
メソッドをオーバーライドして背景色でビューの矩形を塗る、という処理を書く必要があります。
ビューをアニメーションさせた場合、毎フレームdrawRect()
で背景色を塗る、という処理が呼ばれますが、Core Animationを使う場合、CALayer
が一度表示内容を計算すれば(サブビューがあれば重なりも含めて)表示内容をビットマップとしてキャッシュします。
アニメーションさせる場合はCore Animationアニメーションパラメータから中間の値を導出してひとまとまりのパッケージとしてRender Serverに転送してあとはGPUが処理します。
という仕組みなのであらゆる画面表示やアニメーションはCore AnimationとRender Serverによってハードウェアを効率的に使って実行できるのです。
アニメーションの途中の状態を取得する
Core Animationのアニメーションは基本的にあるプロパティの値a
がb
に変わるその間の各値をアニメーションのパラメータに従ってなめらかに見えるように自動的に補完する、というものです。
アニメーション対象のプロパティの値は、アニメーションが発動しているときにはすでにアニメーション終了後の値に変わっているので、途中の値を観測することはできません。
ただし、CALayer
にはpresentationLayer
というプロパティがあり、このオブジェクト(CALayer
のインスタンス)のプロパティを調べるとアニメーション中の現在の値がわかります。
通常のアプリケーションで利用することはほとんどなく、アニメーションAPIというよりはCore Animationのレンダリングの仕組みの話なので省きましたが、Discordで質問があったのでここで補足します。
Presentation TreeとRender Tree
この話をするとUIViewのツリー構造とCALayerのツリー構造に加えてさらにCALayerにはPresentation TreeとRender Treeというツリー構造があるという話になって完全にCore Animationのレダリングシステムの解説をすることになるので省いたのでした。
私たちが操作しているCALayer(あるいはUIViewを通じて)は実際にはモデルを変更していて、Core AnimationはそこからPresentation Treeという実際に画面に表示されるべき状態とRender Treeという画面に表示されるテクスチャそのものの構造を導出します。
Core Animationは最終的にRender TreeをRender Serverに転送して、実際の画面表示はRender Serverによって行われます。この仕組みにより、プロセスをまたぐホーム画面のAppスイッチャーのアニメーションなども非常になめらかに処理できます。
Render Treeは完全にプライベートで私たちが触れることはできませんが、Presentation Treeは先述のpresentationLayer
プロパティを使って確認できます。
CALayerはタッチイベントを受け取りませんが、hitTest()
メソッドがあるので、presentationLayer
のhitTest()
やframe
の値から現在アニメーション中のレイヤーにタッチしたかどうか、なども判定できます。
まあ必要とすることはほとんどないのですが、稀にそういうことがしたい場合に、このような仕組みを知っておくと役に立ちます。
Specialized Layers
CALayerのサブクラス群は非常に有用なのでもっと時間をとって紹介したかったところです。非常に数多くのサブクラスが存在しますが、紹介できなかった中でも有用だと思うものがこちらです。
CATiledLayer
- マップ.appのようなズームレベルによって異なる画像を表示することができるレイヤーです。非常に大きな画像を一部だけレンダリングしつつズームすると高精細な画像に切り替えるという挙動が簡単に実装できます。名前の通り、表示エリアをタイル状に分割してそれぞれの読み込みは非同期で行われるのでUIは非常になめらかに動きます。
AVSynchronizedLayer
- AVFoundationやCore Videoにはビデオやサンプルバッファを表示するためのレイヤーのサブクラスがあります。これは
AVPlayer
やAVPlayerLayer
でビデオを再生している場合に再生時刻とCALayer
のアニメーションを同期できるレイヤーです。ビデオの再生中にフェードなどのトランジションを追加する、といったことができます。
- AVFoundationやCore Videoにはビデオやサンプルバッファを表示するためのレイヤーのサブクラスがあります。これは
余談ですが、AVFoundationとCore Animationは関係が深く、AVVideoCompositionCoreAnimationToolというAPIを使うとCore Animationによるアニメーションをビデオに合成でき、特殊効果をつける、といったことが簡単にできます。
スライドアプリの構造
プレゼンテーションをiOSアプリを使って行うというのはそもそもの目的でした。
スライドの内容とアプリの設計を同時に行うことは不確定要素が多いのでSwiftUIではなく慣れているクラシックなUIKitアプリとして作られています。
普段はKeynoteを使ってスライドを作っているので、まずKeynoteに用意されているテンプレートのうち普段使っているものを模倣することにしました。
このようにStoryboardで同じフォントとレイアウトを作ってテンプレートとして利用できるようにします。
基本はテンプレートのどれかのクラスに、title
プロパティやsubtilte
プロパティを設定すればいいのですが、大事なことはサンプルがスライドに組み込まれて実際に動いているということなので、それぞれのテンプレートにはconentView
のようなプロパティを作り、自由にビューを追加できるようにしました。
サンプルをどう表示するかという点について、最初はシンプルに直接ビューに追加することを試しましたが、そうしてしまうとスライドが表示される前にアニメーションが終了してしまったり、スライドに戻ってきた場合にアニメーションが再開しなかったりとアニメーションの管理に問題がありました。
最終的にそれぞれのサンプルをView Controllerにして、Child View Controllerとして表示することで、スライドのページ送りでviewWillAppaer/disappear()
などのライフサイクルのメソッドが呼ばれるので、そこでアニメーションを作成したり削除することで表示されるときに必ず最初からアニメーションが再生できるようになりました。
複数のデバイスサイズに対応する
最初は手軽に見てもらえるようにiPhone環境に合わせて作っていましたが、さすがに小さすぎるということがすぐにわかったので、大きなデバイスを使うことにしました。 iPadは良さそうだったのですが、現代のiPadは16:9のサイズのデバイスがなかったので実際のプレゼンテーションはウインドウサイズが自由にできるCatalystでMacアプリとしてやることにしました。
ただ、実際にそのまま動かせる、ということが売りなので、iPhoneやiPadでちゃんと動くということは必要でした。
最初はAuto Layoutでなんとかなるかと思いましたが、さすがにサイズが違いすぎるし、Auto Layoutはデバイスに合わせて全体的にスケールする、というような表現にはあまり向いてないのでやめました。
コードを見るとわかりますが、結局ウインドウ自体をデバイスの画面サイズに合わせて縮小し、中身は縮小した比率にスケールするという方法で各デバイスで同じ見た目になるようにしています。
#if !targetEnvironment(macCatalyst) let bounds = window.bounds let width: CGFloat = 1920 let height: CGFloat = 1080 window.frame = CGRect(x: 0, y: 0, width: width, height: height) if bounds.width < bounds.height { let scale = bounds.height / height window.transform = CGAffineTransform(scaleX: scale, y: scale) } else { let scale = bounds.width / width window.transform = CGAffineTransform(scaleX: scale, y: scale) } if window.frame.width > bounds.width { let scale = bounds.width / window.frame.width window.transform = window.transform.scaledBy(x: scale, y: scale) } if window.frame.height > bounds.height { let scale = bounds.height / window.frame.height window.transform = window.transform.scaledBy(x: scale, y: scale) } window.frame.origin.x = (bounds.width - window.frame.width) / 2 window.frame.origin.y = (bounds.height - window.frame.height) / 2 #endif
コードハイライト
スライドに載せたコードがそのまま動いている、ような表現にしたかったのでコードはそこそこたくさん載せています。 コードがハイライトされていないととても見にくいのでコードハイライトはなんとかして実現したかったことです。
最初は画像にすることを考えましたが、画像は画像でサイズをうまく合わせるのが難しく、細かく調整していると時間が足りなくなりそうだったのでなんとかしてテキストでやる必要がありました。
Xcodeのテキストエディタはコードハイライトを保ったままコピー&ペーストできるのでなんとかそれをうまく利用できないかと考えました。 問題は背景色までコピーされてしまって、白背景以外だと白の背景色が奇妙に見えてしまうということだけでした。
Keynoteの場合はペーストした後に背景色を透明にすればいいのですが、XcodeのInterface Builderで同じ操作をすると、色の設定も無くなってしまう、ということが問題でした。
Xcodeはコードハイライトを保ったままリッチテキストとしてコピー&ペーストできる。
— kishikawa katsumi (@k_katsumi) 2022年9月12日
だけど背景色がついてしまってKeynoteなどにそのままペーストしても使いにくいのを修正していい感じにする方法。
#iosdc pic.twitter.com/sAEVPq0Di5
ただ、何回かやっているうちに偶然、色の設定も無くなってしまったあとにUndoして再度同じ操作をしてさらにUndo、Redoとするとその過程でうまいこと色の設定が復活するという挙動が判明したので解決しました。
サンプルコードが載っているStoryboardやXIBファイルを見るとわかりますが、UITextView
にAttributed Stringでそのままコードハイライトされたテキストをペーストして使っています。
以上です。 プレゼンテーションに使ったスライドのアプリはこちらです。サンプルのアニメーションもそのまま動きます。台本の原稿も入っています。
参考になれば幸いです。アニメーションを使いこなして使って楽しいアプリを作りましょう。