個人開発で壁紙アプリを iOS と Android の両方で出していると、片方で好評だった機能をもう片方にも揃えたくなります。今回は逆方向でした。iOS 版の全画面ビューアに先に入れていた「スライドショーで自動送りしつつ、画面下部のスクラバーを掴めば任意のページへ一気に飛べる」操作を、Android 版へ持っていく作業です。
逆移植そのものは Antigravity に任せました。iOS の UICollectionView 実装を読ませて「これと同じ挙動を RecyclerView で」と頼むと、土台はすぐ組み上がります。ところが、実機で触ると現在ページの表示が一拍ずれる。スクラバーを動かすとビューアが二重にスクロールする。スライドショー中に指で送ると画面がガタつく。素直に動かないところに、iOS と Android の設計思想の違いがそのまま現れていました。
詰まった三点を、順番に解いていった記録です。
まず「現在ページが確定する瞬間」が iOS と Android で違う
最初の違和感は、スクラバーに表示するページ番号が実際の表示より遅れて追従することでした。原因は、ページ位置を確定させるタイミングのモデルが両プラットフォームで根本的に異なるからです。
iOS の UICollectionView で isPagingEnabled = true にすると、1 画面ぴったりで止まり、scrollViewDidEndDecelerating が呼ばれた時点でページが確定します。フレームワークが「1 ページ = 1 画面幅」を保証してくれるので、contentOffset.x / pageWidth を四捨五入すれば現在ページが取れます。
Android の RecyclerView には、この「ページング」の概念が組み込まれていません。スナップは SnapHelper という別部品が後付けで担当します。さらにスクロール状態の遷移が iOS と異なり、指を離した後に SETTLING(慣性で滑っている)を経て IDLE に落ちます。SETTLING の最中にページを読むと、まだ滑走中の中途半端な位置を拾ってしまう。これが「一拍ずれる」の正体でした。
観点 iOS(UICollectionView) Android(RecyclerView + SnapHelper)
ページングの担い手 フレームワーク標準(isPagingEnabled) 後付けの SnapHelper
現在ページの確定契機 scrollViewDidEndDecelerating スクロール状態が IDLE に落ちた瞬間
位置の読み方 contentOffset から算出 snapHelper.findSnapView() で中央ビューを取得
滑走中の中間状態 意識せずに済む SETTLING を明示的に除外する必要がある
ここを理解しないまま addOnScrollListener の中で毎フレーム位置を読むと、スクラバーが小刻みに震えます。「確定は IDLE のときだけ」という線引きが出発点になります。
RecyclerView + PagerSnapHelper で 1 枚ずつ止める土台
PagerSnapHelper を取り付ける
逆移植の土台は、横スクロールの RecyclerView に PagerSnapHelper を取り付けるところから始まります。PagerSnapHelper は 1 回のフリングで 1 ページだけ進む、まさに iOS の paging に相当する挙動を作ってくれます。
class WallpaperViewer (
private val recyclerView: RecyclerView ,
private val scrubber: SeekBar ,
private val itemCount: Int ,
) {
private val snapHelper = PagerSnapHelper ()
init {
recyclerView.layoutManager =
LinearLayoutManager (recyclerView.context, RecyclerView.HORIZONTAL, false )
// 1 フリング = 1 ページ。複数ページ飛ばさせない
snapHelper. attachToRecyclerView (recyclerView)
scrubber.max = (itemCount - 1 ). coerceAtLeast ( 0 )
}
/** SnapHelper が中央に吸着しているビューのアダプタ位置を返す。なければ -1。 */
private fun currentPage (): Int {
val lm = recyclerView.layoutManager ?: return - 1
val view = snapHelper. findSnapView (lm) ?: return - 1
return lm. getPosition (view)
}
}
findFirstVisibleItemPosition では位置がずれる
findSnapView() が返すのは「いま中央にスナップしているビュー」です。LinearLayoutManager.findFirstVisibleItemPosition() を使う実装例をよく見かけますが、スナップ位置とは一致しないことがあり、スクラバーがずれる原因になります。私自身、最初はそちらで書いて 1 ページずれる不具合を踏みました。スナップ位置はあくまで snapHelper.findSnapView() から取るのが正解です。
ビューア → スクラバーの反映は「IDLE のときだけ」
土台ができたら、ユーザーがスワイプして止まったときにスクラバーのつまみを動かします。ここで先ほどの「確定は IDLE のときだけ」を実装に落とし込みます。
recyclerView. addOnScrollListener ( object : RecyclerView . OnScrollListener () {
override fun onScrollStateChanged (rv: RecyclerView , newState: Int ) {
// SETTLING(慣性で滑走中)は無視。完全に止まった瞬間だけページを確定する
if (newState != RecyclerView.SCROLL_STATE_IDLE) return
val page = currentPage ()
if (page < 0 ) return
syncScrubberTo (page) // つまみをページ位置へ
}
})
onScrolled(ピクセル単位で毎フレーム呼ばれる方)ではなく onScrollStateChanged を使い、しかも IDLE のみを拾うのがポイントです。これで「滑走中の中途半端な位置を読む」事故を構造的に回避でき、ずれの問題を根本から解決できます。iOS の scrollViewDidEndDecelerating に対応するのが、この IDLE への遷移だと考えると整理しやすいです。
スクラバー → ビューアの双方向同期で必ず起きる無限ループ
次に逆方向、スクラバーを掴んで動かしたらビューアをその位置へジャンプさせます。ここに、双方向同期を書くと必ず踏む罠があります。
スクラバーを動かす → ビューアをスクロールする → ビューアが止まって IDLE になる → リスナーがスクラバーを動かす → スクラバーのリスナーがまたビューアを動かす……という相互呼び出しのループです。実機ではつまみがピクピク震えたり、目的のページを通り越したりします。
発生源フラグで相互発火を止める
断ち切り方は「いま動かしているのはプログラム由来か、ユーザーの指由来か」を 1 つのフラグで持ち、プログラム由来の更新では相手のリスナーを発火させないことです。
// 「コード側が能動的にスクロールさせている最中」を表すフラグ
private var isProgrammaticScroll = false
private fun syncScrubberTo (page: Int ) {
if (scrubber.progress == page) return
isProgrammaticScroll = true // これからの progress 変更はコード由来
scrubber.progress = page
isProgrammaticScroll = false
}
init {
scrubber. setOnSeekBarChangeListener ( object : SeekBar . OnSeekBarChangeListener {
override fun onProgressChanged (sb: SeekBar , progress: Int , fromUser: Boolean ) {
// コード由来の変更(syncScrubberTo)には反応しない。ユーザーの指だけ拾う
if ( ! fromUser || isProgrammaticScroll) return
recyclerView. smoothScrollToPosition (progress)
}
override fun onStartTrackingTouch (sb: SeekBar ) { pauseSlideshow (UserIntent.SCRUBBING) }
override fun onStopTrackingTouch (sb: SeekBar ) { resumeSlideshowLater () }
})
}
SeekBar の onProgressChanged は fromUser を渡してくれるので、まずはそれで指由来かを判定します。ただし fromUser だけでは、smoothScrollToPosition の途中で IDLE が挟まって syncScrubberTo が走るケースを完全には防げません。そこで isProgrammaticScroll フラグを併用し、コードが能動的に動かしている区間を明示的に除外します。二重の防御にしておくのが安全でした。
iOS では UISlider の addTarget に .valueChanged を結ぶだけで似たことができますが、fromUser 相当の区別が言語仕様として与えられないぶん、Android の方がフラグ管理を丁寧にやる必要があります。
スライドショーの自動送りとユーザー操作を状態機械で分ける
最後の難所が、スライドショーの自動送りと手動操作の競合です。一定間隔でページを進めている最中に、ユーザーが指でスワイプしたりスクラバーを掴んだりすると、自動送りと手動送りが同時に発火して画面がガタつきます。
Handler.postDelayed のフラグを if で継ぎ足していくと、すぐに条件分岐が絡まります。私はここを 3 つの状態に整理した状態機械として書き直しました。IDLE(停止)・AUTO(自動送り中)・USER(ユーザー操作中)の 3 つです。ユーザー操作が始まったら必ず USER に倒し、操作が終わって一定時間経ったら AUTO に戻す、という一方通行のルールにすると、競合が起きなくなります。
private enum class PlayState { IDLE, AUTO, USER }
private enum class UserIntent { SWIPING, SCRUBBING }
private var state = PlayState.IDLE
private var autoJob: Job ? = null
private val scope = MainScope ()
fun startSlideshow () {
if (state == PlayState.USER) return // 操作中は自動送りを始めない
state = PlayState.AUTO
autoJob?. cancel ()
autoJob = scope. launch {
while (isActive && state == PlayState.AUTO) {
delay ( 5_000 )
if (state != PlayState.AUTO) break // 途中で USER に倒れたら抜ける
val next = ( currentPage () + 1 ) % itemCount
recyclerView. smoothScrollToPosition (next)
}
}
}
/** スワイプ・スクラバー操作の開始で必ず呼ぶ。USER へ倒し、自動送りを止める */
private fun pauseSlideshow (intent: UserIntent ) {
state = PlayState.USER
autoJob?. cancel ()
}
/** 操作終了後、少し待ってから AUTO に戻す(指を離してすぐ送ると慌ただしい) */
private fun resumeSlideshowLater () {
scope. launch {
delay ( 2_000 )
if (state == PlayState.USER) startSlideshow ()
}
}
肝は「USER への遷移はユーザー操作のどの入口からでも起こすが、AUTO への復帰は resumeSlideshowLater の一箇所だけに集約する」ことです。復帰経路を 1 本に絞ると、delay 中に再び操作が来ても state を見て弾けるので、二重起動しません。if の継ぎ足しで書いていたときは、この復帰経路が暗黙的に複数あって、自動送りが二重に走るのが原因でガタついていました。
smoothScrollToPosition で端から端へ戻る(最後のページから 0 へ折り返す)瞬間だけは、全ページを猛スピードで巻き戻すアニメーションになって目障りでした。折り返しのときだけ scrollToPosition(即時ジャンプ)に切り替えると、iOS のループ送りに近い自然な見え方になります。細かい点ですが、ここを直すと完成度がぐっと上がります。
Antigravity に逆移植を任せて効いた点、任せきれなかった点
今回の作業で Antigravity が強かったのは、iOS の UICollectionView 実装を読み込んで「RecyclerView + SnapHelper という対応物に置き換える」骨格づくりでした。クラス構成とアダプタ周りは、ほぼそのまま使える土台が出てきます。プラットフォーム間の API 対応表を頭の中で引く手間が省けるのは、地味ですが日々効きます。
一方で、ここで挙げた三点——現在ページの確定タイミング、双方向同期の無限ループ、自動送りと手動の競合——は、いずれも「実機で触って初めて顕在化する状態遷移の問題」でした。エージェントが最初に出したコードは onScrolled で毎フレーム位置を読み、フラグなしで双方向に結線していて、静的には正しく見えるのに実機ではガタついた。ここは私自身が実機で症状を観察し、「IDLE のときだけ」「発生源フラグ」「状態機械」という設計の芯を渡して初めて収束しました。
任せられるのは構造の翻訳まで。状態遷移の芯は人が握る。逆移植のような作業ほど、この線引きがはっきりすると感じています。同じくプラットフォーム差で苦労した記録として、新しい iPhone 解像度へ対応した際の振り分け設計 や、テーマ切替時の白画面を AppRestarter で根治した記録 も、同じ「実機で初めて見える問題」を扱っています。
私は最初に findFirstVisibleItemPosition で書いて 1 ページずれを踏んだ実体験から、現在ページの取得には必ず snapHelper.findSnapView() を使うことを推奨します。まず手元のギャラリー系画面で、現在ページの取得を findFirstVisibleItemPosition から snapHelper.findSnapView() に置き換えてみてください。スクラバーのずれが消えるなら、双方向同期と状態機械へ進む価値があります。