Porting a Wallpaper Viewer's Slideshow and Page-Scrubber from iOS to Android — Where the Two-Way Sync with SnapHelper Tripped Me Up
A hands-on record of porting a full-screen wallpaper viewer's slideshow and bottom scrubber from iOS to Android. How I pinned down the current page with RecyclerView and SnapHelper, synced it two-way with the scrubber, and resolved the conflict between auto-advance and user input with a small state machine — in working Kotlin.
When you ship a wallpaper app on both iOS and Android as a solo developer, you keep wanting to bring a well-received feature from one side over to the other. This time the direction was reversed. The full-screen viewer on iOS already had a touch I liked — it auto-advances as a slideshow, but if you grab the scrubber at the bottom you can jump straight to any page — and I wanted it on Android too.
I handed the port itself to Antigravity. Feed it the iOS UICollectionView implementation, ask for "the same behavior with a RecyclerView," and the scaffold comes together fast. But on a real device the current-page label lagged a beat behind what was on screen. Dragging the scrubber made the viewer scroll twice. Swiping during the slideshow made the screen stutter. Where it refused to "just work," the difference in design philosophy between iOS and Android showed through directly.
Here's the record of working through the three places I got stuck, in order.
First, the moment a "current page" becomes final differs between iOS and Android
The first thing that felt off was the page number on the scrubber trailing behind the actual view. The cause is that the model for when a page position becomes final is fundamentally different on the two platforms.
On iOS, set isPagingEnabled = true on a UICollectionView and it stops exactly one screen at a time; the page is final the instant scrollViewDidEndDecelerating fires. Because the framework guarantees "one page equals one screen width," you can round contentOffset.x / pageWidth to get the current page.
Android's RecyclerView has no built-in concept of paging. Snapping is handled by a separate, bolt-on component called SnapHelper. On top of that, the scroll-state transitions differ: after you lift your finger, it passes through SETTLING (gliding under inertia) before landing on IDLE. Read the page while it's still SETTLING and you grab a half-way position mid-glide. That was the real source of the "lags a beat."
Aspect
iOS (UICollectionView)
Android (RecyclerView + SnapHelper)
Who owns paging
Framework built-in (isPagingEnabled)
Bolt-on SnapHelper
When the page is final
scrollViewDidEndDecelerating
The moment scroll state drops to IDLE
How you read position
Computed from contentOffset
snapHelper.findSnapView() returns the centered view
Mid-glide state
You can ignore it
You must explicitly exclude SETTLING
Read the position every frame inside addOnScrollListener without grasping this, and the scrubber trembles. "Finalize only on IDLE" is the starting point.
The foundation: stop one page at a time with RecyclerView + PagerSnapHelper
The base of the port starts by attaching a PagerSnapHelper to a horizontally scrolling RecyclerView. PagerSnapHelper advances exactly one page per fling — the very behavior that corresponds to 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) // One fling = one page. Don't let it skip multiple pages. snapHelper.attachToRecyclerView(recyclerView) scrubber.max = (itemCount - 1).coerceAtLeast(0) } /** Returns the adapter position of the view SnapHelper has snapped to center, or -1. */ private fun currentPage(): Int { val lm = recyclerView.layoutManager ?: return -1 val view = snapHelper.findSnapView(lm) ?: return -1 return lm.getPosition(view) }}
What findSnapView() returns is "the view currently snapped to center." You'll often see implementations that use LinearLayoutManager.findFirstVisibleItemPosition(), but it doesn't always match the snap position, and that's exactly what throws the scrubber off. I wrote it that way at first and hit an off-by-one-page bug. The snap position must come from snapHelper.findSnapView().
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you carried iOS's UICollectionView paging straight to Android and the current page kept drifting, you'll fix it with the correct settle timing using SnapHelper
✦You'll learn the source-flag pattern that breaks the infinite loop that always appears when you sync a viewer and a scrubber (SeekBar) two ways
✦You'll tame the jitter where slideshow auto-advance fights the user's swipe by modeling it as a three-state machine you can reuse on any gallery screen
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
With the foundation in place, move the scrubber's thumb when the user swipes and comes to rest. Here's where "finalize only on IDLE" turns into code.
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(rv: RecyclerView, newState: Int) { // Ignore SETTLING (gliding under inertia). Finalize only the instant it fully stops. if (newState != RecyclerView.SCROLL_STATE_IDLE) return val page = currentPage() if (page < 0) return syncScrubberTo(page) // move the thumb to the page position }})
Use onScrollStateChanged rather than onScrolled (which fires every frame in pixel units), and pick up only IDLE. That structurally avoids "reading a half-way position mid-glide." It helps to think of this transition to IDLE as the counterpart to iOS's scrollViewDidEndDecelerating.
Scrubber → viewer: the infinite loop two-way sync always causes
Now the reverse direction: grab the scrubber, drag it, and jump the viewer to that position. Write two-way sync and you'll always hit a trap.
Move the scrubber → scroll the viewer → the viewer stops and goes IDLE → the listener moves the scrubber → the scrubber's listener moves the viewer again… a mutual-call loop. On a device the thumb twitches, or it overshoots the page you aimed for.
The way to break it is to hold a single flag for "is the thing moving right now coming from code, or from the user's finger?" and to not fire the other side's listener during code-driven updates.
// Flag marking "code is actively driving the scroll right now"private var isProgrammaticScroll = falseprivate fun syncScrubberTo(page: Int) { if (scrubber.progress == page) return isProgrammaticScroll = true // the coming progress change is code-driven scrubber.progress = page isProgrammaticScroll = false}init { scrubber.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(sb: SeekBar, progress: Int, fromUser: Boolean) { // Ignore code-driven changes (syncScrubberTo). Pick up only the user's finger. if (!fromUser || isProgrammaticScroll) return recyclerView.smoothScrollToPosition(progress) } override fun onStartTrackingTouch(sb: SeekBar) { pauseSlideshow(UserIntent.SCRUBBING) } override fun onStopTrackingTouch(sb: SeekBar) { resumeSlideshowLater() } })}
SeekBar's onProgressChanged hands you fromUser, so start by judging finger-origin with that. But fromUser alone won't fully prevent the case where IDLE slips in mid-smoothScrollToPosition and runs syncScrubberTo. So I pair it with the isProgrammaticScroll flag to explicitly exclude the span where code is actively driving. Double defense turned out to be the safe choice.
On iOS you can do something similar just by wiring .valueChanged to a UISlider's addTarget. But since the language gives you no equivalent of the fromUser distinction, Android needs more careful flag management.
Separate auto-advance from user input with a state machine
The last hard part is the conflict between slideshow auto-advance and manual input. While pages advance on a fixed interval, if the user swipes or grabs the scrubber, auto and manual advance fire at once and the screen stutters.
Patch Handler.postDelayed flags together with ifs and the branches tangle immediately. I rewrote this part as a state machine with three states: IDLE (stopped), AUTO (auto-advancing), and USER (user interacting). Make it a one-way rule — always fall to USER when interaction starts, return to AUTO only after interaction ends and a short delay — and the conflict disappears.
private enum class PlayState { IDLE, AUTO, USER }private enum class UserIntent { SWIPING, SCRUBBING }private var state = PlayState.IDLEprivate var autoJob: Job? = nullprivate val scope = MainScope()fun startSlideshow() { if (state == PlayState.USER) return // don't start auto-advance during interaction state = PlayState.AUTO autoJob?.cancel() autoJob = scope.launch { while (isActive && state == PlayState.AUTO) { delay(5_000) if (state != PlayState.AUTO) break // bail if it fell to USER mid-wait val next = (currentPage() + 1) % itemCount recyclerView.smoothScrollToPosition(next) } }}/** Call on the start of any swipe/scrubber input. Fall to USER, stop auto-advance. */private fun pauseSlideshow(intent: UserIntent) { state = PlayState.USER autoJob?.cancel()}/** After input ends, wait a little before returning to AUTO (advancing the instant * the finger lifts feels frantic). */private fun resumeSlideshowLater() { scope.launch { delay(2_000) if (state == PlayState.USER) startSlideshow() }}
The key is: "transition to USER from any user-input entry point, but funnel the return to AUTO through the single spot resumeSlideshowLater." Narrow the recovery path to one and, even if input arrives again during the delay, it checks state and bows out, so it never double-starts. Back when I had it stitched together with ifs, that recovery path was implicitly plural, and auto-advance ran twice — that was the cause of the stutter.
One more detail: only when smoothScrollToPosition wraps from end to end (folding from the last page back to 0) did it animate a high-speed rewind through every page, which was distracting. Switching to scrollToPosition (an instant jump) only on the wrap makes it look natural, close to iOS's loop advance. A small thing, but fixing it lifts the polish considerably.
What Antigravity nailed in the port, and what it couldn't
Where Antigravity was strong this time was building the skeleton — reading the iOS UICollectionView implementation and translating it into its Android counterpart, RecyclerView + SnapHelper. The class structure and adapter plumbing came out as a foundation you can use nearly as-is. Not having to look up the cross-platform API mapping in my head is quiet, but it pays off day to day.
On the other hand, the three points here — when the current page is finalized, the infinite loop in two-way sync, the conflict between auto-advance and manual input — were all "state-transition problems that only surface once you touch a real device." The agent's first cut read position every frame in onScrolled and wired the two sides together with no flag; it looked correct statically yet stuttered on a device. It only converged once I observed the symptoms on a device myself and handed over the design cores — "only on IDLE," "a source flag," "a state machine."
Start by swapping your current-page lookup from findFirstVisibleItemPosition to snapHelper.findSnapView() on a gallery screen you have at hand. If the scrubber drift disappears, it's worth moving on to two-way sync and the state machine.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.