Skip to content

Let plugins replace a list's pull-to-refresh indicator - #198

Draft
jubianchi wants to merge 12 commits into
NativePHP:mainfrom
jubianchi:feat/refresh-indicator-registry
Draft

jubianchi wants to merge 12 commits into
NativePHP:mainfrom
jubianchi:feat/refresh-indicator-registry

Conversation

@jubianchi

@jubianchi jubianchi commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What

<native:list on-refresh> always shows the platform spinner. This lets a list name an indicator that a plugin registered, and pass it options:

<native:list
    on-refresh="reload"
    refresh-indicator="lottie"
    :refresh-indicator-options="['asset' => 'animations/logo.lottie']"
>
NativeList::make()
    ->onRefresh('reload')
    ->refreshIndicator('lottie', ['asset' => 'animations/logo.lottie']);

If no installed plugin registered that name, the list keeps the platform spinner, so a screen can name an indicator whether or not the app ships it. mobile-ui ships no indicator itself and adds no dependency.

How

  • Registry: plugins register a renderer from their init_function.
    • Kotlin: NativeUIRefreshIndicators.register("name") { scope -> … }
    • Swift: NativeUIRefreshIndicators.shared.register("name") { scope in AnyView(…) }
    • The scope gives the pull's progress (0 at rest, 1 exactly where letting go refreshes), whether a refresh is running, and the options decoded from JSON. Props carry no maps, so the options travel as a JSON string.
  • Android: the indicator fills the stock 48dp disc of PullToRefreshBox.
  • iOS: .refreshable can't show a custom view, so a list with a registered indicator runs its own pull-to-refresh, built on onScrollGeometryChange / onScrollPhaseChange. The indicator fills a 40pt square in the gap the pull opens. While refreshing, the room above the rows is kept with safeAreaPadding; contentMargins pads inside the content, which made the scroll offset lose track of it. Lists without a registered indicator keep .refreshable.
  • Threshold feedback on both platforms: a short bounce and a haptic tap each time the pull crosses the threshold, as the platform spinners do.
  • Only native:list supports this; native:refreshable keeps the platform spinner.

Example plugin

jubianchi/nativephp-lottie-refresh-indicator uses this registry to draw a Lottie / dotLottie animation that follows the pull, then loops while refreshing. It supports pull / refresh markers and a dark-mode asset. It has been run on devices in a real app, on Android and iOS; its README has recordings.

Docs

Companion docs PR: NativePHP/nativephp.com#600. It documents the list attributes and the plugin-side registration, and should merge once this ships.

Tests

  • Pest: element props for refresh-indicator / refresh-indicator-options (tests/ListTest.php). Full suite green.
  • Android (Robolectric, tests/android):
    • fallback to the stock indicator;
    • options decoding;
    • progress and refreshing state;
    • disc position;
    • one haptic per threshold crossing;
    • the bounce.
  • iOS: new Swift package tests/ios (swift test --package-path tests/ios). It covers the gesture logic in NativeUIPullToRefresh and the registry. The SwiftUI wiring was checked on simulator and device.
  • CI runs neither the Android nor the iOS native suites; both need to be run locally (see tests/android/README.md, tests/ios/README.md).

🤖 Generated with Claude Code

refresh-indicator names an indicator a plugin registers on the native side
and refresh-indicator-options hands it its settings (camelCase spellings
and NativeList::refreshIndicator() too). They serialize as the
refresh_indicator and refresh_indicator_options props; the options travel
as a JSON object since props carry no maps. Without a name, no prop is
sent and the list keeps the platform spinner.

🤖 Généré avec Claude Code
NativeUIRefreshIndicators.register(name) { scope -> … } registers an
indicator, typically from a plugin's android.init_function. A list whose
refresh_indicator names it draws it on the stock indicator's disc, through
PullToRefreshDefaults.IndicatorBox, so it slides with the pull, is clipped
to the list's top edge and casts the stock shadow; the indicator only
fills the 48dp disc, inset by 6dp. Its scope gives the pull's progress
(0..1, read lazily), whether a refresh is running and the decoded
refresh_indicator_options. A list naming no indicator, or one nobody
registered, keeps the stock spinner. mobile-ui itself takes on no new
dependency.

🤖 Généré avec Claude Code
SwiftUI's .refreshable only shows the system spinner, so a list drawing a
plugin's indicator needs its own pull-to-refresh. NativeUIPullToRefresh is
its plain Swift core: fed the list's top overscroll and whether a finger is
on it, it gives the pull's progress up to an 80pt threshold, starts a
refresh only when a finger lets go past it and never during another, keeps
60pt of room above the content while refreshing, and places the indicator
in the gap the pull opens.

tests/ios is a Swift package that unit-tests such logic on macOS
(swift test --package-path tests/ios), linking the shipped sources.

🤖 Généré avec Claude Code
NativeUIRefreshIndicators.shared.register(name) { scope in AnyView(…) }
mirrors the Android registry, for a plugin's ios.init_function. The scope
gives the pull's progress (0..1, 1 while refreshing), whether a refresh is
running and the list's refresh_indicator_options decoded from JSON
(missing or malformed options read as none).

The tests/ios package now builds in Swift 5 mode, as the app does.

🤖 Généré avec Claude Code
.refreshable only shows the system spinner, so a list whose
refresh_indicator names a registered indicator runs its own
pull-to-refresh instead: onScrollGeometryChange feeds the top overscroll
to NativeUIPullToRefresh, onScrollPhaseChange tells it when the finger
lets go, and the indicator draws in a 40pt square in the gap the pull
opens above the rows, clipped to it. A release past the threshold calls
on_refresh with a light haptic and keeps room above the rows for a
second, as long as the .refreshable path waits, then lets them settle
back. A list naming no indicator, or one nobody registered, keeps
.refreshable.

🤖 Généré avec Claude Code
Covers the refresh-indicator attributes and fluent method for screens,
registering an indicator from a plugin on Android and iOS, the fallback
to the platform spinner, and how each platform draws it.

🤖 Généré avec Claude Code
…hing

The indicator vanished on release while the list kept the refresh's room
above its rows. Its position assumed the scroll geometry's top inset counts
the content margin the refresh adds; if it doesn't, the gap was measured a
margin too tall and drawn a margin too high, behind the navigation bar.

The rows' rest is now the top inset seen outside a refresh, and the gap is
how far the rows sit below it, so either reading of the inset gives the same
gap and position.

🤖 Généré avec Claude Code
On a List, contentMargins for the scroll content pad inside the content:
the scroll offset settles back to its rest while the rows stay shifted, so
the indicator's gap closed and it rose out of view during the refresh. The
content height also grew by far more than the margin.

A top safe area padding insets the scroll view instead: the rows rest on
it with the offset reflecting the room, which is what the gap measures.

🤖 Généré avec Claude Code
Nothing told the user how far to pull: a release just short of the
threshold drew most of the artwork yet didn't refresh. Crossing the
threshold while pulling now plays the gesture-threshold haptic and bounces
the indicator inside its disc, once per crossing, so every registered
indicator gets it. The threshold is where PullToRefreshBox's distance
fraction passes 1, the same point the indicator's progress reaches 1.

🤖 Généré avec Claude Code
As on Android, crossing the threshold while pulling now bounces the
indicator's square and plays a medium impact, once per crossing. The
light impact played as the refresh started is gone: the buzz now says
"you can let go", not "it's refreshing".

🤖 Généré avec Claude Code
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