ユーザーレビューに何年も繰り返し届いていた要望が 2 つありました。「ベッドで眺めるときに自動でめくれるようにしてほしい」と「壁紙の枚数が多すぎて目当ての一枚に辿り着けない」です。今回 Android 版の「綺麗な壁紙 4K/HDR」と「浮世絵壁紙」をアップデートし、この 2 点に応えるかたちで スライドショー と ページジャンプスライダー を追加しました。あわせて、これも長く要望をいただいていた 広告非表示オプション も同時にリリースしています。実装そのものは特別なことをしていませんが、2014 年から壁紙アプリを運営してきた個人開発の感覚で、迷った場面と選んだ理由を残しておきます。
既存の RecyclerView を壊さずに自動送りを差し込む
スライドショーは「3 秒ごとに次の壁紙を表示する」だけの機能ですが、自前で Thread を回すと画面回転やバックグラウンド遷移でリークしやすくなります。今回は ViewPager2 が背後に持つ RecyclerView をそのまま動かす方針にして、UI スレッドの Handler を使う形に落としました。
private val mainHandler = Handler(Looper.getMainLooper())
private val advanceRunnable = object : Runnable {
override fun run() {
val next = viewPager.currentItem + 1
if (next < adapter.itemCount) {
viewPager.setCurrentItem(next, true) // smoothScroll = true
} else {
viewPager.setCurrentItem(0, true) // 末尾で先頭へループ
}
mainHandler.postDelayed(this, INTERVAL_MS)
}
}
fun startSlideshow() {
mainHandler.removeCallbacks(advanceRunnable)
mainHandler.postDelayed(advanceRunnable, INTERVAL_MS)
}
fun stopSlideshow() {
mainHandler.removeCallbacks(advanceRunnable)
}ポイントは「画面タップで停止」を onTouchEvent ではなく ViewPager2.OnPageChangeCallback#onPageScrollStateChanged 経由で拾うことです。ユーザーが手でスワイプしたときも自動送りと判定が衝突しないように、SCROLL_STATE_DRAGGING を検知したら stopSlideshow() を呼ぶ作りにしています。タップ判定だけで止めようとすると、慣性スクロール中に次のページへ進んでしまう不具合が初期実装で出ました。
ページジャンプは SeekBar とプレビューの 2 層構造
10 万枚規模の中から特定の一枚に飛ぶ機能を、シークバー 1 本で済ませました。素朴な作りですが、OnSeekBarChangeListener の中で viewPager.setCurrentItem(progress, false) を直接呼ぶと、ドラッグ中に毎フレームレイアウトが走ってカクつきます。
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(sb: SeekBar?, progress: Int, fromUser: Boolean) {
if (!fromUser) return
previewLabel.text = "${progress + 1} / ${adapter.itemCount}" // 軽量プレビュー
}
override fun onStartTrackingTouch(sb: SeekBar?) {
stopSlideshow() // ジャンプ中は自動送りを止める
}
override fun onStopTrackingTouch(sb: SeekBar?) {
viewPager.setCurrentItem(sb?.progress ?: 0, false) // 確定時のみ実位置を動かす
}
})ドラッグ中はテキストで「現在地 / 総数」を出すだけに留め、指を離したタイミングで初めて setCurrentItem を呼びます。10 万件規模でもスクラブが滑らかになり、Glide 側のキャンセルと再ロードも最後の 1 回で済むため、メモリ圧と通信量の両方に効きました。
広告非表示オプションとの兼ね合い
「広告を非表示にする選択肢が欲しい」という声も多くいただいていたので、今回のリリースで合わせて入れています。動作の実体は、永久課金 / サブスクリプションによる ad-free と、リワード広告視聴による期間限定 ad-free の状態を OR で合成した値を Globals.SHOW_AD として参照する仕組みです。スライドショー中はインタースティシャル広告が割り込むと体験が壊れるため、自動送りが走っている間は背面のインタースティシャル呼び出しを抑制する条件分岐を入れました。
過去にバックボタン経由の広告ゲートを入れ子で書いていた頃、優先度が暗黙化してデバッグが本当に辛くなった記憶があります。その反省を継いで、今回はスライドショーフラグ・ad-free フラグ・広告カウンタを独立した変数として並列に扱うようにしました。状態を並べておくと、後から「誰が誰を抑制したか」をログだけで追えるようになります。
ロック・回転・バックグラウンドへの対応
onPause で必ず stopSlideshow() を呼ぶ、onResume では自動で再開しないという地味な対応が重要でした。スライドショーが裏で回ったまま画面ロックされると、解除した瞬間に複数枚スキップして見えてしまうためです。
回転時は ViewModel に「現在ページ」と「スライドショー実行中フラグ」を保存し、onSaveInstanceState でも併用しています。両方持つのは冗長ですが、累計 5,000 万 DL のレンジで動かすと「Activity 全破棄を伴うクラッシュ復帰」と「単純な構成変更」のどちらも一定頻度で出るので、保険として残しています。
次に検討していること
スライドショーの間隔を設定画面から変えられるようにすること、ロック画面に直接設定するショートカットを足すこと、そして同じ仕組みを iOS 版に逆移植することを次の課題に置いています。祖父が宮大工で「手を動かすことが一つの信心」と教わって育った家庭の感覚が私にも残っていて、初回リリース時の設計に対して納得していない部分が今もあります。今回のアップデートはその一部を少しずつ整理していくきっかけになりました。
もしご利用のなかでお気づきの点があれば、Google Play のレビューから教えていただけると嬉しいです。実機でしか拾えない挙動が必ずあるので、引き続き 1 件ずつ手で返信しながら反映していきます。お読みいただきありがとうございました。