24/7 twenty-four seven

iOS/OS X application programing topics.

iOSDC 2022「アニメーションAPIのすべて」補足など

先日のiOSDC 2022にて「アニメーションAPIのすべて」という発表をしました。

fortee.jp

きっかけはDroidKaigi 2021で荒木佑一さんの「動かす」という発表です。

www.youtube.com

Androidのさまざまなアニメーション APIについてコードや具体的な例を用いて解説する内容です。最後にスライド自体がAndroidアプリとして作られていて、サンプルのアニメーションはすべて実際に動いていたものだった、と明かされるところが非常におもしろいと思ったのです。ぜひこれのiOS版をやろうとそのとき考えたのでした。

ちなみに、荒木さんはそれ以前のDroidKaigiや別のカンファレンスでも「動かす」シリーズで話されているので資料などを探して読んでみるとどれもおもしろいです。

ということで1年間あたためていたアイデアが無事採択されたことはよかったのですが、さすがにこの内容を40分にまとめるのは簡単ではありませんでした。最初は漠然とAPIを一つずつ例と一緒に使い方を説明して、できるだけ高レベルなAPIを使いましょう、でいいかと思ってましたが、全然時間が足りないしたぶん例だけ並べても使い分けを判断できるようには伝わないなと思って半分はアニメーション自体の仕組みの説明に割くことにしました。

ただ、そうすると当然個々のAPIの具体的な説明はさらに減らすことになるので、なんとか40分のプレゼンテーションの形にまとめたものの、それぞれの説明を完全にはできなかったなと思ってこの記事で少し補足します。

APIの使い分けについて

次のようにUIViewはCALayerのラッパー、UIView.animate()系のメソッドはCore AnimationのアニメーションAPIのラッパー、UIViewPropertyAnimatorはさらにUIView.animate()系のラッパー、ということで基本的には高レベルのAPIを使えばいいです。ただUIViewPropertyAnimatorUIView.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のアニメーションは基本的にあるプロパティの値abに変わるその間の各値をアニメーションのパラメータに従ってなめらかに見えるように自動的に補完する、というものです。

アニメーション対象のプロパティの値は、アニメーションが発動しているときにはすでにアニメーション終了後の値に変わっているので、途中の値を観測することはできません。

ただし、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()メソッドがあるので、presentationLayerhitTest()frameの値から現在アニメーション中のレイヤーにタッチしたかどうか、なども判定できます。

まあ必要とすることはほとんどないのですが、稀にそういうことがしたい場合に、このような仕組みを知っておくと役に立ちます。

Specialized Layers

CALayerのサブクラス群は非常に有用なのでもっと時間をとって紹介したかったところです。非常に数多くのサブクラスが存在しますが、紹介できなかった中でも有用だと思うものがこちらです。

  • CATiledLayer

    • マップ.appのようなズームレベルによって異なる画像を表示することができるレイヤーです。非常に大きな画像を一部だけレンダリングしつつズームすると高精細な画像に切り替えるという挙動が簡単に実装できます。名前の通り、表示エリアをタイル状に分割してそれぞれの読み込みは非同期で行われるのでUIは非常になめらかに動きます。
  • AVSynchronizedLayer

    • AVFoundationやCore Videoにはビデオやサンプルバッファを表示するためのレイヤーのサブクラスがあります。これはAVPlayerAVPlayerLayerでビデオを再生している場合に再生時刻とCALayerのアニメーションを同期できるレイヤーです。ビデオの再生中にフェードなどのトランジションを追加する、といったことができます。

余談ですが、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で同じ操作をすると、色の設定も無くなってしまう、ということが問題でした。

ただ、何回かやっているうちに偶然、色の設定も無くなってしまったあとにUndoして再度同じ操作をしてさらにUndo、Redoとするとその過程でうまいこと色の設定が復活するという挙動が判明したので解決しました。

サンプルコードが載っているStoryboardやXIBファイルを見るとわかりますが、UITextViewにAttributed Stringでそのままコードハイライトされたテキストをペーストして使っています。

以上です。 プレゼンテーションに使ったスライドのアプリはこちらです。サンプルのアニメーションもそのまま動きます。台本の原稿も入っています。

github.com

参考になれば幸いです。アニメーションを使いこなして使って楽しいアプリを作りましょう。