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 @@ -415,6 +415,14 @@
"android_renderer": "com.nativephp.plugins.native_ui.ui.EmptyRenderer",
"ios_renderer": "NativeUIEmptyRenderer",
"self_closing": false
},
{
"type": "floating_overlay",
"element": "Nativephp\\NativeUi\\Elements\\FloatingOverlay",
"blade": "Nativephp\\NativeUi\\Components\\FloatingOverlay",
"android_renderer": "com.nativephp.plugins.native_ui.ui.EmptyRenderer",
"ios_renderer": "NativeUIEmptyRenderer",
"self_closing": false
}
],
"bridge_functions": [
Expand Down
6 changes: 5 additions & 1 deletion resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.nativephp.plugins.native_ui.ui

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.LocalTextStyle
Expand Down Expand Up @@ -82,7 +83,10 @@ object BareTextInputRenderer {
lastSentValue = newText
props.dispatchChange?.invoke(newText)
},
modifier = modifier,
// Full width by default (parity with the iOS renderer's
// maxWidth: .infinity); an explicit width in `modifier` (FIXED
// layout mode) still wins since it comes later in the chain.
modifier = Modifier.fillMaxWidth().then(modifier),
enabled = !props.disabled,
readOnly = props.readOnly,
textStyle = LocalTextStyle.current.copy(
Expand Down
40 changes: 39 additions & 1 deletion resources/android/ContainerRenderers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
Expand All @@ -27,6 +28,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -289,6 +291,17 @@ object LazyGridRenderer {
}
}

/** Recursive descendant count — a cheap content signal that changes whenever a
* message is added anywhere in a scroll-view's subtree (even inside a wrapping
* <column>), used to re-trigger stick-to-bottom scrolling. */
private fun totalDescendants(node: NativeUINode): Int {
var count = node.children.size
for (child in node.children) {
count += totalDescendants(child)
}
return count
}

object ScrollViewRenderer {
@Composable
fun Render(node: NativeUINode, modifier: Modifier) {
Expand All @@ -305,7 +318,32 @@ object ScrollViewRenderer {
}
}
} else {
LazyColumn(modifier = scrollModifier) {
// Chat-style bottom anchoring (`scroll-anchor="bottom"`): open at
// the bottom and follow new content. Keyed on the recursive
// descendant count (not direct children) so it fires even when
// messages sit inside a wrapping <column> — a common chat layout
// where the scroll-view has a single child. Scrolling to the last
// item with a max offset lands at the very bottom regardless of how
// the content is nested. Hooks are called unconditionally to satisfy
// Compose's rules; the work is gated on the prop.
val stickBottom = node.props.getString("scroll_anchor", "") == "bottom"
val listState = rememberLazyListState()
val didInitialScroll = remember { mutableStateOf(false) }
val contentSignal = if (stickBottom) totalDescendants(node) else 0

LaunchedEffect(stickBottom, contentSignal) {
if (stickBottom && node.children.isNotEmpty()) {
val lastIndex = node.children.size - 1
if (!didInitialScroll.value) {
didInitialScroll.value = true
listState.scrollToItem(lastIndex, Int.MAX_VALUE) // jump on open
} else {
listState.animateScrollToItem(lastIndex, Int.MAX_VALUE)
}
}
}

LazyColumn(modifier = scrollModifier, state = listState) {
items(node.children, key = { it.id }) { child ->
NodeView(node = child)
}
Expand Down
6 changes: 5 additions & 1 deletion resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.CircularProgressIndicator
Expand Down Expand Up @@ -88,7 +89,10 @@ object FilledTextInputRenderer {
text = filtered
dispatcher.onTextChanged(filtered)
},
modifier = modifier.nuiA11y(props.a11yLabel, props.a11yHint),
// Full width by default (parity with the iOS renderer's
// maxWidth: .infinity); an explicit width in `modifier` (FIXED
// layout mode) still wins since it comes later in the chain.
modifier = Modifier.fillMaxWidth().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint),
enabled = props.enabled,
readOnly = props.readOnly,
interactionSource = interactionSource,
Expand Down
72 changes: 72 additions & 0 deletions resources/android/NativeFloatingOverlayHost.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.nativephp.plugins.native_ui.ui

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.nativephp.mobile.ui.nativerender.NativeUINode
import com.nativephp.mobile.ui.nativerender.RenderNode

/**
* Hosts the content-agnostic floating overlay (`floating_overlay`). Unlike a
* bottom bar it does NOT inset the content — the overlay floats on a top layer
* (a [Box]), so the screen beneath is untouched and the pill hovers above it
* (and above the tab bar).
*
* Registered on core's `NativeRootHostRegistry` from this plugin's init
* function ([registerNativeUIChrome]); core folds it around the rendered tree.
* The overlay content is arbitrary — its children render through the generic
* [RenderNode]. When `overlayNode` is null this is a transparent pass-through.
*
* Placement (from the element's props):
* - `alignment` → `bottom` (above the tab bar, default) or `top`.
* - `offset` → extra dp between the overlay and the aligned edge on top of
* the system-bar inset; unset (0) → a default that clears a tab bar.
*/
@Composable
fun NativeFloatingOverlayHost(
overlayNode: NativeUINode?,
content: @Composable () -> Unit,
) {
if (overlayNode == null) {
content()
return
}

val isTop = overlayNode.props.getString("alignment", "bottom") == "top"
// getInt returns 0 for an absent key; the builder only ever emits a
// positive offset, so 0 means "unset" → use the default clearance.
val rawOffset = overlayNode.props.getInt("offset", 0)
val clearance = (if (rawOffset > 0) rawOffset else if (isTop) DEFAULT_TOP_CLEARANCE else DEFAULT_BOTTOM_CLEARANCE).dp

Box(Modifier.fillMaxSize()) {
content()

// The overlay sizes to its content (a pill), so only the pill — not the
// full layer — sits in the layout / captures taps.
val overlayModifier = Modifier
.align(if (isTop) Alignment.TopCenter else Alignment.BottomCenter)
.then(if (isTop) Modifier.statusBarsPadding() else Modifier.navigationBarsPadding())
.padding(if (isTop) PaddingTop(clearance) else PaddingBottom(clearance))

Box(overlayModifier) {
overlayNode.children.forEach { child ->
RenderNode(child)
}
}
}
}

private const val DEFAULT_BOTTOM_CLEARANCE = 72
private const val DEFAULT_TOP_CLEARANCE = 8

private fun PaddingBottom(value: androidx.compose.ui.unit.Dp) =
androidx.compose.foundation.layout.PaddingValues(bottom = value)

private fun PaddingTop(value: androidx.compose.ui.unit.Dp) =
androidx.compose.foundation.layout.PaddingValues(top = value)
6 changes: 6 additions & 0 deletions resources/android/NativeUIChromeInit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.nativephp.plugins.native_ui
import android.content.Context
import com.nativephp.mobile.ui.NativeUIThemeProvider
import com.nativephp.mobile.ui.nativerender.NativeRootHostRegistry
import com.nativephp.plugins.native_ui.ui.NativeFloatingOverlayHost
import com.nativephp.plugins.native_ui.ui.NativeLayoutDrawerHost

/**
Expand All @@ -22,6 +23,11 @@ fun registerNativeUIChrome(context: Context) {
NativeLayoutDrawerHost(drawerNode = drawerNode, content = content)
}

NativeRootHostRegistry.register("native-ui.floating-overlay", consumes = "floating_overlay") { root, content ->
val overlayNode = root.children.firstOrNull { it.type == "floating_overlay" }
NativeFloatingOverlayHost(overlayNode = overlayNode, content = content)
}

// Supply the app's color scheme from native-ui's theme tokens. The lambda
// reads NativeUITheme.{light,dark} (Compose snapshot state) when invoked
// during composition, so PHP-side Theme::merge updates stay reactive.
Expand Down
6 changes: 5 additions & 1 deletion resources/android/OutlinedTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.CircularProgressIndicator
Expand Down Expand Up @@ -97,7 +98,10 @@ object OutlinedTextInputRenderer {
text = filtered
dispatcher.onTextChanged(filtered)
},
modifier = modifier.nuiA11y(props.a11yLabel, props.a11yHint),
// Full width by default (parity with the iOS renderer's
// maxWidth: .infinity); an explicit width in `modifier` (FIXED
// layout mode) still wins since it comes later in the chain.
modifier = Modifier.fillMaxWidth().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint),
enabled = props.enabled,
readOnly = props.readOnly,
interactionSource = interactionSource,
Expand Down
116 changes: 115 additions & 1 deletion resources/android/TextRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
Expand All @@ -22,6 +27,18 @@ import com.nativephp.mobile.ui.nativerender.argbToComposeColor
object TextRenderer {
@Composable
fun Render(node: NativeUINode, modifier: Modifier) {
// A text node with child text runs composes them into ONE wrapping
// AnnotatedString (inline bold/colored runs, inline-code chips via a
// SpanStyle background). A leaf text keeps the original path below.
if (node.children.any { it.type == "text" }) {
RenderComposed(node, modifier)
} else {
RenderLeaf(node, modifier)
}
}

@Composable
private fun RenderLeaf(node: NativeUINode, modifier: Modifier) {
val p = node.props
val text = applyTransform(p.getString("text"), p.getInt("text_transform"))
val fontSize = p.getFloat("font_size", 16f)
Expand All @@ -36,7 +53,6 @@ object TextRenderer {
val isDark = isSystemInDarkTheme()
val darkColor = if (isDark) p.getColor("dark_color", 0) else 0
val textArgb = if (darkColor != 0) darkColor else p.getColor("color", 0xFF000000.toInt())

Text(
text = text,
modifier = modifier,
Expand Down Expand Up @@ -66,6 +82,104 @@ object TextRenderer {
)
)
}

@Composable
private fun RenderComposed(node: NativeUINode, modifier: Modifier) {
val p = node.props
val isDark = isSystemInDarkTheme()
val maxLines = p.getInt("max_lines")

val annotated = buildAnnotatedString {
appendTextRuns(node, RunCtx.Root, isDark)
}

Text(
text = annotated,
modifier = modifier,
textAlign = resolveTextAlign(p.getInt("text_align")),
maxLines = if (maxLines > 0) maxLines else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
// Same font-padding / line-height trim as the leaf path so composed
// and plain text share vertical metrics.
style = TextStyle(
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both
)
)
)
}
}

/**
* Typographic context inherited down the run tree. A child run reads its own
* props, falling back to the inherited value per field (CSS-like inheritance) —
* implemented by passing these as the `getX` defaults, so an unset prop
* transparently inherits. Decoration/background are NOT carried here; they're
* per-run (a chip's background must not bleed onto siblings).
*/
private data class RunCtx(
val fontSize: Float,
val fontWeightInt: Int,
val fontFamilyInt: Int,
val colorArgb: Int,
val darkColorArgb: Int,
val italic: Boolean,
val letterSpacingEm: Float,
val textTransform: Int,
) {
companion object {
// Root defaults — mirror the leaf path (16sp, normal, black, no dark
// override, no decoration/letter-spacing/transform).
val Root = RunCtx(16f, 0, 0, 0xFF000000.toInt(), 0, false, 0f, 0)
}
}

/**
* Walk a node, emitting one styled span for its own text (leaf, or the leading
* text before nested runs) then recursing into text children.
*/
private fun AnnotatedString.Builder.appendTextRuns(node: NativeUINode, inherited: RunCtx, isDark: Boolean) {
val p = node.props

// Resolve this level's effective context: own props over inherited.
val ctx = RunCtx(
fontSize = p.getFloat("font_size", inherited.fontSize),
fontWeightInt = p.getInt("font_weight", inherited.fontWeightInt),
fontFamilyInt = p.getInt("font_family", inherited.fontFamilyInt),
colorArgb = p.getColor("color", inherited.colorArgb),
darkColorArgb = p.getColor("dark_color", inherited.darkColorArgb),
italic = p.getInt("font_style", if (inherited.italic) 1 else 0) == 1,
letterSpacingEm = p.getFloat("letter_spacing", inherited.letterSpacingEm),
textTransform = p.getInt("text_transform", inherited.textTransform),
)

val ownText = applyTransform(p.getString("text"), ctx.textTransform)
if (ownText.isNotEmpty()) {
val fg = if (isDark && ctx.darkColorArgb != 0) ctx.darkColorArgb else ctx.colorArgb
// Run background = the inline-code chip. bg_color lives on style;
// dark_bg_color on props (matches NodeModifiers' split). Per-run.
val bgArgb = node.style?.bgColor ?: 0
val darkBg = if (isDark) p.getColor("dark_bg_color", 0) else 0
val effectiveBg = if (darkBg != 0) darkBg else bgArgb

val span = SpanStyle(
color = argbToComposeColor(fg),
fontSize = ctx.fontSize.sp,
fontWeight = resolveFontWeight(ctx.fontWeightInt),
fontStyle = if (ctx.italic) FontStyle.Italic else FontStyle.Normal,
fontFamily = resolveFontFamily(ctx.fontFamilyInt),
letterSpacing = if (ctx.letterSpacingEm != 0f) ctx.letterSpacingEm.em else TextUnit.Unspecified,
textDecoration = resolveDecoration(p.getInt("underline"), p.getInt("line_through")),
background = if (effectiveBg != 0) argbToComposeColor(effectiveBg) else Color.Unspecified,
)
withStyle(span) { append(ownText) }
}

node.children.filter { it.type == "text" }.forEach { child ->
appendTextRuns(child, ctx, isDark)
}
}

private fun resolveFontWeight(weight: Int): FontWeight {
Expand Down
5 changes: 5 additions & 0 deletions resources/ios/NativeUIDrawerHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ func registerNativeUIChrome() {
let drawerNode = root.children.first { $0.type == "native_drawer" }
return AnyView(NativeDrawerHost(drawerNode: drawerNode) { content })
}

NativeRootHostRegistry.shared.register("native-ui.floating-overlay", consumes: "floating_overlay") { root, content in
let overlayNode = root.children.first { $0.type == "floating_overlay" }
return AnyView(NativeFloatingOverlayHost(overlayNode: overlayNode) { content })
}
}

/// Global open/close state for the content-agnostic side drawer
Expand Down
Loading
Loading