【iOSDC2024 #LT】5. AutoScrollViewの実装について
この記事はiOSDC2024で発表したLTのセッション、我が家に電子ピアノがやってきたので、ピアノと連携するアプリを作ってみた!の発表のうち、発表スライドで実装したアニメーションの実装についてまとめたものです
前回の記事は、「4. SwiftUIのアニメーションでスライド演出を作ってみた」でした
この記事は前回のスライド演出のうち、電光掲示板のようにテキストを自動スクロールさせるUIの実装方法についてです
↓の動画の一番下に映っているものです
今回の発表はLTで、発表時間が5分しかなく、自己紹介などの時間が確保するのが難しいので、自己紹介などの情報と発表の補足情報を流すために、スライドの下にこのような情報を流すためのViewを実装しました
最初に思いついた時は手探りでやって実装して、うまく表示できたと思ったら、数百字の長文になるとカクカクしたりと、綺麗に流れない状態でしたが、試行錯誤の上、今回の5分のLTで使う分にはいい感じに動いている実装ができたので、その実装方法について紹介します
AutoScrollTextViewの実装
まずは実装方針や制約について確認しましょう
まず最初はScrollViewにTextを入れて、offsetのアニメーションをTimerやwithAnimationなどで少しずつ動かしていけばいけるかな?と思ったのですが、それだと滑らかに流れないし、そもそも長文を1つのTextに入れると、動きがおかしくなることがあったので、ある程度のStringの単位で分割して、複数のTextに分割する必要がありそうだなということがわかりました
そこでアニメーションの実装について、使えそうだなとたどり着いたのがTimelineViewです
以下はAppleのドキュメントに記載されたサンプルコードです
TimelineView(.periodic(from: startDate, by: 1.0)) { context in
AnalogTimerView(
date: context.date,
showSeconds: context.cadence <= .seconds)
}
TimelineViewは指定したTimelineScheduleに基づき、一定間隔でその内部のViewを描画してくれます
例えば.periodic(from: startDate, by: 1.0)を指定すれば、startDataから1秒ごとに呼び出されるので、時計のUIなどを実装する時に活用できそうです
今回指定するのは.animationで、これを指定すると、毎フレームごとに(1秒あたりのフレーム数は使用しているデバイスやアプリ実行時のパフォーマンスに依存します)1回呼ばれます(内部的にはCADisplayLinkが使われているのだろうか?)
これを使用すれば、contextから取得できる現在のdateとstartDataからどれくらいの秒数が経過したか、アニメーション全体の時間を定めている場合はその進捗度合いを判定できます
またそれに合わせて、滑らかにViewを動かし、アニメーション効果を付与することができます
方針としてはHStackのように横に並べたTextをこのTimelineViewのcontent.dateから計算した値に応じて、徐々にoffsetしていくというものです
ここまでくればあとはLazyHStackなどでいい感じに実装すればいいかと思われましたが、そうはいきません
今回のLTは5分間なので、うまく5分間ぴったしで文字がすべて流れるようになってほしいのです(正確には一定スピードで流れるようにした場合、5分間でその文字が流れるように調整するのがかなり難しい)
そのため全体の文字量がどれくらいのwidthになるのかをフォントも加味して計算し、それを適切にoffsetで動かす必要があります
またいきなり文字がViewの一番leadingにある状態で始まってしまうと、最初の文字はすぐに流れてしまい、実質読むことができません
そのため最初の文字のスタート位置は右側の画面外の少し奥の方に設定したいです
それらの条件を考慮すると、既存のHStackなどを使う場合、GeometryReaderなどを駆使しないとかなり複雑な実装になります
フォントを考慮した文字のwidthについては、UIKit/AppKitのNSStringのsize関数を利用すれば計算することができます
しかしそれ以外の条件は、GeometryReaderを使ったとしても、かなり大変な実装になりそうでした
そもそもGeometryReaderをレイアウトのサイズ計算で使いたくないなと思った時に思い出したのが、PianoUIを実装した時に使用したLayout Protocolでした
Layout Protocolについては、PianoUIの実装記事で概要を紹介しています
これを利用すれば、ピアノの鍵盤のように横方向に順番に一定の文字数単位で分割した複数のTextを配置し、かつ初期の配置をbounds.width + αに設定すれば、文字の開始位置を右側の画面外に設定できます
最終的にこのLayout Procotolを使った実装でうまく実装することができたので、以下にコードを記載します
基本的な実装はPianoViewと同じですが、異なる点はViewのサイズをキャッシュとして保持していることです
PianoViewと異なる点は、PianoViewのsubViewsは鍵盤のViewだったので、基本的に白と黒の鍵盤のViewのwidthはそれぞれすべて同じです
しかし今回のAutoScrollTextViewのsubViewはテキストからsize関数を使って計算するので、すべて異なる可能性が高いです
そのためplaceSubviews関数が実行されるたびに毎回サイズ計算をしているとパフォーマンス的によくないです
したがって今回はPianoViewでは利用していなかった、Layout Protocolのcache機能を利用します
cacheの機能についてはAppleの公式ドキュメントにも記載があります(Improve layout efficiency with a cache)
Layout ProtocolにはmakeCache(subviews: Subviews) -> CacheDataというoptionalの関数が定義されており、これを実装すると、Layoutが最初に生成される時にこの関数が実行され、サイズ計算などを行い、その結果をcacheとして保持することができます
そのcacheはsizeThatFitsやplaceSubviews関数からも利用でき、かつもし更新される場合は、updateCache関数も定義されているので、それを定義することでcacheを更新することができます(これを定義する必要があるのは、Layoutに渡すsubViewsが動的に変更される場合が主だと思います)
今回のAutoScrollTextViewの場合、以下のようなcacheのstructとmakeCacheの定義をしています
fileprivate struct Cache {
var widths: [CGFloat]
var startPoints: [CGFloat]
var totolWidth: CGFloat
}
fileprivate func makeCache(subviews: Subviews) -> Cache {
var startPoint: CGFloat = 0
var startPoints: [CGFloat] = []
var widths: [CGFloat] = []
subviews.forEach { subView in
let width = subView.sizeThatFits(.unspecified).width
startPoints.append(startPoint)
startPoint += width
widths.append(width)
}
return Cache(widths: widths, startPoints: startPoints, totolWidth: startPoint)
}
Cacheのstructには、それぞれのTextのwidthとそれらを合計したtotalWidth、そして全てのTextをhorizontalに並べた時のTextのx座標の位置をstartPointとして記録しています
それらをすべて最初にmakeCacheで計算して、cacheに格納することで、sizeThatFitsやplaceSubviewsの処理中に個別のTextのサイズや位置について毎度計算する必要がなくなり、パフォーマンスが向上します
まとめ
今回は自動で文字が横方向にスクロールするAutoScrollTextViewの実装についてまとめました
今回の実装を踏まえて、TimelineView + Layout Protocolは複雑なレイアウトにアニメーションを付与したい場合、かなり汎用的に活躍する組み合わせだなと感じたので、他にも色々な活用方法がありそうです
次は「6. ピアノUIのアプリでvisionOS対応したこと」の記事です