Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<native:list
on-refresh="reload"
refresh-indicator="lottie"
:refresh-indicator-options="['asset' => 'animations/logo.lottie']"
>
```

```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

`<native:popover>` floats its children in a small bubble beside the spot it
Expand Down
11 changes: 10 additions & 1 deletion resources/android/ListRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
}
Expand Down
161 changes: 161 additions & 0 deletions resources/android/NativeUIRefreshIndicators.kt
Original file line number Diff line number Diff line change
@@ -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<String, Any?>,
)

/**
* 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<String, @Composable (RefreshIndicatorScope) -> 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<String, Any?> {
if (json.isEmpty()) return emptyMap()
return try {
jsonObjectToMap(JSONObject(json))
} catch (_: JSONException) {
emptyMap()
}
}

private fun jsonObjectToMap(json: JSONObject): Map<String, Any?> =
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
}
2 changes: 1 addition & 1 deletion resources/boost/skills/nativephp-mobile-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"` |
Expand Down
86 changes: 81 additions & 5 deletions resources/ios/NativeUIListRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading