From 1480f98319aaf9decda0c0ccfc824f777c5bb482 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sat, 31 Oct 2026 23:36:53 +0100 Subject: [PATCH 01/12] feat(list): name a plugin-registered pull-to-refresh indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Elements/NativeList.php | 25 +++++++++++++++++++++++++ tests/ListTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/Elements/NativeList.php b/src/Elements/NativeList.php index 66aee63..c8ed9c6 100644 --- a/src/Elements/NativeList.php +++ b/src/Elements/NativeList.php @@ -54,6 +54,11 @@ public function applyAttributes(array $attrs): void if (isset($attrs['on-refresh']) || isset($attrs['onRefresh'])) { $this->onRefresh($attrs['on-refresh'] ?? $attrs['onRefresh']); } + $refreshIndicator = $attrs['refresh-indicator'] ?? $attrs['refreshIndicator'] ?? null; + if (is_string($refreshIndicator) && $refreshIndicator !== '') { + $options = $attrs['refresh-indicator-options'] ?? $attrs['refreshIndicatorOptions'] ?? []; + $this->refreshIndicator($refreshIndicator, is_array($options) ? $options : []); + } if (isset($attrs['on-end-reached']) || isset($attrs['onEndReached'])) { $this->onEndReached($attrs['on-end-reached'] ?? $attrs['onEndReached']); } @@ -147,6 +152,26 @@ public function onRefresh(string $method): static return $this; } + /** + * Replace the platform pull-to-refresh spinner with the indicator a + * plugin registered under $name, handing it $options (an asset path, a + * colour…). The list keeps the platform spinner when no plugin + * registered that name, so a screen can name an indicator whether or + * not the app ships it. + * + * @param array $options + */ + public function refreshIndicator(string $name, array $options = []): static + { + $this->listProps['refresh_indicator'] = $name; + if ($options !== []) { + // Props carry no maps, so the options travel as a JSON object. + $this->listProps['refresh_indicator_options'] = json_encode($options, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + return $this; + } + public function onEndReached(string $method): static { $this->endReachedCallback = $method; diff --git a/tests/ListTest.php b/tests/ListTest.php index 9805b7f..7d07337 100644 --- a/tests/ListTest.php +++ b/tests/ListTest.php @@ -186,3 +186,35 @@ function collectList(array $attrs): array expect(collectList(['autoScrollTo' => $index])['props']['auto_scroll_to'])->toBe($index) ->and(NativeList::make()->autoScrollTo($index)->toArray(new CallbackRegistry)['props']['auto_scroll_to'])->toBe($index); })->with([0, 12, -3]); + +it('names a registered pull-to-refresh indicator with its options', function () { + $tree = collectList([ + 'refresh-indicator' => 'lottie', + 'refresh-indicator-options' => ['asset' => 'animations/splash.lottie', 'loop' => true], + ]); + + expect($tree['props']['refresh_indicator'])->toBe('lottie') + ->and(json_decode($tree['props']['refresh_indicator_options'], true)) + ->toBe(['asset' => 'animations/splash.lottie', 'loop' => true]); +}); + +it('accepts the camelCase refresh indicator spellings and the fluent builder', function () { + $props = collectList(['refreshIndicator' => 'lottie', 'refreshIndicatorOptions' => ['asset' => 'a.lottie']])['props']; + $built = NativeList::make()->refreshIndicator('lottie', ['asset' => 'a.lottie'])->toArray(new CallbackRegistry)['props']; + + expect($props['refresh_indicator'])->toBe('lottie') + ->and($props['refresh_indicator_options'])->toBe('{"asset":"a.lottie"}') + ->and($built['refresh_indicator'])->toBe('lottie') + ->and($built['refresh_indicator_options'])->toBe('{"asset":"a.lottie"}'); +}); + +it('keeps the stock refresh indicator unless one is named', function () { + expect(collectList([])['props'] ?? [])->not->toHaveKey('refresh_indicator') + ->and(collectList(['refresh-indicator' => ''])['props'] ?? [])->not->toHaveKey('refresh_indicator') + ->and(collectList(['refresh-indicator-options' => ['asset' => 'a.lottie']])['props'] ?? [])->not->toHaveKey('refresh_indicator_options'); +}); + +it('sends no options for an indicator named without any', function () { + expect(collectList(['refresh-indicator' => 'lottie'])['props'])->not->toHaveKey('refresh_indicator_options') + ->and(collectList(['refresh-indicator' => 'lottie', 'refresh-indicator-options' => []])['props'])->not->toHaveKey('refresh_indicator_options'); +}); From 69c71623ab1157edd17424044b912969531e69c7 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sat, 31 Oct 2026 23:42:19 +0100 Subject: [PATCH 02/12] feat(list): let plugins provide the Android pull-to-refresh indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- resources/android/ListRenderer.kt | 11 +- .../android/NativeUIRefreshIndicators.kt | 125 ++++++++++++++++ tests/android/build.gradle.kts | 2 +- .../test/kotlin/ListRefreshIndicatorTest.kt | 135 ++++++++++++++++++ 4 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 resources/android/NativeUIRefreshIndicators.kt create mode 100644 tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt diff --git a/resources/android/ListRenderer.kt b/resources/android/ListRenderer.kt index 2a65e39..dbcc765 100644 --- a/resources/android/ListRenderer.kt +++ b/resources/android/ListRenderer.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp import com.nativephp.mobile.ui.nativerender.NativeElementBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.mobile.ui.nativerender.NodeView +import com.nativephp.plugins.native_ui.NativeUIRefreshIndicator import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -288,7 +289,15 @@ object ListRenderer { } }, state = refreshState, - modifier = modifier + modifier = modifier, + indicator = { + NativeUIRefreshIndicator( + name = node.props.getString("refresh_indicator", ""), + options = node.props.getString("refresh_indicator_options", ""), + state = refreshState, + isRefreshing = isRefreshing.value, + ) + }, ) { listContent() } diff --git a/resources/android/NativeUIRefreshIndicators.kt b/resources/android/NativeUIRefreshIndicators.kt new file mode 100644 index 0000000..adace21 --- /dev/null +++ b/resources/android/NativeUIRefreshIndicators.kt @@ -0,0 +1,125 @@ +package com.nativephp.plugins.native_ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.util.concurrent.ConcurrentHashMap + +/** + * What a registered pull-to-refresh indicator draws from. + * + * @property progress how far the content is pulled, from 0 at rest to 1 at + * the refresh threshold; read it while drawing so a pull doesn't recompose + * @property isRefreshing whether the refresh the pull triggered is running + * @property options the `refresh-indicator-options` the screen gave, decoded + * from JSON (strings, numbers, booleans, lists and maps) + */ +class RefreshIndicatorScope( + val progress: () -> Float, + val isRefreshing: Boolean, + val options: Map, +) + +/** + * Pull-to-refresh indicators other plugins provide, by the name a list's + * `refresh-indicator` attribute gives. Register from the plugin's + * `android.init_function`, before the first composition: + * + * NativeUIRefreshIndicators.register("lottie") { scope -> … } + * + * The list draws the indicator on the stock indicator's disc, which slides + * with the pull and is clipped to the list's top edge; the indicator only + * fills the disc. A list naming an indicator nobody registered keeps the + * stock spinner. + */ +object NativeUIRefreshIndicators { + private val indicators = ConcurrentHashMap Unit>() + + fun register(name: String, content: @Composable (RefreshIndicatorScope) -> Unit) { + indicators[name] = content + } + + internal fun find(name: String): (@Composable (RefreshIndicatorScope) -> Unit)? = + if (name.isEmpty()) null else indicators[name] + + internal fun reset() = indicators.clear() +} + +internal const val REFRESH_INDICATOR_TAG = "refresh-indicator" + +/** A little larger than the stock 40dp disc, so artwork stays legible inside its padding. */ +private val REFRESH_INDICATOR_SIZE = 48.dp + +private val REFRESH_INDICATOR_PADDING = 6.dp + +/** + * The indicator a list's `refresh_indicator` names, on the stock disc, or + * the stock indicator when no plugin registered that name. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BoxScope.NativeUIRefreshIndicator( + name: String, + options: String, + state: PullToRefreshState, + isRefreshing: Boolean, +) { + val content = NativeUIRefreshIndicators.find(name) + if (content == null) { + PullToRefreshDefaults.Indicator( + state = state, + isRefreshing = isRefreshing, + modifier = Modifier.align(Alignment.TopCenter), + ) + return + } + + val decoded = remember(options) { decodeRefreshIndicatorOptions(options) } + PullToRefreshDefaults.IndicatorBox( + state = state, + isRefreshing = isRefreshing, + modifier = Modifier.align(Alignment.TopCenter).size(REFRESH_INDICATOR_SIZE), + containerColor = PullToRefreshDefaults.indicatorContainerColor, + ) { + // Inside the box, so its bounds follow the disc as it slides. + Box( + Modifier.fillMaxSize().testTag(REFRESH_INDICATOR_TAG).padding(REFRESH_INDICATOR_PADDING), + contentAlignment = Alignment.Center, + ) { + content(RefreshIndicatorScope({ state.distanceFraction.coerceIn(0f, 1f) }, isRefreshing, decoded)) + } + } +} + +private fun decodeRefreshIndicatorOptions(json: String): Map { + if (json.isEmpty()) return emptyMap() + return try { + jsonObjectToMap(JSONObject(json)) + } catch (_: JSONException) { + emptyMap() + } +} + +private fun jsonObjectToMap(json: JSONObject): Map = + json.keys().asSequence().associateWith { jsonValue(json.get(it)) } + +private fun jsonValue(value: Any?): Any? = when (value) { + JSONObject.NULL -> null + is JSONObject -> jsonObjectToMap(value) + is JSONArray -> (0 until value.length()).map { jsonValue(value.get(it)) } + else -> value +} diff --git a/tests/android/build.gradle.kts b/tests/android/build.gradle.kts index 05c4fc7..84bd8bf 100644 --- a/tests/android/build.gradle.kts +++ b/tests/android/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } val stageSources = tasks.register("stageSources") { from(layout.projectDirectory.dir("../../resources/android")) { - include("FilledTextInputRenderer.kt", "OutlinedTextInputRenderer.kt", "BareTextInputRenderer.kt", "TextInputShared.kt", "NativeUIFontResolver.kt", "NativeUITheme.kt", "ButtonRenderer.kt", "GestureAreaRenderer.kt", "ListRenderer.kt", "VirtualListRenderer.kt", "PagerRenderer.kt") + include("FilledTextInputRenderer.kt", "OutlinedTextInputRenderer.kt", "BareTextInputRenderer.kt", "TextInputShared.kt", "NativeUIFontResolver.kt", "NativeUITheme.kt", "ButtonRenderer.kt", "GestureAreaRenderer.kt", "ListRenderer.kt", "NativeUIRefreshIndicators.kt", "VirtualListRenderer.kt", "PagerRenderer.kt") } from(providers.gradleProperty("mobileRoot").map { "$it/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender" }) { include("KeyboardFocusPolicy.kt", "NativeUINode.kt", "SharedValueStore.kt") diff --git a/tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt b/tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt new file mode 100644 index 0000000..10e1d64 --- /dev/null +++ b/tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt @@ -0,0 +1,135 @@ +package com.nativephp.plugins.native_ui.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.GenericProps +import com.nativephp.mobile.ui.nativerender.NativeUINode +import com.nativephp.plugins.native_ui.NativeUIRefreshIndicators +import com.nativephp.plugins.native_ui.REFRESH_INDICATOR_TAG +import com.nativephp.plugins.native_ui.RefreshIndicatorScope +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ListRefreshIndicatorTest { + @get:Rule val compose = createComposeRule() + + private var seen: RefreshIndicatorScope? = null + + @After fun forgetIndicators() = NativeUIRefreshIndicators.reset() + + @Test fun aListNamingNoIndicatorKeepsTheStockOne() { + registerProbe() + showList(mapOf("on_refresh" to 7)) + + compose.onAllNodesWithTag(REFRESH_INDICATOR_TAG).assertCountEquals(0) + compose.onAllNodesWithTag("probe").assertCountEquals(0) + } + + @Test fun anIndicatorNoPluginRegisteredFallsBackToTheStockOne() { + registerProbe() + showList(mapOf("on_refresh" to 7, "refresh_indicator" to "missing")) + + compose.onAllNodesWithTag(REFRESH_INDICATOR_TAG).assertCountEquals(0) + compose.onAllNodesWithTag("probe").assertCountEquals(0) + } + + @Test fun theRegisteredIndicatorDrawsInsideTheDiscWithItsOptions() { + registerProbe() + showList(mapOf( + "on_refresh" to 7, + "refresh_indicator" to "probe", + "refresh_indicator_options" to """{"asset":"animations/splash.lottie","speed":1.5,"loop":true}""", + )) + + compose.onNodeWithTag("probe").assertExists() + val options = seen!!.options + assertEquals("animations/splash.lottie", options["asset"]) + assertEquals(1.5, (options["speed"] as Number).toDouble(), 0.0) + assertEquals(true, options["loop"]) + } + + @Test fun malformedOptionsReachTheIndicatorAsNone() { + registerProbe() + showList(mapOf("on_refresh" to 7, "refresh_indicator" to "probe", "refresh_indicator_options" to "not json")) + + compose.onNodeWithTag("probe").assertExists() + assertTrue(seen!!.options.isEmpty()) + } + + @Test fun theIndicatorFollowsThePullThenKnowsItIsRefreshing() { + registerProbe() + showList(mapOf("on_refresh" to 7, "refresh_indicator" to "probe")) + assertEquals(0f, seen!!.progress(), 0.001f) + assertFalse(seen!!.isRefreshing) + + compose.onNodeWithTag("list").performTouchInput { + down(Offset(centerX, 10f)) + repeat(5) { moveBy(Offset(0f, 10f)) } + } + compose.waitForIdle() + val pulled = seen!!.progress() + assertTrue("progress $pulled is part way", pulled > 0f && pulled < 1f) + + compose.mainClock.autoAdvance = false + compose.onNodeWithTag("list").performTouchInput { + repeat(10) { moveBy(Offset(0f, 40f)) } + up() + } + compose.mainClock.advanceTimeBy(100) + assertTrue(seen!!.isRefreshing) + } + + @Test fun theDiscRestsBelowTheTopOfTheListWhileRefreshing() { + registerProbe() + showList(mapOf("on_refresh" to 7, "refresh_indicator" to "probe")) + compose.mainClock.autoAdvance = false + compose.onNodeWithTag("list").performTouchInput { + down(Offset(centerX, 10f)) + repeat(10) { moveBy(Offset(0f, 40f)) } + up() + } + compose.mainClock.advanceTimeBy(1000) + + val list = compose.onNodeWithTag("list").getBoundsInRoot() + val disc = compose.onNodeWithTag(REFRESH_INDICATOR_TAG).getBoundsInRoot() + assertEquals(48f, (disc.right - disc.left).value, 0.5f) + assertEquals(48f, (disc.bottom - disc.top).value, 0.5f) + assertEquals(80f - 48f, (disc.top - list.top).value, 0.5f) + } + + private fun registerProbe() { + NativeUIRefreshIndicators.register("probe") { scope -> + seen = scope + Box(Modifier.testTag("probe")) + } + } + + private fun showList(props: Map) { + compose.setContent { + val rows = (0 until 30).map { index -> + NativeUINode(index + 100, "text", null, null, GenericProps(), 0, 0, emptyList()) + } + val node = NativeUINode(1, "list", null, null, GenericProps(props), 0, 0, rows) + MaterialTheme { + ListRenderer.Render(node, Modifier.width(300.dp).height(400.dp).testTag("list")) + } + } + } +} From e112f00482a6f40f44d787b59a32fd27e97e055b Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 09:19:31 +0100 Subject: [PATCH 03/12] feat(list): add the gesture logic of a custom iOS pull-to-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + resources/ios/NativeUIPullToRefresh.swift | 61 ++++++++++ tests/ios/Package.swift | 14 +++ tests/ios/README.md | 20 ++++ .../NativeUILogic/NativeUIPullToRefresh.swift | 1 + .../NativeUIPullToRefreshTests.swift | 107 ++++++++++++++++++ 6 files changed, 206 insertions(+) create mode 100644 resources/ios/NativeUIPullToRefresh.swift create mode 100644 tests/ios/Package.swift create mode 100644 tests/ios/README.md create mode 120000 tests/ios/Sources/NativeUILogic/NativeUIPullToRefresh.swift create mode 100644 tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift diff --git a/.gitignore b/.gitignore index 2d12b24..9e6fd15 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ composer.lock /tests/android/.gradle/ /tests/android/build/ /tests/android/local.properties +/tests/ios/.build/ +/tests/ios/.swiftpm/ +/tests/ios/Package.resolved diff --git a/resources/ios/NativeUIPullToRefresh.swift b/resources/ios/NativeUIPullToRefresh.swift new file mode 100644 index 0000000..93ef6bf --- /dev/null +++ b/resources/ios/NativeUIPullToRefresh.swift @@ -0,0 +1,61 @@ +/// The gesture behind a list's custom pull-to-refresh, used when a plugin +/// draws the indicator: SwiftUI's `.refreshable` only shows the system +/// spinner. Fed the list's overscroll at its top and whether a finger is on +/// it, it says how far the list is pulled, when a release starts a refresh, +/// and where the indicator goes in the gap the pull opens above the rows. +/// +/// A refresh keeps `margin` of room above the content until it ends, so the +/// indicator stays in view once the list settles back. +struct NativeUIPullToRefresh: Equatable { + /// How far the list is pulled to refresh when it's let go. + static let threshold: Double = 80 + /// The room kept above the content while refreshing. + static let restHeight: Double = 60 + + private(set) var isRefreshing = false + private var pull: Double = 0 + private var interacting = false + + /// How far the list is pulled, from 0 at rest to 1 at the threshold; 1 + /// while refreshing. + var progress: Double { + isRefreshing ? 1 : min(pull / Self.threshold, 1) + } + + /// The room to keep above the content. + var margin: Double { + isRefreshing ? Self.restHeight : 0 + } + + /// The height of the gap above the content, where the indicator draws. + var gap: Double { + pull + margin + } + + /// Where the indicator's top goes in the gap, for an indicator [size] + /// tall: centred in the gap as it opens, then held as near the top as + /// it sits while refreshing. + func indicatorTop(size: Double) -> Double { + min(gap, Self.restHeight) / 2 - size / 2 + } + + /// The list's top overscroll: how far its content sits below where it + /// rests, the room kept while refreshing included. + mutating func scrolled(overscroll: Double) { + pull = max(0, overscroll) + } + + /// Returns whether letting go starts a refresh. + @discardableResult + mutating func interactionChanged(interacting: Bool) -> Bool { + let released = self.interacting && !interacting + self.interacting = interacting + guard released, !isRefreshing, pull >= Self.threshold else { return false } + isRefreshing = true + return true + } + + mutating func finishRefresh() { + isRefreshing = false + } +} diff --git a/tests/ios/Package.swift b/tests/ios/Package.swift new file mode 100644 index 0000000..b66c8a5 --- /dev/null +++ b/tests/ios/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version:6.0 +import PackageDescription + +// Unit tests for the plain Swift logic behind mobile-ui's iOS renderers, the +// parts that need neither SwiftUI nor the core app. Sources are linked from +// resources/ios so the tests run the shipped code. +let package = Package( + name: "NativeUILogic", + platforms: [.macOS(.v15)], + targets: [ + .target(name: "NativeUILogic", path: "Sources/NativeUILogic"), + .testTarget(name: "NativeUILogicTests", dependencies: ["NativeUILogic"], path: "Tests/NativeUILogicTests"), + ] +) diff --git a/tests/ios/README.md b/tests/ios/README.md new file mode 100644 index 0000000..d55a9d9 --- /dev/null +++ b/tests/ios/README.md @@ -0,0 +1,20 @@ +# iOS logic unit tests + +This Swift package tests the plain Swift logic behind the iOS renderers: the +parts that need neither SwiftUI nor the core app. `Sources/NativeUILogic` +links the shipped files from `resources/ios`, so the tests run the code that +ships. It runs on macOS with Xcode 16 or later (Swift Testing). From this +repository: + +```bash +swift test --package-path tests/ios +``` + +`NativeUIPullToRefreshTests` drives the gesture behind a list's custom +pull-to-refresh (used when a plugin registers the indicator): the pull's +progress up to the threshold, a refresh starting only when a finger lets go +past it and never during another, the room kept above the content while it +runs, and where the indicator sits in the gap the pull opens. + +The SwiftUI wiring around it (scroll geometry, scroll phases, the overlay) +isn't covered here and needs testing on a simulator or device. diff --git a/tests/ios/Sources/NativeUILogic/NativeUIPullToRefresh.swift b/tests/ios/Sources/NativeUILogic/NativeUIPullToRefresh.swift new file mode 120000 index 0000000..7a80e13 --- /dev/null +++ b/tests/ios/Sources/NativeUILogic/NativeUIPullToRefresh.swift @@ -0,0 +1 @@ +../../../../resources/ios/NativeUIPullToRefresh.swift \ No newline at end of file diff --git a/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift b/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift new file mode 100644 index 0000000..8edb5b2 --- /dev/null +++ b/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift @@ -0,0 +1,107 @@ +import Testing +@testable import NativeUILogic + +@Suite struct NativeUIPullToRefreshTests { + @Test func restsHiddenWithNoProgress() { + let pull = NativeUIPullToRefresh() + + #expect(pull.progress == 0) + #expect(pull.gap == 0) + #expect(!pull.isRefreshing) + } + + @Test func progressFollowsThePullUpToTheThreshold() { + var pull = NativeUIPullToRefresh() + pull.interactionChanged(interacting: true) + + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold / 2) + #expect(pull.progress == 0.5) + #expect(pull.gap == NativeUIPullToRefresh.threshold / 2) + + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold * 2) + #expect(pull.progress == 1) + } + + @Test func scrollingDownTheListIsNoPull() { + var pull = NativeUIPullToRefresh() + pull.scrolled(overscroll: -300) + + #expect(pull.progress == 0) + #expect(pull.gap == 0) + } + + @Test func releasingPastTheThresholdStartsARefresh() { + var pull = NativeUIPullToRefresh() + pull.interactionChanged(interacting: true) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold + 10) + + let started = pull.interactionChanged(interacting: false) + #expect(started) + #expect(pull.isRefreshing) + #expect(pull.progress == 1) + } + + @Test func releasingShortOfTheThresholdDoesNot() { + var pull = NativeUIPullToRefresh() + pull.interactionChanged(interacting: true) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold - 1) + + let started = pull.interactionChanged(interacting: false) + #expect(!started) + #expect(!pull.isRefreshing) + } + + @Test func aBounceWithoutAFingerDoesNotRefresh() { + var pull = NativeUIPullToRefresh() + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold * 2) + + let started = pull.interactionChanged(interacting: false) + #expect(!started) + #expect(!pull.isRefreshing) + } + + @Test func aPullDuringARefreshStartsNoOther() { + var pull = NativeUIPullToRefresh() + pull.interactionChanged(interacting: true) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold * 2) + pull.interactionChanged(interacting: false) + + pull.interactionChanged(interacting: true) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold * 2) + let startedAgain = pull.interactionChanged(interacting: false) + #expect(!startedAgain) + #expect(pull.isRefreshing) + } + + @Test func theRefreshKeepsRoomAboveTheContentUntilItEnds() { + var pull = NativeUIPullToRefresh() + pull.interactionChanged(interacting: true) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold + 20) + pull.interactionChanged(interacting: false) + + // The room shifts the content's top down by its height, so the same + // spot reads that much less overscroll: the gap doesn't jump. + #expect(pull.margin == NativeUIPullToRefresh.restHeight) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold + 20 - NativeUIPullToRefresh.restHeight) + #expect(pull.gap == NativeUIPullToRefresh.threshold + 20) + + pull.scrolled(overscroll: 0) + #expect(pull.gap == NativeUIPullToRefresh.restHeight) + + pull.finishRefresh() + #expect(!pull.isRefreshing) + #expect(pull.margin == 0) + #expect(pull.gap == 0) + } + + @Test func theIndicatorDrawsInFromTheTopThenStaysNearIt() { + var pull = NativeUIPullToRefresh() + let size: Double = 40 + + pull.scrolled(overscroll: 10) + #expect(pull.indicatorTop(size: size) == 5 - 20) + + pull.scrolled(overscroll: NativeUIPullToRefresh.restHeight * 3) + #expect(pull.indicatorTop(size: size) == NativeUIPullToRefresh.restHeight / 2 - 20) + } +} From 65ac80567a1bc31c5ab94bfb7e4a5b93ef4df035 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 09:35:27 +0100 Subject: [PATCH 04/12] feat(list): let plugins register an iOS pull-to-refresh indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- resources/ios/NativeUIRefreshIndicators.swift | 55 +++++++++++++++++++ tests/ios/Package.swift | 4 +- .../NativeUIRefreshIndicators.swift | 1 + .../NativeUIRefreshIndicatorsTests.swift | 45 +++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 resources/ios/NativeUIRefreshIndicators.swift create mode 120000 tests/ios/Sources/NativeUILogic/NativeUIRefreshIndicators.swift create mode 100644 tests/ios/Tests/NativeUILogicTests/NativeUIRefreshIndicatorsTests.swift diff --git a/resources/ios/NativeUIRefreshIndicators.swift b/resources/ios/NativeUIRefreshIndicators.swift new file mode 100644 index 0000000..3846f97 --- /dev/null +++ b/resources/ios/NativeUIRefreshIndicators.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftUI + +/// What a registered pull-to-refresh indicator draws from. +struct NativeUIRefreshIndicatorScope { + /// How far the list is pulled, from 0 at rest to 1 at the refresh + /// threshold; 1 while refreshing. + let progress: Double + /// Whether the refresh the pull triggered is running. + let isRefreshing: Bool + /// The `refresh-indicator-options` the screen gave, decoded from JSON. + let options: [String: Any] +} + +/// Pull-to-refresh indicators other plugins provide, by the name a list's +/// `refresh-indicator` attribute gives. Register from the plugin's +/// `ios.init_function`, before the first render: +/// +/// NativeUIRefreshIndicators.shared.register("lottie") { scope in AnyView(…) } +/// +/// A list naming a registered indicator runs its own pull-to-refresh, since +/// `.refreshable` only shows the system spinner, and draws the indicator in +/// a 40pt square in the gap the pull opens above its rows. A list naming an +/// indicator nobody registered keeps `.refreshable`. +final class NativeUIRefreshIndicators { + typealias Indicator = (NativeUIRefreshIndicatorScope) -> AnyView + + static let shared = NativeUIRefreshIndicators() + + private var indicators: [String: Indicator] = [:] + private let lock = NSLock() + + func register(_ name: String, indicator: @escaping Indicator) { + lock.lock(); defer { lock.unlock() } + indicators[name] = indicator + } + + func indicator(named name: String) -> Indicator? { + lock.lock(); defer { lock.unlock() } + return name.isEmpty ? nil : indicators[name] + } + + func reset() { + lock.lock(); defer { lock.unlock() } + indicators = [:] + } +} + +/// A list's `refresh_indicator_options`, a JSON object; none when missing or +/// malformed. +func decodeRefreshIndicatorOptions(_ json: String) -> [String: Any] { + guard !json.isEmpty, let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } + return object +} diff --git a/tests/ios/Package.swift b/tests/ios/Package.swift index b66c8a5..324c8c1 100644 --- a/tests/ios/Package.swift +++ b/tests/ios/Package.swift @@ -10,5 +10,7 @@ let package = Package( targets: [ .target(name: "NativeUILogic", path: "Sources/NativeUILogic"), .testTarget(name: "NativeUILogicTests", dependencies: ["NativeUILogic"], path: "Tests/NativeUILogicTests"), - ] + ], + // The app builds its sources in Swift 5 mode. + swiftLanguageModes: [.v5] ) diff --git a/tests/ios/Sources/NativeUILogic/NativeUIRefreshIndicators.swift b/tests/ios/Sources/NativeUILogic/NativeUIRefreshIndicators.swift new file mode 120000 index 0000000..f23e1fc --- /dev/null +++ b/tests/ios/Sources/NativeUILogic/NativeUIRefreshIndicators.swift @@ -0,0 +1 @@ +../../../../resources/ios/NativeUIRefreshIndicators.swift \ No newline at end of file diff --git a/tests/ios/Tests/NativeUILogicTests/NativeUIRefreshIndicatorsTests.swift b/tests/ios/Tests/NativeUILogicTests/NativeUIRefreshIndicatorsTests.swift new file mode 100644 index 0000000..09f2c4d --- /dev/null +++ b/tests/ios/Tests/NativeUILogicTests/NativeUIRefreshIndicatorsTests.swift @@ -0,0 +1,45 @@ +import SwiftUI +import Testing +@testable import NativeUILogic + +@Suite(.serialized) struct NativeUIRefreshIndicatorsTests { + init() { NativeUIRefreshIndicators.shared.reset() } + + @Test func findsAnIndicatorByTheNameItWasRegisteredUnder() { + NativeUIRefreshIndicators.shared.register("probe") { _ in AnyView(EmptyView()) } + + #expect(NativeUIRefreshIndicators.shared.indicator(named: "probe") != nil) + } + + @Test func findsNoIndicatorNobodyRegistered() { + NativeUIRefreshIndicators.shared.register("probe") { _ in AnyView(EmptyView()) } + + #expect(NativeUIRefreshIndicators.shared.indicator(named: "missing") == nil) + #expect(NativeUIRefreshIndicators.shared.indicator(named: "") == nil) + } + + @Test func handsTheIndicatorItsScope() { + var seen: NativeUIRefreshIndicatorScope? + NativeUIRefreshIndicators.shared.register("probe") { scope in + seen = scope + return AnyView(EmptyView()) + } + + let options = decodeRefreshIndicatorOptions(#"{"asset":"animations/splash.lottie","speed":1.5,"loop":true}"#) + _ = NativeUIRefreshIndicators.shared.indicator(named: "probe")?( + NativeUIRefreshIndicatorScope(progress: 0.5, isRefreshing: false, options: options) + ) + + #expect(seen?.progress == 0.5) + #expect(seen?.isRefreshing == false) + #expect(seen?.options["asset"] as? String == "animations/splash.lottie") + #expect(seen?.options["speed"] as? Double == 1.5) + #expect(seen?.options["loop"] as? Bool == true) + } + + @Test func readsMissingOrMalformedOptionsAsNone() { + #expect(decodeRefreshIndicatorOptions("").isEmpty) + #expect(decodeRefreshIndicatorOptions("not json").isEmpty) + #expect(decodeRefreshIndicatorOptions("[1,2]").isEmpty) + } +} From 3a7b0950404c5b77dd29709d755dd1f3bf084e6a Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 09:35:27 +0100 Subject: [PATCH 05/12] feat(list): draw a registered refresh indicator on iOS lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .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 --- resources/ios/NativeUIListRenderer.swift | 82 ++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index a6480ec..745df7c 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -194,7 +194,9 @@ struct NativeUIListRenderer: View { // pull-to-refresh (spinner and all) on every list, including // static settings/forms where it does nothing. Android's renderer // already gates its PullToRefreshBox the same way. - .modifier(ListRefreshModifier(onRefreshCb: onRefreshCb, nodeId: nodeId)) + .modifier(ListRefreshModifier(onRefreshCb: onRefreshCb, nodeId: nodeId, + indicatorName: node.props.getString("refresh_indicator"), + indicatorOptions: node.props.getString("refresh_indicator_options"))) .modifier(ListScrollPhaseModifier(tracker: onScrollEndCb != 0 ? visibleRange : nil)) .modifier(NativeUIPagerHeaderListModifier(header: pagerHeader)) // Only this list draws the header, not one in its rows. @@ -789,23 +791,93 @@ struct ListBackgroundModifier: ViewModifier { } /// Conditionally attaches pull-to-refresh: only lists with an `on_refresh` -/// callback get the control; every other list scrolls plainly. +/// callback get the control; every other list scrolls plainly. A list +/// naming an indicator a plugin registered runs its own pull-to-refresh to +/// draw it; any other gets `.refreshable` and the system spinner. private struct ListRefreshModifier: ViewModifier { let onRefreshCb: Int let nodeId: Int + let indicatorName: String + let indicatorOptions: String func body(content: Content) -> some View { - if onRefreshCb != 0 { + if onRefreshCb == 0 { + content + } else if let indicator = NativeUIRefreshIndicators.shared.indicator(named: indicatorName) { + content.modifier(ListCustomRefreshModifier( + onRefreshCb: onRefreshCb, nodeId: nodeId, indicator: indicator, + options: decodeRefreshIndicatorOptions(indicatorOptions))) + } else { content.refreshable { NativeElementBridge.sendPressEvent(onRefreshCb, nodeId: nodeId) try? await Task.sleep(nanoseconds: 1_000_000_000) } - } else { - content } } } +/// Pull-to-refresh drawing a plugin's indicator, driven by +/// NativeUIPullToRefresh: the indicator draws in the gap the pull opens +/// above the rows, clipped to it, and a release past the threshold calls +/// `on_refresh` and keeps the indicator in view for as long as +/// `.refreshable` would. +private struct ListCustomRefreshModifier: ViewModifier { + let onRefreshCb: Int + let nodeId: Int + let indicator: NativeUIRefreshIndicators.Indicator + let options: [String: Any] + + @State private var pull = NativeUIPullToRefresh() + /// Where the rows' top rests, without the room a refresh keeps. + @State private var contentTop: CGFloat = 0 + + private static let indicatorSize: CGFloat = 40 + + func body(content: Content) -> some View { + content + .contentMargins(.top, pull.margin, for: .scrollContent) + .onScrollGeometryChange(for: ListTopEdge.self) { geometry in + ListTopEdge(inset: geometry.contentInsets.top, + overscroll: -(geometry.contentOffset.y + geometry.contentInsets.top)) + } action: { _, edge in + pull.scrolled(overscroll: edge.overscroll) + contentTop = edge.inset - pull.margin + } + .onScrollPhaseChange { _, phase in + if pull.interactionChanged(interacting: phase == .interacting) { + refresh() + } + } + .overlay(alignment: .top) { + if pull.gap > 0 { + indicator(NativeUIRefreshIndicatorScope(progress: pull.progress, isRefreshing: pull.isRefreshing, options: options)) + .frame(width: Self.indicatorSize, height: Self.indicatorSize) + .offset(y: pull.indicatorTop(size: Self.indicatorSize)) + .frame(maxWidth: .infinity) + .frame(height: pull.gap, alignment: .top) + .clipped() + .offset(y: contentTop) + .allowsHitTesting(false) + } + } + .sensoryFeedback(.impact(weight: .light), trigger: pull.isRefreshing) { _, refreshing in refreshing } + } + + private func refresh() { + NativeElementBridge.sendPressEvent(onRefreshCb, nodeId: nodeId) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_000_000_000) + withAnimation { pull.finishRefresh() } + } + } +} + +/// Where a list's rows start, and how far below that a pull has drawn them. +private struct ListTopEdge: Equatable { + let inset: CGFloat + let overscroll: CGFloat +} + /// The horizontal inset of a list row's content: the system's minimum /// layout margin (16pt on most iPhones, 20pt on the wide ones), which is /// where iOS puts section headers, footers and its own cell content. Rows From f8d92ba3ac3cbd2ef8d06c016d20cd877115b6d9 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 09:58:32 +0100 Subject: [PATCH 06/12] docs(list): document custom pull-to-refresh indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index de5f10a..6a20db4 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,45 @@ use Native\Mobile\UI\Elements\StickyHeader; StickyHeader::make($newPostsButton); ``` +### Custom pull-to-refresh indicators + +A list with `on-refresh` shows the platform spinner when pulled. `refresh-indicator` replaces it with an indicator a plugin provides, such as an animated logo, and `refresh-indicator-options` passes that indicator its settings: + +```blade + +``` + +```php +NativeList::make() + ->onRefresh('reload') + ->refreshIndicator('lottie', ['asset' => 'animations/logo.lottie']); +``` + +If no installed plugin provides that indicator, 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 of its own and adds no dependency for this. + +A plugin registers its indicator from its init function (`android.init_function` / `ios.init_function` in its `nativephp.json`). The indicator receives the pull's progress (`0` at rest, `1` at the refresh threshold), whether a refresh is running, and the options decoded from JSON: + +```kotlin +import com.nativephp.plugins.native_ui.NativeUIRefreshIndicators + +NativeUIRefreshIndicators.register("lottie") { scope -> + // scope.progress(), scope.isRefreshing, scope.options["asset"] +} +``` + +```swift +NativeUIRefreshIndicators.shared.register("lottie") { scope in + // scope.progress, scope.isRefreshing, scope.options["asset"] + AnyView(MyIndicator(scope: scope)) +} +``` + +On Android the indicator fills a 48dp disc styled like the stock indicator. The disc slides down with the pull and is clipped to the top of the list. On iOS, `.refreshable` can't show a custom indicator, so a list with a registered indicator runs its own pull-to-refresh: the indicator fills a 40pt square in the gap the pull opens above the rows. Only `native:list` supports this; `native:refreshable` keeps the platform spinner. + ## Popovers `` floats its children in a small bubble beside the spot it From c901bcd9beb4cc1e5fab98bc6a67bdd06d13ba9b Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 13:05:01 +0100 Subject: [PATCH 07/12] fix(list): keep the iOS custom refresh indicator in view while refreshing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- resources/ios/NativeUIListRenderer.swift | 14 ++++----- resources/ios/NativeUIPullToRefresh.swift | 15 +++++++++- .../NativeUIPullToRefreshTests.swift | 30 +++++++++++++++---- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index 745df7c..2b701da 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -828,8 +828,6 @@ private struct ListCustomRefreshModifier: ViewModifier { let options: [String: Any] @State private var pull = NativeUIPullToRefresh() - /// Where the rows' top rests, without the room a refresh keeps. - @State private var contentTop: CGFloat = 0 private static let indicatorSize: CGFloat = 40 @@ -837,11 +835,9 @@ private struct ListCustomRefreshModifier: ViewModifier { content .contentMargins(.top, pull.margin, for: .scrollContent) .onScrollGeometryChange(for: ListTopEdge.self) { geometry in - ListTopEdge(inset: geometry.contentInsets.top, - overscroll: -(geometry.contentOffset.y + geometry.contentInsets.top)) + ListTopEdge(inset: geometry.contentInsets.top, offset: geometry.contentOffset.y) } action: { _, edge in - pull.scrolled(overscroll: edge.overscroll) - contentTop = edge.inset - pull.margin + pull.scrolled(offset: edge.offset, inset: edge.inset) } .onScrollPhaseChange { _, phase in if pull.interactionChanged(interacting: phase == .interacting) { @@ -856,7 +852,7 @@ private struct ListCustomRefreshModifier: ViewModifier { .frame(maxWidth: .infinity) .frame(height: pull.gap, alignment: .top) .clipped() - .offset(y: contentTop) + .offset(y: pull.restTop) .allowsHitTesting(false) } } @@ -872,10 +868,10 @@ private struct ListCustomRefreshModifier: ViewModifier { } } -/// Where a list's rows start, and how far below that a pull has drawn them. +/// A list's top content inset and scroll offset. private struct ListTopEdge: Equatable { let inset: CGFloat - let overscroll: CGFloat + let offset: CGFloat } /// The horizontal inset of a list row's content: the system's minimum diff --git a/resources/ios/NativeUIPullToRefresh.swift b/resources/ios/NativeUIPullToRefresh.swift index 93ef6bf..4d0d87a 100644 --- a/resources/ios/NativeUIPullToRefresh.swift +++ b/resources/ios/NativeUIPullToRefresh.swift @@ -13,6 +13,9 @@ struct NativeUIPullToRefresh: Equatable { static let restHeight: Double = 60 private(set) var isRefreshing = false + /// Where the rows' top rests, below the bars, without the room a + /// refresh keeps. + private(set) var restTop: Double = 0 private var pull: Double = 0 private var interacting = false @@ -29,7 +32,7 @@ struct NativeUIPullToRefresh: Equatable { /// The height of the gap above the content, where the indicator draws. var gap: Double { - pull + margin + pull } /// Where the indicator's top goes in the gap, for an indicator [size] @@ -45,6 +48,16 @@ struct NativeUIPullToRefresh: Equatable { pull = max(0, overscroll) } + /// The list's scroll offset and top content inset. The inset is taken as + /// the rows' rest only outside a refresh, since it may or may not count + /// the room the refresh keeps. + mutating func scrolled(offset: Double, inset: Double) { + if !isRefreshing { + restTop = inset + } + scrolled(overscroll: -offset - restTop) + } + /// Returns whether letting go starts a refresh. @discardableResult mutating func interactionChanged(interacting: Bool) -> Bool { diff --git a/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift b/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift index 8edb5b2..22e67a1 100644 --- a/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift +++ b/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift @@ -79,21 +79,39 @@ import Testing pull.scrolled(overscroll: NativeUIPullToRefresh.threshold + 20) pull.interactionChanged(interacting: false) - // The room shifts the content's top down by its height, so the same - // spot reads that much less overscroll: the gap doesn't jump. #expect(pull.margin == NativeUIPullToRefresh.restHeight) - pull.scrolled(overscroll: NativeUIPullToRefresh.threshold + 20 - NativeUIPullToRefresh.restHeight) - #expect(pull.gap == NativeUIPullToRefresh.threshold + 20) - - pull.scrolled(overscroll: 0) + pull.scrolled(overscroll: NativeUIPullToRefresh.restHeight) #expect(pull.gap == NativeUIPullToRefresh.restHeight) pull.finishRefresh() #expect(!pull.isRefreshing) #expect(pull.margin == 0) + pull.scrolled(overscroll: 0) #expect(pull.gap == 0) } + @Test func measuresThePullFromWhereTheRowsRestBeforeARefresh() { + var pull = NativeUIPullToRefresh() + let inset: Double = 100 + pull.scrolled(offset: -inset, inset: inset) + #expect(pull.restTop == inset) + #expect(pull.gap == 0) + + pull.interactionChanged(interacting: true) + pull.scrolled(offset: -inset - NativeUIPullToRefresh.threshold - 20, inset: inset) + #expect(pull.gap == NativeUIPullToRefresh.threshold + 20) + pull.interactionChanged(interacting: false) + + // Whether the reported inset counts the room the refresh keeps or + // not, the rows settling on it read as that room. + let room = NativeUIPullToRefresh.restHeight + for reportedInset in [inset, inset + room] { + pull.scrolled(offset: -inset - room, inset: reportedInset) + #expect(pull.restTop == inset) + #expect(pull.gap == room) + } + } + @Test func theIndicatorDrawsInFromTheTopThenStaysNearIt() { var pull = NativeUIPullToRefresh() let size: Double = 40 From e49e51bbe386b967c427b39c01500dedf3cf1cc4 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 17:20:35 +0100 Subject: [PATCH 08/12] fix(list): keep the iOS refresh room as a safe area inset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- resources/ios/NativeUIListRenderer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index 2b701da..54a1bf2 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -833,7 +833,7 @@ private struct ListCustomRefreshModifier: ViewModifier { func body(content: Content) -> some View { content - .contentMargins(.top, pull.margin, for: .scrollContent) + .safeAreaPadding(.top, pull.margin) .onScrollGeometryChange(for: ListTopEdge.self) { geometry in ListTopEdge(inset: geometry.contentInsets.top, offset: geometry.contentOffset.y) } action: { _, edge in From 0f4c77ad3deeb5eb1473b73ee1b403e7727983c1 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 17:47:16 +0100 Subject: [PATCH 09/12] feat(list): mark the Android refresh threshold with a bounce and a buzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/NativeUIRefreshIndicators.kt | 38 +++++++++++- .../test/kotlin/ListRefreshIndicatorTest.kt | 61 ++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/resources/android/NativeUIRefreshIndicators.kt b/resources/android/NativeUIRefreshIndicators.kt index adace21..7dc0e87 100644 --- a/resources/android/NativeUIRefreshIndicators.kt +++ b/resources/android/NativeUIRefreshIndicators.kt @@ -1,5 +1,7 @@ package com.nativephp.plugins.native_ui +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize @@ -9,9 +11,15 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import org.json.JSONArray @@ -66,6 +74,10 @@ private val REFRESH_INDICATOR_SIZE = 48.dp private val REFRESH_INDICATOR_PADDING = 6.dp +/** The bounce marking a pull past the threshold: up to this scale and back. */ +private const val REFRESH_INDICATOR_BOUNCE_SCALE = 1.15f +private const val REFRESH_INDICATOR_BOUNCE_MILLIS = 200 + /** * The indicator a list's `refresh_indicator` names, on the stock disc, or * the stock indicator when no plugin registered that name. @@ -89,6 +101,23 @@ internal fun BoxScope.NativeUIRefreshIndicator( } val decoded = remember(options) { decodeRefreshIndicatorOptions(options) } + // PullToRefreshBox refreshes on a release past the threshold, where the + // distance fraction passes 1. + val armed by remember(state) { derivedStateOf { state.distanceFraction > 1f } } + val haptic = LocalHapticFeedback.current + val scale = remember { Animatable(1f) } + LaunchedEffect(armed && !isRefreshing) { + if (armed && !isRefreshing) { + haptic.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + scale.animateTo(1f, keyframes { + durationMillis = REFRESH_INDICATOR_BOUNCE_MILLIS + REFRESH_INDICATOR_BOUNCE_SCALE at REFRESH_INDICATOR_BOUNCE_MILLIS * 2 / 5 + }) + } else { + scale.snapTo(1f) + } + } + PullToRefreshDefaults.IndicatorBox( state = state, isRefreshing = isRefreshing, @@ -97,7 +126,14 @@ internal fun BoxScope.NativeUIRefreshIndicator( ) { // Inside the box, so its bounds follow the disc as it slides. Box( - Modifier.fillMaxSize().testTag(REFRESH_INDICATOR_TAG).padding(REFRESH_INDICATOR_PADDING), + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale.value + scaleY = scale.value + } + .testTag(REFRESH_INDICATOR_TAG) + .padding(REFRESH_INDICATOR_PADDING), contentAlignment = Alignment.Center, ) { content(RefreshIndicatorScope({ state.distanceFraction.coerceIn(0f, 1f) }, isRefreshing, decoded)) diff --git a/tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt b/tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt index 10e1d64..b99aafe 100644 --- a/tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt +++ b/tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt @@ -2,10 +2,15 @@ package com.nativephp.plugins.native_ui.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule @@ -114,21 +119,71 @@ class ListRefreshIndicatorTest { assertEquals(80f - 48f, (disc.top - list.top).value, 0.5f) } + @Test fun crossingTheThresholdWhilePullingBuzzesOnceEachTime() { + registerProbe() + showList(mapOf("on_refresh" to 7, "refresh_indicator" to "probe")) + compose.onNodeWithTag("list").performTouchInput { + down(Offset(centerX, 10f)) + repeat(10) { moveBy(Offset(0f, 40f)) } + } + compose.waitForIdle() + assertEquals(listOf(HapticFeedbackType.GestureThresholdActivate), haptics) + + compose.onNodeWithTag("list").performTouchInput { repeat(8) { moveBy(Offset(0f, -40f)) } } + compose.waitForIdle() + assertTrue("progress ${seen!!.progress()} is back under the threshold", seen!!.progress() < 1f) + assertEquals(1, haptics.size) + + compose.onNodeWithTag("list").performTouchInput { + repeat(10) { moveBy(Offset(0f, 40f)) } + up() + } + compose.waitForIdle() + assertEquals(2, haptics.size) + } + + @Test fun theIndicatorBouncesAsTheThresholdIsCrossed() { + registerProbe() + showList(mapOf("on_refresh" to 7, "refresh_indicator" to "probe")) + val rest = probeWidth() + compose.mainClock.autoAdvance = false + compose.onNodeWithTag("list").performTouchInput { + down(Offset(centerX, 10f)) + repeat(10) { moveBy(Offset(0f, 40f)) } + } + + compose.mainClock.advanceTimeBy(80) + assertTrue("width ${probeWidth()} grows past $rest", probeWidth() > rest * 1.05f) + compose.mainClock.advanceTimeBy(400) + assertEquals(rest, probeWidth(), 0.5f) + } + + private fun probeWidth(): Float = compose.onNodeWithTag("probe").getBoundsInRoot().let { (it.right - it.left).value } + private fun registerProbe() { NativeUIRefreshIndicators.register("probe") { scope -> seen = scope - Box(Modifier.testTag("probe")) + Box(Modifier.size(20.dp).testTag("probe")) } } + private val haptics = mutableListOf() + private fun showList(props: Map) { compose.setContent { val rows = (0 until 30).map { index -> NativeUINode(index + 100, "text", null, null, GenericProps(), 0, 0, emptyList()) } val node = NativeUINode(1, "list", null, null, GenericProps(props), 0, 0, rows) - MaterialTheme { - ListRenderer.Render(node, Modifier.width(300.dp).height(400.dp).testTag("list")) + val haptic = object : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + haptics += hapticFeedbackType + } + } + CompositionLocalProvider(LocalHapticFeedback provides haptic) { + MaterialTheme { + ListRenderer.Render(node, Modifier.width(300.dp).height(400.dp).testTag("list")) + } } } } From 358c823fa851464aadac320d05453c41827c8815 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 17:53:37 +0100 Subject: [PATCH 10/12] feat(list): mark the iOS refresh threshold with a bounce and a buzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- resources/ios/NativeUIListRenderer.swift | 10 ++++- resources/ios/NativeUIPullToRefresh.swift | 20 ++++++++- .../NativeUIPullToRefreshTests.swift | 43 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index 54a1bf2..361da27 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -830,6 +830,8 @@ private struct ListCustomRefreshModifier: ViewModifier { @State private var pull = NativeUIPullToRefresh() private static let indicatorSize: CGFloat = 40 + /// The bounce marking a pull past the threshold: up to this scale and back. + private static let bounceScale: CGFloat = 1.15 func body(content: Content) -> some View { content @@ -848,6 +850,12 @@ private struct ListCustomRefreshModifier: ViewModifier { if pull.gap > 0 { indicator(NativeUIRefreshIndicatorScope(progress: pull.progress, isRefreshing: pull.isRefreshing, options: options)) .frame(width: Self.indicatorSize, height: Self.indicatorSize) + .keyframeAnimator(initialValue: 1.0, trigger: pull.crossings) { view, scale in + view.scaleEffect(scale) + } keyframes: { _ in + CubicKeyframe(Self.bounceScale, duration: 0.08) + SpringKeyframe(1.0, duration: 0.12) + } .offset(y: pull.indicatorTop(size: Self.indicatorSize)) .frame(maxWidth: .infinity) .frame(height: pull.gap, alignment: .top) @@ -856,7 +864,7 @@ private struct ListCustomRefreshModifier: ViewModifier { .allowsHitTesting(false) } } - .sensoryFeedback(.impact(weight: .light), trigger: pull.isRefreshing) { _, refreshing in refreshing } + .sensoryFeedback(.impact(weight: .medium), trigger: pull.crossings) } private func refresh() { diff --git a/resources/ios/NativeUIPullToRefresh.swift b/resources/ios/NativeUIPullToRefresh.swift index 4d0d87a..f8c84cd 100644 --- a/resources/ios/NativeUIPullToRefresh.swift +++ b/resources/ios/NativeUIPullToRefresh.swift @@ -16,8 +16,24 @@ struct NativeUIPullToRefresh: Equatable { /// Where the rows' top rests, below the bars, without the room a /// refresh keeps. private(set) var restTop: Double = 0 + /// How many times a pull has crossed the threshold, to mark each + /// crossing with a bounce and a buzz. + private(set) var crossings = 0 private var pull: Double = 0 - private var interacting = false + private var interacting = false { + didSet { countCrossing(wasArmed: oldValue && !isRefreshing && pull >= Self.threshold) } + } + + /// Whether letting go now would start a refresh. + var isArmed: Bool { + interacting && !isRefreshing && pull >= Self.threshold + } + + private mutating func countCrossing(wasArmed: Bool) { + if isArmed && !wasArmed { + crossings += 1 + } + } /// How far the list is pulled, from 0 at rest to 1 at the threshold; 1 /// while refreshing. @@ -45,7 +61,9 @@ struct NativeUIPullToRefresh: Equatable { /// The list's top overscroll: how far its content sits below where it /// rests, the room kept while refreshing included. mutating func scrolled(overscroll: Double) { + let wasArmed = isArmed pull = max(0, overscroll) + countCrossing(wasArmed: wasArmed) } /// The list's scroll offset and top content inset. The inset is taken as diff --git a/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift b/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift index 22e67a1..b13a99f 100644 --- a/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift +++ b/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift @@ -112,6 +112,49 @@ import Testing } } + @Test func theProgressIsFullRightWhereALetGoRefreshes() { + var pull = NativeUIPullToRefresh() + pull.interactionChanged(interacting: true) + + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold - 1) + #expect(pull.progress < 1) + #expect(!pull.isArmed) + + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold) + #expect(pull.progress == 1) + #expect(pull.isArmed) + let started = pull.interactionChanged(interacting: false) + #expect(started) + } + + @Test func countsEachCrossingOfTheThresholdWhilePulling() { + var pull = NativeUIPullToRefresh() + pull.interactionChanged(interacting: true) + #expect(pull.crossings == 0) + + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold + 5) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold + 30) + #expect(pull.crossings == 1) + + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold - 5) + #expect(!pull.isArmed) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold + 5) + #expect(pull.crossings == 2) + + pull.interactionChanged(interacting: false) + #expect(!pull.isArmed) + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold * 2) + #expect(pull.crossings == 2) + } + + @Test func aBounceWithoutAFingerCrossesNothing() { + var pull = NativeUIPullToRefresh() + pull.scrolled(overscroll: NativeUIPullToRefresh.threshold * 2) + + #expect(!pull.isArmed) + #expect(pull.crossings == 0) + } + @Test func theIndicatorDrawsInFromTheTopThenStaysNearIt() { var pull = NativeUIPullToRefresh() let size: Double = 40 From 6b91645be5f773827ee8c4b397ebe0db987fb09a Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 1 Nov 2026 17:53:37 +0100 Subject: [PATCH 11/12] docs(list): describe the refresh threshold's bounce and haptic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Généré avec Claude Code --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6a20db4..335065b 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,8 @@ NativeUIRefreshIndicators.shared.register("lottie") { scope in On Android the indicator fills a 48dp disc styled like the stock indicator. The disc slides down with the pull and is clipped to the top of the list. On iOS, `.refreshable` can't show a custom indicator, so a list with a registered indicator runs its own pull-to-refresh: the indicator fills a 40pt square in the gap the pull opens above the rows. Only `native:list` supports this; `native:refreshable` keeps the platform spinner. +The progress reaches `1` exactly where letting go starts a refresh, so an indicator that is complete at `1` tells the user they can let go. When a pull crosses the threshold, mobile-ui also gives a short bounce to the indicator and a haptic tap, as the platform spinners do. It does this again each time the pull goes back below the threshold and crosses it again. + ## Popovers `` floats its children in a small bubble beside the spot it From fcedb3145679c9d1982cfc9773a5154ca0874e08 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Tue, 3 Nov 2026 21:49:42 +0100 Subject: [PATCH 12/12] docs(list): list refresh-indicator in the Boost skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Généré avec Claude Code --- resources/boost/skills/nativephp-mobile-ui/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/boost/skills/nativephp-mobile-ui/SKILL.md b/resources/boost/skills/nativephp-mobile-ui/SKILL.md index ca9a795..a5e3d48 100644 --- a/resources/boost/skills/nativephp-mobile-ui/SKILL.md +++ b/resources/boost/skills/nativephp-mobile-ui/SKILL.md @@ -25,7 +25,7 @@ dropped without an error. | `text` (slot is the text) | `class` (`text-lg font-bold line-through underline text-theme-on-surface`), `font` | | `button` | label as slot or `label`; `variant` primary\|secondary\|destructive\|success\|ghost; `size` sm\|md\|lg; `icon`, `icon-trailing`, `ios-icon`, `android-icon`, `disabled`, `loading`, `a11y-label` | | `outlined-text-input`, `filled-text-input`, `bare-text-input` | `native:model`, `placeholder`, `label`, `@submit`, `@change`, `keyboard` (text\|number\|decimal\|email\|phone\|url\|password), `secure`, `revealable`, `multiline`, `max-length`, `autofocus`, `leading-icon`, `trailing-icon`, `error`, `supporting`, `@selectionChange`, `:selection` | -| `list` | `separator`, `plain`, `horizontal`, `on-refresh`, `on-end-reached`, `auto-scroll-to` (row to open at). Children: `list-item` or any element | +| `list` | `separator`, `plain`, `horizontal`, `on-refresh`, `refresh-indicator` (name a plugin registered, keeps the platform spinner otherwise) with `:refresh-indicator-options` (array), `on-end-reached`, `auto-scroll-to` (row to open at). Children: `list-item` or any element | | `list-item` | see below | | `popover` | `:visible`, `@dismiss`, `placement` top\|bottom. Children are the bubble. Lay it over its anchor in a stack with `absolute top-0 bottom-0 left-0 right-0` | | `icon` | `name` (`star.fill`, `trash`, `plus`), or per platform `ios="checkmark.circle.fill" android="check_circle"`, `:size="20"`, `class="text-theme-primary"` |