Skip to content
Merged
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
8 changes: 8 additions & 0 deletions nativephp.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,14 @@
"android_renderer": "com.nativephp.plugins.native_ui.ui.EmptyRenderer",
"ios_renderer": "NativeUIEmptyRenderer",
"self_closing": false
},
{
"type": "sheet_pane",
"element": "Native\\Mobile\\UI\\Elements\\SheetPane",
"blade": "Native\\Mobile\\UI\\Components\\SheetPane",
"ios_renderer": "NativeUISheetPaneRenderer",
"self_closing": false,
"android_renderer": "com.nativephp.plugins.native_ui.ui.SheetPaneRenderer"
}
],
"bridge_functions": [
Expand Down
17 changes: 15 additions & 2 deletions resources/android/BottomSheetRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -39,24 +41,35 @@ object BottomSheetRenderer {
val onDismissCb = p.getCallbackId("on_dismiss")
val detentsStr = p.getString("detents", "medium,large")
val a11yLabel = p.getString("a11y_label")
// Counterpart of iOS `.interactiveDismissDisabled`: block drag-to-hide
// and back-press, and swallow scrim-tap dismiss requests. NOTE:
// `background_interaction` has no Android counterpart here — M3's
// ModalBottomSheet is a modal window, so the scrim always intercepts
// touches; a Maps-style pane over a live background is what
// `sheet_pane` is for.
val permanent = p.getBool("permanent")

if (!visible) return

val theme = if (isSystemInDarkTheme()) NativeUITheme.dark else NativeUITheme.light

val skipPartial = !hasPartialDetent(detentsStr)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartial)
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = skipPartial,
confirmValueChange = { value -> !(permanent && value == SheetValue.Hidden) },
)

val sheetModifier = modifier
.let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m }

ModalBottomSheet(
onDismissRequest = {
if (onDismissCb != 0) {
if (!permanent && onDismissCb != 0) {
NativeUIBridge.sendSheetDismissEvent(onDismissCb, node.id)
}
},
sheetState = sheetState,
properties = ModalBottomSheetProperties(shouldDismissOnBackPress = !permanent),
containerColor = theme.surface,
contentColor = theme.onSurface,
scrimColor = BottomSheetDefaults.ScrimColor,
Expand Down
158 changes: 158 additions & 0 deletions resources/android/SheetPaneRenderer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.nativephp.plugins.native_ui.ui

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.nativephp.mobile.ui.nativerender.AlignItems
import com.nativephp.mobile.ui.nativerender.FlexContainer
import com.nativephp.mobile.ui.nativerender.FlexDirection
import com.nativephp.mobile.ui.nativerender.JustifyContent
import com.nativephp.mobile.ui.nativerender.NativeUIBridge
import com.nativephp.mobile.ui.nativerender.NativeUINode
import com.nativephp.plugins.native_ui.NativeUITheme
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.floor

/**
* Inline draggable bottom pane (`sheet_pane`) — Compose counterpart of the
* iOS renderer. Bottom-anchored rounded pane that tracks vertical drags
* continuously and spring-settles to the nearest detent, seeded with the
* release velocity so flings feel continuous. The settled detent (dp) is
* reported through the element's `on_change` callback; PHP republishes it
* as the `detent` prop, which only moves the pane when the value actually
* changes.
*
* Content is laid out ONCE at the tallest detent (reveal model) so nothing
* reflows mid-drag; the animated frame clips it. Children go through
* FlexContainer so EDGE flex semantics (a scroll-view's flex-1) behave as
* in a regular column — and inner scrollables consume drags within their
* bounds, so dragging the header moves the pane while dragging a list
* scrolls it.
*/
object SheetPaneRenderer {
@Composable
fun Render(node: NativeUINode, modifier: Modifier) {
val p = node.props
val theme = if (isSystemInDarkTheme()) NativeUITheme.dark else NativeUITheme.light
val density = LocalDensity.current

val detents = remember(p.getString("detents", "")) {
p.getString("detents", "200,560,780")
.split(",")
.mapNotNull { it.trim().toFloatOrNull() }
.sorted()
.ifEmpty { listOf(200f, 560f) }
}
val maxDetent = detents.last()
val cornerRadius = p.getFloat("corner_radius", 44f)
val insetX = p.getFloat("inset_x", 8f)
val insetBottom = p.getFloat("inset_bottom", 8f)
val detentProp = p.getFloat("detent", detents.first())
val changeCb = p.getCallbackId("on_change")

val height = remember { Animatable(detentProp.coerceIn(detents.first(), maxDetent)) }
val appliedDetent = remember { mutableFloatStateOf(detentProp) }
val scope = rememberCoroutineScope()

// Only a genuinely NEW PHP-side detent moves the pane — republished
// frames with the value we just reported are ignored.
LaunchedEffect(detentProp) {
if (detentProp != appliedDetent.floatValue) {
appliedDetent.floatValue = detentProp
height.animateTo(detentProp, spring(dampingRatio = 0.8f, stiffness = 300f))
}
}

Box(modifier.fillMaxSize()) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(start = insetX.dp, end = insetX.dp, bottom = insetBottom.dp)
.fillMaxWidth()
.height(height.value.dp)
.clip(RoundedCornerShape(cornerRadius.dp))
.background(theme.background)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { deltaPx ->
val deltaDp = with(density) { deltaPx.toDp().value }
val proposed = height.value - deltaDp
scope.launch { height.snapTo(rubberBand(proposed, detents)) }
},
onDragStopped = { velocityPx ->
// Upward fling (negative y velocity) grows the pane.
val velocityDp = with(density) { -velocityPx.toDp().value }
val projected = height.value + velocityDp * 0.15f
val target = detents.minByOrNull { abs(it - projected) } ?: height.value

val settledElsewhere = target != appliedDetent.floatValue
appliedDetent.floatValue = target
scope.launch {
height.animateTo(
target,
spring(dampingRatio = 0.8f, stiffness = 300f),
initialVelocity = velocityDp
)
}
// Only report a detent CHANGE — snapping back to
// the detent we started from is not one. Report
// fractional detents faithfully: truncating
// `560.5` to `560` fails the applied-detent
// equality on the republish and nudges the pane
// a frame after every drag.
if (changeCb != 0 && settledElsewhere) {
val text = if (target == floor(target)) target.toInt().toString() else target.toString()
NativeUIBridge.sendTextChangeEvent(changeCb, node.id, text)
}
}
)
) {
// Reveal model: content at the tallest detent, clipped above.
FlexContainer(
direction = FlexDirection.COLUMN,
justify = JustifyContent.START,
align = AlignItems.STRETCH,
gap = 0f,
wrap = 0,
childNodes = node.children,
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.height(maxDetent.dp)
) {}
}
}
}

/** Soft overshoot past the outermost detents instead of a hard stop. */
private fun rubberBand(proposed: Float, detents: List<Float>): Float {
val lo = detents.first()
val hi = detents.last()
return when {
proposed > hi -> hi + (proposed - hi) * 0.25f
proposed < lo -> lo - (lo - proposed) * 0.25f
else -> proposed
}
}
}
6 changes: 6 additions & 0 deletions resources/ios/NativeUIBottomSheetRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ struct NativeUIBottomSheetRenderer: View {
let onDismissCb = node.props.getCallbackId("on_dismiss")
let detentsStr = node.props.getString("detents", default: "medium,large")
let a11yLabel = node.props.getString("a11y_label")
// Flighty/Maps-style always-on panel: no swipe-away, and the view
// behind stays interactive (HIG "sheets with interaction behind").
let permanent = node.props.getBool("permanent")
let bgInteraction = node.props.getBool("background_interaction")

Color.clear.frame(width: 0, height: 0)
.sheet(isPresented: $isPresented, onDismiss: {
Expand All @@ -35,6 +39,8 @@ struct NativeUIBottomSheetRenderer: View {
.background(theme.surface)
.presentationDetents(resolveDetents(detentsStr))
.presentationDragIndicator(.visible)
.interactiveDismissDisabled(permanent)
.presentationBackgroundInteraction(bgInteraction ? .enabled : .automatic)
.modifier(A11yLabelModifier(label: a11yLabel))
}
.onAppear { isPresented = visible }
Expand Down
Loading
Loading