Skip to content

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

Merged
simonhamp merged 1 commit into
feat/pager-report-on-settlefrom
feat/pager-tabs
Oct 22, 2026
Merged

simonhamp merged 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>
@simonhamp
simonhamp marked this pull request as ready for review October 22, 2026 11:04
@simonhamp
simonhamp merged commit cb22289 into feat/pager-report-on-settle Oct 22, 2026
4 checks passed
simonhamp added a commit that referenced this pull request Oct 22, 2026
* Let a pager report its page once the swipe settles

The pager reports the page as soon as it becomes the nearest one mid-swipe, so PHP's round trip overlaps the animation. That is right for a media feed. On a screen whose frame is large, the frame the report causes lands during the animation and the swipe stutters.

report-on-settle (reportOnSettle()) makes the pager report only once the scroll comes to rest. iOS keeps tracking the nearest page but only reports from the idle phase. Android reports settledPage instead of currentPage.

Both platforms now remember the page PHP last knew about, which includes a page PHP jumped to. Without that, on settle, a swipe back to the page before a jump could go unreported on iOS. Android also reported the start page straight after the pager appeared, which iOS never did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw a pager's tabs natively, following the swipe (#136)

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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit that referenced this pull request Oct 22, 2026
A profile has a header (avatar, bio, counts) above its tabs. Native apps
scroll the header up with the page you're on, pin the tabs under the nav
bar, and swipe the pages sideways under them while the header stays put.
EDGE couldn't do that: the pager's tabs sat above the pages, and anything
above the pager never scrolled away.

<native:pager-header> goes inside a horizontal pager beside the pages. The
pager hands it to each page, and the page's list draws it as its first row
with the tabs as a pinned section header (iOS) or sticky header (Android)
under it. So the header scrolls with the list, pull to refresh pulls it
down, and a drag that starts on it scrolls. While the pager swipes, the
header and tabs are moved against the page so they stay still, and the
two halves on screen meet as one.

That only works if the pages beside each other are level. The pager keeps
every page's list at the same offset while any of the header shows. Once
it has gone, each list keeps its own place, and one higher than the header
is brought down to where it pins the tabs. A list too short to scroll the
header away gets room at its end. iOS moves the lists through their
UIScrollView, since SwiftUI can't set a List's offset.

<native:pager-overlay> hangs something under the tabs over the pages, a
"3 new posts" pill say, and follows the tabs as the header scrolls.

Every page needs a list for the header to show on it.

This re-lands #139. That PR was merged into feat/pager-tabs a minute after #136 had already merged that branch, so it never reached main. Two conflicts, both in the iOS list renderer. #144 replaced the rows' end-reached closures with Self.endReachedAction, and the header's rows use that too. #118 added the list's minimum row height next to where this adds the pager header, and the list keeps both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit that referenced this pull request Oct 22, 2026
…154)

A profile has a header (avatar, bio, counts) above its tabs. Native apps
scroll the header up with the page you're on, pin the tabs under the nav
bar, and swipe the pages sideways under them while the header stays put.
EDGE couldn't do that: the pager's tabs sat above the pages, and anything
above the pager never scrolled away.

<native:pager-header> goes inside a horizontal pager beside the pages. The
pager hands it to each page, and the page's list draws it as its first row
with the tabs as a pinned section header (iOS) or sticky header (Android)
under it. So the header scrolls with the list, pull to refresh pulls it
down, and a drag that starts on it scrolls. While the pager swipes, the
header and tabs are moved against the page so they stay still, and the
two halves on screen meet as one.

That only works if the pages beside each other are level. The pager keeps
every page's list at the same offset while any of the header shows. Once
it has gone, each list keeps its own place, and one higher than the header
is brought down to where it pins the tabs. A list too short to scroll the
header away gets room at its end. iOS moves the lists through their
UIScrollView, since SwiftUI can't set a List's offset.

<native:pager-overlay> hangs something under the tabs over the pages, a
"3 new posts" pill say, and follows the tabs as the header scrolls.

Every page needs a list for the header to show on it.

This re-lands #139. That PR was merged into feat/pager-tabs a minute after #136 had already merged that branch, so it never reached main. Two conflicts, both in the iOS list renderer. #144 replaced the rows' end-reached closures with Self.endReachedAction, and the header's rows use that too. #118 added the list's minimum row height next to where this adds the pager header, and the list keeps both.

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