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/README.md b/README.md index de5f10a..335065b 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,47 @@ 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. + +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 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..7dc0e87 --- /dev/null +++ b/resources/android/NativeUIRefreshIndicators.kt @@ -0,0 +1,161 @@ +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 +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.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 +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 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. + */ +@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) } + // 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, + 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() + .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)) + } + } +} + +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/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"` | diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index a6480ec..361da27 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,97 @@ 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() + + 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 + .safeAreaPadding(.top, pull.margin) + .onScrollGeometryChange(for: ListTopEdge.self) { geometry in + ListTopEdge(inset: geometry.contentInsets.top, offset: geometry.contentOffset.y) + } action: { _, edge in + pull.scrolled(offset: edge.offset, inset: edge.inset) + } + .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) + .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) + .clipped() + .offset(y: pull.restTop) + .allowsHitTesting(false) + } + } + .sensoryFeedback(.impact(weight: .medium), trigger: pull.crossings) + } + + private func refresh() { + NativeElementBridge.sendPressEvent(onRefreshCb, nodeId: nodeId) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_000_000_000) + withAnimation { pull.finishRefresh() } + } + } +} + +/// A list's top content inset and scroll offset. +private struct ListTopEdge: Equatable { + let inset: CGFloat + let offset: 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 diff --git a/resources/ios/NativeUIPullToRefresh.swift b/resources/ios/NativeUIPullToRefresh.swift new file mode 100644 index 0000000..f8c84cd --- /dev/null +++ b/resources/ios/NativeUIPullToRefresh.swift @@ -0,0 +1,92 @@ +/// 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 + /// 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 { + 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. + 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 + } + + /// 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) { + let wasArmed = isArmed + pull = max(0, overscroll) + countCrossing(wasArmed: wasArmed) + } + + /// 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 { + 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/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/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'); +}); 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..b99aafe --- /dev/null +++ b/tests/android/src/test/kotlin/ListRefreshIndicatorTest.kt @@ -0,0 +1,190 @@ +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 +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) + } + + @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.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) + 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")) + } + } + } + } +} diff --git a/tests/ios/Package.swift b/tests/ios/Package.swift new file mode 100644 index 0000000..324c8c1 --- /dev/null +++ b/tests/ios/Package.swift @@ -0,0 +1,16 @@ +// 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"), + ], + // The app builds its sources in Swift 5 mode. + swiftLanguageModes: [.v5] +) 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/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/NativeUIPullToRefreshTests.swift b/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift new file mode 100644 index 0000000..b13a99f --- /dev/null +++ b/tests/ios/Tests/NativeUILogicTests/NativeUIPullToRefreshTests.swift @@ -0,0 +1,168 @@ +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) + + #expect(pull.margin == NativeUIPullToRefresh.restHeight) + 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 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 + + 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) + } +} 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) + } +}