Repository navigation
Give a horizontal pager a header that scrolls with the page showing - #139
Merged
Merged
Conversation
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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp
added a commit
that referenced
this pull request
Oct 17, 2026
# Conflicts: # resources/ios/NativeUIListRenderer.swift
simonhamp
added a commit
to NativePHP/dev-null-app
that referenced
this pull request
Oct 18, 2026
…olls away The profile's Commits and Replies are now pages of a pager, with the tabs the pager draws, as on the Log and Notifications, underlined in your lane colour. The header (avatar, facts, counts, activity, streaks, achievements) goes in the pager's new header slot from NativePHP/mobile-ui#139. It scrolls up with the tab you're on, the tabs pin under the nav bar once it has gone, and the pages swipe under the tabs while the header stays put. Each tab has its own list, cache and pull to refresh. Only the tab you're on is live. The other shows the first five commits kept for it, so the frame stays small. The tab you leave starts again at the top of a fresh list, and tapping the tab you're on takes it back to the top. Your own profile still opens on the tab you last had. The new commits pill hangs under the tabs wherever they are, in the pager's overlay slot, instead of the sticky header between the tabs and the commits. The Log keeps its sticky header. The profile now sends every frame in full, like the Log: a reselect only changes a list's key, and EDGE's subtree reuse never sent it. The PHP-drawn page tabs partial is gone, since nothing uses it now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp
marked this pull request as ready for review
October 22, 2026 11:05
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #136 (pager tabs). The devnull app needs it for profiles (NativePHP/dev-null-app#11): a header with the avatar, bio and counts above Commits and Replies tabs that swipe.
What it does
<native:pager-header>goes inside a horizontal pager beside the pages. The header scrolls up with the page you're on, the tabs pin under the nav bar once it has gone, and the pages swipe sideways under the tabs while the header stays where it is. That's how profiles behave in most native apps.<native:pager-overlay>hangs something under the tabs over the pages, like a "3 new posts" pill. It follows the tabs as the header scrolls.How
I tried laying the header over the pages and insetting each list under it. Drags that start on the header then don't scroll anything, since the header isn't in a scroll view, so I dropped that.
Instead the pager hands the header to each page through the environment (a CompositionLocal on Android), and the page's list draws it as its first row, with the tabs as a pinned section header on iOS and a
stickyHeaderon Android. Scrolling, taps and pull to refresh all come from the list itself, and the refresh spinner shows above the header as it should.While the pager swipes, each page's header and tabs are moved against the page by the pager's position, so they look still and the halves of the two pages' headers meet as one. On Android that happens in the draw phase. For that to look right, the pages either side must be level. The pager keeps every list at the same offset while any of the header shows. Once the header has gone, each list keeps its own place, and a list that isn't that far down yet is brought to where the tabs pin. A list too short to scroll the header away gets room at its end.
What to look at
UIScrollView, found from inside the header row. SwiftUI has no way to set aList's offset. Android usesLazyListState.requestScrollToItem.list_sectionrows go into the one section under the tabs.Verified
Pest (291 tests) and
swiftc -parsepass locally. On the devnull app in an iPhone 17 Pro simulator (iOS 26.2): swipe both ways, tab taps, reselect back to the top, header part-scrolled then swiped (the other page is level), header scrolled away then swiped to a short tab, pull to refresh, the pill under the tabs, dark mode, own profile and someone else's. On Android (API 35 emulator) with a test screen: same swipe, pin, short tab, overlay and reselect checks, and Kotlin compiles.Recording on iOS: ios-profile-pager.mp4. The screenshots are on the orphan branch
pager-header-screenshots, to delete after merge.🤖 Generated with Claude Code