HorizontalPager is a lazy list that snaps — and PagerState is where the bugs live

HorizontalPager brings paging into Compose with no external dependency. The interesting surface is PagerState — currentPage versus settledPage, scroll versus animateScrollToPage, and how to sync tabs without a loop.

6 min read
androidcomposekotlinlayout

Day 25 — HorizontalPager is a lazy list that snaps — and PagerState i

Day 25 of 100. Onboarding carousels, image galleries, tabbed screens. HorizontalPager has been in the foundation library since Compose 1.4 — no Accompanist, no ViewPager2 interop.

The symptom

A pager wired to a tab row, where selecting a tab fights the swipe:

var selectedTab by remember { mutableStateOf(0) }
val pagerState = rememberPagerState { pages.size }

TabRow(selectedTabIndex = selectedTab) {
    pages.forEachIndexed { i, p ->
        Tab(selected = selectedTab == i, onClick = { selectedTab = i }) { Text(p.title) }
    }
}
HorizontalPager(state = pagerState) { page -> PageContent(pages[page]) }

LaunchedEffect(selectedTab) { pagerState.animateScrollToPage(selectedTab) }
LaunchedEffect(pagerState.currentPage) { selectedTab = pagerState.currentPage }

Swipe halfway and release: the tab flickers, sometimes lands on the wrong page, and occasionally the pager animates back and forth before settling.

Two sources of truth, each driving the other. Day 11's bug, in an animated container that makes it visible.

Why the obvious fix fails

The obvious fix is a guard flag:

var updatingFromPager by remember { mutableStateOf(false) }
LaunchedEffect(selectedTab) {
    if (!updatingFromPager) pagerState.animateScrollToPage(selectedTab)
}

This is the flag from Day 9 wearing different clothes. It works until an animation is interrupted mid-flight, at which point the flag is stuck in the wrong state and the tabs stop responding entirely — a bug that reproduces roughly once per twenty attempts and takes an afternoon to pin down.

The real fix is to delete one of the two sources of truth.

PagerState is the single source of truth; the tab row reads it and the pager owns it

The actual mechanism

PagerState is the selected-page state. There is no second variable:

val pagerState = rememberPagerState(initialPage = 0) { pages.size }
val scope = rememberCoroutineScope()

TabRow(selectedTabIndex = pagerState.currentPage) {
    pages.forEachIndexed { i, p ->
        Tab(
            selected = pagerState.currentPage == i,
            onClick = { scope.launch { pagerState.animateScrollToPage(i) } },
        ) { Text(p.title) }
    }
}
HorizontalPager(state = pagerState) { page -> PageContent(pages[page]) }

No LaunchedEffect, no flag, no loop. The tab row reads currentPage; tapping a tab writes by scrolling. One owner, and Day 11's rule applied to a container that otherwise makes the violation very visible.

Note the page count is a lambdarememberPagerState { pages.size }. It's read lazily so the pager survives the list changing size without being recreated.

currentPage versus settledPage

Two properties, and picking the wrong one is the most common pager bug after the loop.

  • currentPage — the page closest to the snap position. Updates during a swipe, as soon as you pass the halfway point.
  • settledPage — updates only once scrolling has fully stopped.

For a tab indicator you want currentPage, so the highlight moves with the finger. For anything with a side effect — analytics, prefetching, starting a video — you want settledPage, or you fire an event for every page brushed past during a fast fling:

LaunchedEffect(pagerState) {
    snapshotFlow { pagerState.settledPage }
        .collect { page -> analytics.screenView(pages[page].name) }
}

snapshotFlow is the right tool here rather than LaunchedEffect(pagerState.currentPage) — it turns a state read into a flow, so you get distinct-until-changed semantics and a single collector, rather than an effect that restarts on each change.

For a smooth indicator you can go finer still: currentPageOffsetFraction is the fractional scroll position between −0.5 and 0.5, which is what drives a dot indicator that slides rather than jumps.

The parameters worth knowing

HorizontalPager(
    state = pagerState,
    pageSize = PageSize.Fill,              // or Fixed(200.dp) for a peeking carousel
    contentPadding = PaddingValues(horizontal = 32.dp),  // shows neighbours
    pageSpacing = 16.dp,
    beyondViewportPageCount = 1,           // compose N pages either side
) { page -> … }

contentPadding plus pageSize is how you get the "card carousel with the next card peeking" layout without any custom measurement.

beyondViewportPageCount defaults to 0 — only the visible page is composed. Raising it to 1 makes swipes feel smoother for expensive pages, at the cost of composing them early. It is not free, and setting it high to fix jank usually means the page content is too expensive rather than that the pager needs more buffer.

Infinite paging

There's no built-in infinite mode; the idiom is a large virtual count with modulo indexing:

val pageCount = Int.MAX_VALUE
val state = rememberPagerState(initialPage = pageCount / 2) { pageCount }

HorizontalPager(state = state) { page ->
    PageContent(items[page % items.size])
}

Start in the middle so there's room to swipe both ways. It's a trick rather than a feature, and worth a comment in the code — the Int.MAX_VALUE reads as a mistake otherwise.

Two details make the difference between this working and feeling broken. Start at pageCount / 2 rather than 0, or the user hits a wall swiping backwards within a few gestures. And index with page % items.size rather than tracking an offset yourself — the modulo is what keeps content correct after thousands of pages without any bookkeeping.

How to prove it

The single-source-of-truth property is testable without a device:

@Test fun tabFollowsPager() = runComposeUiTest {
    setContent { TabbedPager(pages) }
    onNodeWithTag("pager").performTouchInput { swipeLeft() }
    onNodeWithTag("tab-1").assertIsSelected()
}

If the test needs a waitForIdle plus a sleep to pass reliably, you probably still have two sources of truth settling against each other.

For the currentPage/settledPage distinction, log both during a fast fling across ten pages. currentPage will step through most of them; settledPage should fire once. If your analytics uses the first, that difference is a lot of spurious events.

The loop bug has an equally direct check. Log every animateScrollToPage call together with its caller, then swipe once. A single deliberate swipe should produce zero programmatic scrolls — if it produces one, an effect is reacting to the page change by scrolling again, and that is the feedback loop regardless of whether it happens to settle correctly today.

What this generalizes to

The lesson is one already met twice: a component that owns state should be the only one that owns it. PagerState is a Day 17 state holder — a plain class, created with remember, scoped to the composition — and the bug at the top of this post came from duplicating it rather than reading it.

Any time you find yourself writing two effects that update each other, the fix is almost never a guard flag. It's noticing that one of the two variables shouldn't exist.

A note on vertical pagers and nesting

VerticalPager is the same API on the other axis, and it's what a full-screen video feed is built from. The nesting rule from Day 23 applies with a twist: a VerticalPager containing a LazyColumn works, because the pager consumes the fling and the inner list scrolls within a settled page — but only once the inner list has bounded height, which it gets from the pager filling the viewport.

The combination that misbehaves is two scrollables on the same axis with no snapping boundary between them. If a vertical pager page needs vertical scrolling, let the page own it and keep the pager's own gesture for the page transition; don't add a third scrollable in between hoping the gestures sort themselves out.

Tomorrow, Day 26: FlowRow and FlowColumn — wrapping layouts, and the chip group that finally doesn't need a custom Layout.


Day 25 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Pager in Compose.