Skip to content

Draw a pager's tabs natively, following the swipe - #136

Draft
simonhamp wants to merge 1 commit into
feat/pager-report-on-settlefrom
feat/pager-tabs
Draft

simonhamp wants to merge 1 commit into
feat/pager-report-on-settlefrom
feat/pager-tabs

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Stacked on #135. Review that one first. Docs: NativePHP/nativephp.com#563.

A screen that pages between tabs has to draw its tab row in PHP today. The row can only change when PHP sends a frame, so it trails the swipe. Sending that frame during the swipe is also what makes the swipe stutter (#135). So the row has to be native and driven by the pager itself.

<native:pager horizontal report-on-settle
    :tabs="['Following', 'Latest', 'Trending']"
    :page="$page" on-page-change="showPage"
    on-tab-reselect="scrollToTop">
    ...
</native:pager>

The pager draws the row above its pages, one label per page.

  • The indicator follows the swipe frame by frame. Its x and width are interpolated between the labels either side of the pager's position.
  • The highlighted label is the nearest page, so it switches as the swipe passes halfway.
  • Tapping a tab scrolls the pager there, animated. The page is reported when the pager settles, as for a swipe.
  • Tapping the tab already showing calls on-tab-reselect with its index instead, so the app can take that page back to its top.
  • By default the tabs share the row equally. tabs-scrollable makes them as wide as their labels, in a row that scrolls to keep the indicator in the middle as you swipe.

None of that waits for PHP.

Look

Colours come from the theme by default: on-surface for the active label, on-surface-variant for the others, primary for the indicator and outline-variant for the hairline under the row. The font is the theme's at font-md.

These attributes change it per pager:

Attribute Default
tab-active-color, tab-color on-surface, on-surface-variant
tab-indicator-color primary
tab-divider-color outline-variant, transparent hides it
tab-font, tab-font-size theme font, font-md
tab-indicator-height 3
tab-padding-x, tab-padding-top, tab-padding-bottom 12, 14, 11
tab-edge-padding 4, scrollable rows only

The defaults make a 48pt tab. Colours take the usual palette and hex values, and theme-<token> too, which also sends the token's dark shade. A tab can be ['label' => ..., 'a11y-label' => ...] when VoiceOver and TalkBack should say more than the label.

The repo's components are normally theme-only. The colour attributes are here because the row sits on top of whatever the pages are. A video feed needs light labels and a list doesn't, on the same app theme. That's the same reason activity-indicator takes a colour.

How it works

iOS: PagerBody writes the unrounded scroll position into an @Observable object on every geometry change. Only the strip reads it, so a swipe redraws the strip and not the pages. Labels are measured with onGeometryChange. A scrollable row follows with ScrollPosition.scrollTo(x:). A tap sets the pager's scrollPosition inside withAnimation, the same binding PHP's jumps use.

Android: the indicator is drawn in drawBehind from currentPage + currentPageOffsetFraction, so a swipe only invalidates drawing. The highlight is currentPage. A tap calls animateScrollToPage. A scrollable row follows with scrollState.scrollTo.

Both: the tabs are tabs to VoiceOver and TalkBack, with the selected one marked. The pager's a11y-label now applies to the pages only, not the row.

Tests

PagerTabsTest covers the props, theme- colours with both shades, the fluent API, and checks the Swift and Kotlin source like the other iOS tests.

How I tested

A test app from mobile-starter with devnull's theme and Geist fonts. One screen has three fixed tabs, one has seven scrollable ones, and one draws devnull's current PHP tabs for comparison. Every page report and render is logged with a timestamp. iPhone 17 Pro simulator on iOS 27.0, and an Android 15 emulator.

  • Slow drags: the indicator moves with the content in every frame. At about 48% it sits between the two labels with the old one highlighted, and the next frame (about 52%) highlights the new one. Nothing renders in PHP until the pager settles.
  • Taps: the pager animates straight to the tab, crossing pages, and PHP hears once at the end. Tapping the current tab sends reselect and the app's keyed list goes back to the top.
  • Scrollable row: it starts scrolling when the indicator passes the middle and stops at either end. A launch on page 4 opens with that tab centred.
  • Light and dark: the native row and devnull's PHP row match to within a pixel.
  • Android: the same drags, taps and reselect, on both layouts.

On iOS, a pager that opens on a page other than 0 also needs #131, which fixes the start page. Without it the pager and the row open on page 0. The dev-null app branch has both.

Not tested: a physical device, iPad, landscape, right-to-left layouts, and tabs on a vertical pager (it draws them, but I didn't try it).

Screenshots

iOS, both layouts. Slow drags, taps, and a reselect, with the idle time between steps cut:

Demo

devnull's PHP tabs above, the native row below, light and dark:

PHP and native

A slow drag, a frame every 0.2s. The highlight flips between the fourth row's first and second frames:

Fixed drag

The scrollable row following a swipe from Quotes to Stars:

Scrollable drag

Android, fixed and scrollable:

Android fixed

Android scrollable

The images are on the orphan branch pager-tabs-screenshots. Delete it after merge.

🤖 Generated with Claude Code

A screen that pages between tabs has had to draw the tab row in PHP. That row can only change when PHP sends a frame, so it lags the swipe, and sending a frame mid-swipe is what makes the swipe stutter.

tabs="[...]" draws the row natively above the pages, one label per page. The row reads the pager's scroll position every frame. The indicator slides between the labels either side of it, and the highlight moves to the nearest page, so it changes as the swipe passes halfway. Tapping a tab scrolls the pager there and the page is reported when it settles, as for a swipe. Tapping the tab already showing calls on-tab-reselect with its index instead, so the page can go back to its top.

By default the tabs share the row equally. tabs-scrollable makes them as wide as their labels in a row that scrolls to keep the current one in the middle. Colours default to the theme (on-surface, on-surface-variant, primary, outline-variant). The tab-* attributes override the colours, font, indicator thickness and padding per pager, and a theme-<token> colour sends its dark shade too.

iOS keeps the position in an observable that only the row reads, so a swipe redraws the row and not the pages. Android draws the indicator from currentPage plus currentPageOffsetFraction in the draw phase. The tabs are tabs to VoiceOver and TalkBack, with the selected one marked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant