ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-06-22Advanced

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.

android22kotlin6recyclerview2ios30indie-dev19

Premium Article

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."

AspectiOS (UICollectionView)Android (RecyclerView + SnapHelper)
Who owns pagingFramework built-in (isPagingEnabled)Bolt-on SnapHelper
When the page is finalscrollViewDidEndDeceleratingThe moment scroll state drops to IDLE
How you read positionComputed from contentOffsetsnapHelper.findSnapView() returns the centered view
Mid-glide stateYou can ignore itYou 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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

App Dev2026-05-17
Where Is the Source of Truth for Billing State? Designing the ad-free Pattern with Antigravity
A proven pattern from a 50 million download app: centralizing billing state with AdFreeManager, BillingManager, and ModalGate — designed and implemented using Antigravity IDE.
App Dev2026-04-05
Antigravity × Compose Multiplatform: The Complete Guide to Shared UI Across iOS, Android, and Desktop in 2026
A deep-dive production guide to building high-quality cross-platform apps with Compose Multiplatform and Antigravity IDE. Covers architecture, expect/actual patterns, Desktop support, automated testing, and full release pipelines for iOS, Android, and JVM Desktop.
App Dev2026-06-17
Stop Dialogs From Stacking: One Gate for Paywalls, Review Prompts, and Rewarded Ads
A field record of curing the bug where a paywall, a review prompt, and a rewarded ad all surface at once, fixed with a single priority-based modal gate. I let an Antigravity agent sweep up the scattered show() calls, but kept the display policy in my own hands.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →