From 1bb1b3fe6b81fa4c6853cb6187d8eea517df3f3f Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Thu, 9 Jul 2026 15:07:31 -0400 Subject: [PATCH 1/5] feat: add floating overlay layout hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A content-agnostic chrome hook: a layout implements floatingOverlay() (via HasFloatingOverlay) to float any element or Blade view above the content and tab bar, app-wide. Renders nothing when it returns null. Rides the existing chrome seam — ChromeContributorRegistry (PHP) + NativeRootHostRegistry (native) — so it ships entirely from the plugin with no edits to nativephp/mobile core. - FloatingOverlay builder (View|Element content, bottom/top, offset) - floating_overlay sentinel element + blade component pairing - HasFloatingOverlay / InteractsWithFloatingOverlay traits - iOS + Android NativeFloatingOverlayHost, registered as root hosts - registerFloatingOverlay() chrome contributor in the service provider - manifest component entry + tests Co-Authored-By: Claude Opus 4.8 (1M context) --- nativephp.json | 8 ++ .../android/NativeFloatingOverlayHost.kt | 72 ++++++++++++ resources/android/NativeUIChromeInit.kt | 6 + resources/ios/NativeUIDrawerHost.swift | 5 + .../ios/NativeUIFloatingOverlayHost.swift | 85 ++++++++++++++ src/Builders/FloatingOverlay.php | 104 ++++++++++++++++++ src/Components/FloatingOverlay.php | 21 ++++ src/Concerns/HasFloatingOverlay.php | 46 ++++++++ src/Concerns/InteractsWithFloatingOverlay.php | 53 +++++++++ src/Elements/FloatingOverlay.php | 53 +++++++++ src/NativeUIServiceProvider.php | 53 +++++++++ tests/FloatingOverlayTest.php | 58 ++++++++++ 12 files changed, 564 insertions(+) create mode 100644 resources/android/NativeFloatingOverlayHost.kt create mode 100644 resources/ios/NativeUIFloatingOverlayHost.swift create mode 100644 src/Builders/FloatingOverlay.php create mode 100644 src/Components/FloatingOverlay.php create mode 100644 src/Concerns/HasFloatingOverlay.php create mode 100644 src/Concerns/InteractsWithFloatingOverlay.php create mode 100644 src/Elements/FloatingOverlay.php create mode 100644 tests/FloatingOverlayTest.php diff --git a/nativephp.json b/nativephp.json index 3b7c807..0aa7775 100644 --- a/nativephp.json +++ b/nativephp.json @@ -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": [ diff --git a/resources/android/NativeFloatingOverlayHost.kt b/resources/android/NativeFloatingOverlayHost.kt new file mode 100644 index 0000000..d2bde25 --- /dev/null +++ b/resources/android/NativeFloatingOverlayHost.kt @@ -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) diff --git a/resources/android/NativeUIChromeInit.kt b/resources/android/NativeUIChromeInit.kt index deab99c..0e10f56 100644 --- a/resources/android/NativeUIChromeInit.kt +++ b/resources/android/NativeUIChromeInit.kt @@ -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 /** @@ -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. diff --git a/resources/ios/NativeUIDrawerHost.swift b/resources/ios/NativeUIDrawerHost.swift index 7215ad3..79818e6 100644 --- a/resources/ios/NativeUIDrawerHost.swift +++ b/resources/ios/NativeUIDrawerHost.swift @@ -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 diff --git a/resources/ios/NativeUIFloatingOverlayHost.swift b/resources/ios/NativeUIFloatingOverlayHost.swift new file mode 100644 index 0000000..42853c0 --- /dev/null +++ b/resources/ios/NativeUIFloatingOverlayHost.swift @@ -0,0 +1,85 @@ +import SwiftUI +import UIKit + +/// Wraps the rendered screen tree in a floating overlay when the published tree +/// carries a `floating_overlay` element. Unlike a bottom bar it does NOT inset +/// the content — the overlay floats on a top layer (a `ZStack`), so the screen +/// beneath is untouched and the pill hovers above it (and above the tab bar). +/// +/// The overlay content is **arbitrary** — its children render through the +/// generic `NodeView`, so developers can float any Blade/elements (a pill, a +/// banner, a mini-player). +/// +/// Placement (from the element's props): +/// - `alignment` — `bottom` (above the tab bar, default) or `top` +/// (below the nav bar). +/// - `offset` — extra points between the overlay and the aligned edge on +/// top of the safe-area inset; unset → a default that clears a standard +/// bottom tab bar. +/// +/// When `overlayNode` is nil this is a transparent pass-through, so it's safe +/// to wrap every tree unconditionally. +struct NativeFloatingOverlayHost: View { + let overlayNode: NativeUINode? + @ViewBuilder var content: Content + + /// Default clearance above the aligned edge (on top of the safe-area + /// inset) when the layout doesn't set an explicit `offset`. Sized to clear + /// a standard iOS tab bar with a small margin. + private let defaultBottomClearance: CGFloat = 60 + private let defaultTopClearance: CGFloat = 8 + + /// Real safe-area insets read straight from the window. The wrapped content + /// manages its own safe area (via the environment, like NativeTreeRenderer), + /// so a nested reader here double-counts — position against the window inset + /// instead, same approach as NativeDrawerHost. + private var windowInsets: UIEdgeInsets { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.windows.first?.safeAreaInsets ?? .zero + } + + var body: some View { + if let overlayNode { + let isTop = overlayNode.props.getString("alignment", default: "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. + let rawOffset = CGFloat(overlayNode.props.getInt("offset", default: 0)) + + ZStack(alignment: isTop ? .top : .bottom) { + content + + overlayView(overlayNode) + .padding(isTop ? .top : .bottom, + edgeInset(isTop: isTop) + (rawOffset > 0 ? rawOffset : defaultClearance(isTop: isTop))) + .frame(maxWidth: .infinity, maxHeight: .infinity, + alignment: isTop ? .top : .bottom) + .allowsHitTesting(true) + } + .ignoresSafeArea() + } else { + content + } + } + + private func edgeInset(isTop: Bool) -> CGFloat { + isTop ? windowInsets.top : windowInsets.bottom + } + + private func defaultClearance(isTop: Bool) -> CGFloat { + isTop ? defaultTopClearance : defaultBottomClearance + } + + /// The floating content itself. Children render through the generic + /// `NodeView`, and the overlay wrapper sizes to its content (a pill), so + /// only the pill — not the full-width layer — captures taps. + @ViewBuilder + private func overlayView(_ overlayNode: NativeUINode) -> some View { + VStack(spacing: 0) { + ForEach(overlayNode.children) { child in + NodeView(node: child).equatable() + } + } + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/src/Builders/FloatingOverlay.php b/src/Builders/FloatingOverlay.php new file mode 100644 index 0000000..fcdb62c --- /dev/null +++ b/src/Builders/FloatingOverlay.php @@ -0,0 +1,104 @@ +offset(88); // clear the tab bar + * } + * } + * + * The native-ui chrome contributor renders the content through the screen's own + * bound Blade path (so `@press` / wire bindings resolve against the screen) and + * wraps it in a {@see \Nativephp\NativeUi\Elements\FloatingOverlay} sentinel. + * The native floating-overlay host hoists that out onto a persistent top layer + * over every screen routed under the layout. + */ +class FloatingOverlay +{ + private View|Element $content; + + private string $alignment = 'bottom'; + + private ?int $offset = null; + + private function __construct(View|Element $content) + { + $this->content = $content; + } + + public static function make(View|Element $content): self + { + return new self($content); + } + + /** Float against the bottom edge, above the tab bar (default). */ + public function bottom(): self + { + $this->alignment = 'bottom'; + + return $this; + } + + /** Float against the top edge, below the nav bar. */ + public function top(): self + { + $this->alignment = 'top'; + + return $this; + } + + /** + * Extra distance in points/dp between the overlay and the aligned edge, + * on top of the safe-area inset. Null = platform default (clears a + * standard bottom tab bar). Pass a small value on tab-less stack layouts. + */ + public function offset(int $points): self + { + $this->offset = $points; + + return $this; + } + + public function getContent(): View|Element + { + return $this->content; + } + + public function getAlignment(): string + { + return $this->alignment; + } + + public function getOffset(): ?int + { + return $this->offset; + } +} diff --git a/src/Components/FloatingOverlay.php b/src/Components/FloatingOverlay.php new file mode 100644 index 0000000..d2b89bb --- /dev/null +++ b/src/Components/FloatingOverlay.php @@ -0,0 +1,21 @@ +offset(88); + * } + * } + * + * Because it lives on the layout, the overlay floats over *every* screen routed + * under that layout — it persists across tabs and pushes rather than living on + * one screen. The native-ui chrome contributor (registered with core's + * `ChromeContributorRegistry`) discovers `floatingOverlay()` and hoists it onto + * a global top layer. A screen can override per-screen or suppress it with + * {@see InteractsWithFloatingOverlay}. + * + * Note: the method is re-evaluated on every publish from the current `$screen`, + * so an app-wide overlay whose contents depend on state (e.g. "N servers + * nearby") should read that state from a shared store rather than from the + * active screen's own properties. + */ +trait HasFloatingOverlay +{ + /** + * Return the floating overlay for screens routed under this layout, or null + * for none. Declared once here so it isn't duplicated across every screen. + */ + public function floatingOverlay(NativeComponent $screen): ?FloatingOverlay + { + return null; + } +} diff --git a/src/Concerns/InteractsWithFloatingOverlay.php b/src/Concerns/InteractsWithFloatingOverlay.php new file mode 100644 index 0000000..9aa8331 --- /dev/null +++ b/src/Concerns/InteractsWithFloatingOverlay.php @@ -0,0 +1,53 @@ +hidesFloatingOverlay; + } +} diff --git a/src/Elements/FloatingOverlay.php b/src/Elements/FloatingOverlay.php new file mode 100644 index 0000000..79affff --- /dev/null +++ b/src/Elements/FloatingOverlay.php @@ -0,0 +1,53 @@ + 'bottom']; + + public static function make(): static + { + return new static; + } + + public function applyAttributes(array $attrs): void + { + if (isset($attrs['alignment'])) { + $this->props['alignment'] = $attrs['alignment'] === 'top' ? 'top' : 'bottom'; + } + if (isset($attrs['offset']) && $attrs['offset'] !== null) { + $this->props['offset'] = (int) $attrs['offset']; + } + + $this->applyA11yAttributes($attrs); + } + + protected function resolveProps(CallbackRegistry $registry): array + { + return $this->props; + } +} diff --git a/src/NativeUIServiceProvider.php b/src/NativeUIServiceProvider.php index 299e279..35edce6 100644 --- a/src/NativeUIServiceProvider.php +++ b/src/NativeUIServiceProvider.php @@ -10,7 +10,9 @@ use Native\Mobile\Edge\NativeComponent; use Native\Mobile\Edge\TailwindParser; use Nativephp\NativeUi\Builders\Drawer; +use Nativephp\NativeUi\Builders\FloatingOverlay as FloatingOverlayBuilder; use Nativephp\NativeUi\Console\GenerateIconsCommand; +use Nativephp\NativeUi\Elements\FloatingOverlay as FloatingOverlayElement; use Nativephp\NativeUi\Elements\NativeDrawer; class NativeUIServiceProvider extends ServiceProvider @@ -63,6 +65,7 @@ public function boot(): void }); $this->registerLayoutDrawer(); + $this->registerFloatingOverlay(); if ($this->app->runningInConsole()) { $this->commands([ @@ -119,4 +122,54 @@ protected function registerLayoutDrawer(): void return $drawer; }); } + + /** + * Register the floating-overlay chrome contributor with core's chrome seam. + * Same shape as {@see registerLayoutDrawer()}: resolve the overlay for the + * screen (per-screen override beats the layout) and render it into a + * `floating_overlay` sentinel. The native floating-overlay host — registered + * on core's `NativeRootHostRegistry` from this plugin's init function — + * hoists it onto a top layer over the content. + * + * Discovery is via `method_exists`, so layouts/screens opt in by using the + * {@see \Nativephp\NativeUi\Concerns\HasFloatingOverlay} / + * {@see \Nativephp\NativeUi\Concerns\InteractsWithFloatingOverlay} traits (or + * by declaring the methods themselves) — core never knows. + */ + protected function registerFloatingOverlay(): void + { + if (! class_exists(ChromeContributorRegistry::class)) { + return; + } + + ChromeContributorRegistry::register(function (NativeComponent $screen, ?NativeLayout $layout, callable $renderPartial): ?Element { + if (method_exists($screen, 'hidesFloatingOverlay') && $screen->hidesFloatingOverlay()) { + return null; + } + + $builder = null; + if (method_exists($screen, 'floatingOverlayOverride')) { + $builder = $screen->floatingOverlayOverride(); + } + if ($builder === null && $layout !== null && method_exists($layout, 'floatingOverlay')) { + $builder = $layout->floatingOverlay($screen); + } + + if (! $builder instanceof FloatingOverlayBuilder) { + return null; + } + + $content = $builder->getContent(); + $contentElement = $content instanceof View ? $renderPartial($content) : $content; + + $overlay = FloatingOverlayElement::make(); + $overlay->applyAttributes([ + 'alignment' => $builder->getAlignment(), + 'offset' => $builder->getOffset(), + ]); + $overlay->addChild($contentElement); + + return $overlay; + }); + } } \ No newline at end of file diff --git a/tests/FloatingOverlayTest.php b/tests/FloatingOverlayTest.php new file mode 100644 index 0000000..3b68b5d --- /dev/null +++ b/tests/FloatingOverlayTest.php @@ -0,0 +1,58 @@ +getAlignment())->toBe('bottom'); + expect($builder->getOffset())->toBeNull(); +}); + +it('carries alignment, offset and content through the builder', function () { + $content = Chip::make('Now playing'); + $builder = FloatingOverlayBuilder::make($content)->top()->offset(88); + + expect($builder->getAlignment())->toBe('top'); + expect($builder->getOffset())->toBe(88); + expect($builder->getContent())->toBe($content); +}); + +it('serializes the sentinel element props', function () { + $overlay = FloatingOverlay::make(); + $overlay->applyAttributes(['alignment' => 'top', 'offset' => 88]); + + $props = $overlay->toArray(new CallbackRegistry)['props']; + + expect($props['alignment'])->toBe('top'); + expect($props['offset'])->toBe(88); +}); + +it('defaults sentinel alignment to bottom and omits an unset offset', function () { + $overlay = FloatingOverlay::make(); + $overlay->applyAttributes(['offset' => null]); + + $props = $overlay->toArray(new CallbackRegistry)['props']; + + expect($props['alignment'])->toBe('bottom'); + expect($props)->not->toHaveKey('offset'); +}); + +it('clamps an unknown alignment to bottom', function () { + $overlay = FloatingOverlay::make(); + $overlay->applyAttributes(['alignment' => 'sideways']); + + $props = $overlay->toArray(new CallbackRegistry)['props']; + + expect($props['alignment'])->toBe('bottom'); +}); From d0982cf91414f813f86cf64af8d50f0f8dad9d62 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Thu, 9 Jul 2026 15:11:59 -0400 Subject: [PATCH 2/5] feat(text-input): keep keyboard up on submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add keepFocusOnSubmit() (blade: keep-focus-on-submit / keep-focus). By default SwiftUI resigns first responder on return, dismissing the keyboard; opting in re-asserts focus so the keyboard stays up — the chat "send and keep typing" pattern. iOS TextInputCore re-focuses when the server value clears after submit. --- resources/ios/NativeUITextInputCore.swift | 20 ++++++++++++++++++-- src/Elements/BaseTextInput.php | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 2180e80..d93614d 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -42,6 +42,7 @@ struct NativeUITextInputCore: View { let onSubmitCb = p.getCallbackId("on_submit") let syncMode = p.getString("sync_mode", default: "live") let debounceMs = p.getInt("debounce_ms", default: 300) + let keepFocus = p.getBool("keep_focus_on_submit") // Apply `.foregroundColor` (not just `.foregroundStyle`) so the TYPED // text adopts `contentColor`. SwiftUI's TextField/SecureField don't @@ -82,6 +83,13 @@ struct NativeUITextInputCore: View { if newServerValue != lastSentValue { text = newServerValue lastSentValue = newServerValue + // Send-BUTTON path (no `onSubmit`): a send clears the draft to + // empty. Keep the keyboard up by re-asserting focus. Opt-in via + // `keep-focus-on-submit`; only on a clear-to-empty so ordinary + // programmatic value pushes don't grab focus. + if keepFocus && newServerValue.isEmpty { + DispatchQueue.main.async { isFocused = true } + } } } .onChange(of: text) { _, newValue in @@ -99,12 +107,20 @@ struct NativeUITextInputCore: View { } } .onSubmit { - // Submit also acts as a commit point — flush pending, then - // dispatch submit. + // Submit also acts as a commit point — flush pending, then dispatch. flushPending(onChangeCb: onChangeCb) if onSubmitCb != 0 { NativeElementBridge.sendSubmitEvent(onSubmitCb, nodeId: node.id, text: text) } + // Chat "send and keep typing": SwiftUI resigns first responder on + // return by default. Re-assert focus so the keyboard stays up. NOTE: + // this causes a small keyboard "bounce" on return (resign → refocus) + // that the send button doesn't have — the smooth fix needs a + // UIKit-backed field (see notes), not the multiline workaround which + // mis-sized the field in the flex layout. + if keepFocus { + DispatchQueue.main.async { isFocused = true } + } } } diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index c54b61f..d734e7f 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -68,6 +68,9 @@ public function applyAttributes(array $attrs): void $this->maxLength((int) ($attrs['maxLength'] ?? $attrs['max-length'])); } if (! empty($attrs['multiline'])) { $this->multiline(); } + if (! empty($attrs['keepFocusOnSubmit']) || ! empty($attrs['keep-focus-on-submit']) || ! empty($attrs['keep-focus'])) { + $this->keepFocusOnSubmit(); + } if (isset($attrs['maxLines']) || isset($attrs['max-lines'])) { $this->maxLines((int) ($attrs['maxLines'] ?? $attrs['max-lines'])); } @@ -189,6 +192,19 @@ public function multiline(bool $value = true): static return $this; } + /** + * Keep the keyboard up after the field is submitted (return key / + * `@submit`). Without this SwiftUI resigns first responder on return, + * dismissing the keyboard — the chat "send and keep typing" pattern + * wants the opposite. Blade: `keep-focus-on-submit` (or `keep-focus`). + */ + public function keepFocusOnSubmit(bool $value = true): static + { + $this->inputProps['keep_focus_on_submit'] = $value; + + return $this; + } + public function maxLines(int $lines): static { $this->inputProps['max_lines'] = $lines; From 1a45305e4464204558ac7fd2da27b5f7b3e77eba Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Thu, 9 Jul 2026 15:11:59 -0400 Subject: [PATCH 3/5] feat(text): inherit font size/weight/family in composed text Composed/inline text now inherits font size, weight and family from its context instead of resetting to defaults, and preserves Dynamic Type scaling (nuiScaledFont on iOS, includeFontPadding=false trim on Android) with lineLimit/maxLines honored. --- resources/android/TextRenderer.kt | 116 ++++++++++++++++++++- resources/ios/NativeUITextRenderer.swift | 127 +++++++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) diff --git a/resources/android/TextRenderer.kt b/resources/android/TextRenderer.kt index 59bd388..0daa224 100644 --- a/resources/android/TextRenderer.kt +++ b/resources/android/TextRenderer.kt @@ -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 @@ -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) @@ -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, @@ -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 { diff --git a/resources/ios/NativeUITextRenderer.swift b/resources/ios/NativeUITextRenderer.swift index 38535d7..4987fe1 100644 --- a/resources/ios/NativeUITextRenderer.swift +++ b/resources/ios/NativeUITextRenderer.swift @@ -6,6 +6,20 @@ struct NativeUITextRenderer: View { @Environment(\.colorScheme) private var colorScheme var body: some View { + // A text node with child text runs composes them into ONE wrapping + // attributed string (inline bold/colored runs, inline-code chips with a + // background, syntax highlighting) instead of stacking each run as its + // own block. A leaf text (no text children) keeps the original path + // below — preserving Dynamic Type via `nuiScaledFont`. + if node.children.contains(where: { $0.type == "text" }) { + composedBody + } else { + leafBody + } + } + + @ViewBuilder + private var leafBody: some View { let p = node.props let text = applyTransform(p.getString("text"), p.getInt("text_transform")) let fontSize = p.getFloat("font_size", default: 16) @@ -65,6 +79,119 @@ struct NativeUITextRenderer: View { } } + // MARK: - Inline rich text (child runs → one AttributedString) + + /// Typographic context inherited down the run tree. A child run reads its + /// own props, falling back to the inherited value for each 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 bg must not bleed to siblings). + private struct RunContext { + var fontSize: Float + var fontWeightInt: Int + var fontFamilyInt: Int + var colorArgb: Int + var darkColorArgb: Int + var italic: Bool + var letterSpacingEm: Float + var textTransform: Int + + /// Root defaults — mirror the leaf path (16pt, regular, black, no dark + /// override, no decoration/kerning/transform). + static let root = RunContext( + fontSize: 16, fontWeightInt: 0, fontFamilyInt: 0, + colorArgb: 0xFF000000, darkColorArgb: 0, italic: false, + letterSpacingEm: 0, textTransform: 0 + ) + } + + @ViewBuilder + private var composedBody: some View { + let p = node.props + let maxLines = p.getInt("max_lines") + let composed = buildComposed() + + Text(composed) + .multilineTextAlignment(resolveTextAlign(p.getInt("text_align"))) + .lineLimit(maxLines > 0 ? maxLines : nil) + .modifier(TruncateIfLimited(maxLines: maxLines)) + // Same fill + fixedSize policy as the leaf path so the composed + // string wraps at the container width and grows vertically. + .frame(maxWidth: .infinity, alignment: frameAlignment(from: p.getInt("text_align"))) + .fixedSize(horizontal: false, vertical: true) + } + + /// Build the composed attributed string outside the `@ViewBuilder` — the + /// in-out mutation of `appendRuns` is a `Void` statement and can't live in + /// a builder body (it would be read as a view: "() cannot conform to View"). + private func buildComposed() -> AttributedString { + var composed = AttributedString() + appendRuns(into: &composed, node: node, inherited: .root) + return composed + } + + /// Walk a node, emitting one styled run for its own text (leaf, or the + /// leading text before nested runs) then recursing into text children. + private func appendRuns(into result: inout AttributedString, node: NativeUINode, inherited: RunContext) { + let p = node.props + + // Resolve this level's effective context: own props over inherited. + var ctx = inherited + ctx.fontSize = p.getFloat("font_size", default: inherited.fontSize) + ctx.fontWeightInt = p.getInt("font_weight", default: inherited.fontWeightInt) + ctx.fontFamilyInt = p.getInt("font_family", default: inherited.fontFamilyInt) + ctx.colorArgb = p.getColor("color", default: inherited.colorArgb) + ctx.darkColorArgb = p.getColor("dark_color", default: inherited.darkColorArgb) + ctx.italic = p.getInt("font_style", default: inherited.italic ? 1 : 0) == 1 + ctx.letterSpacingEm = p.getFloat("letter_spacing", default: inherited.letterSpacingEm) + ctx.textTransform = p.getInt("text_transform", default: inherited.textTransform) + + let ownText = p.getString("text") + if !ownText.isEmpty { + result += makeRun(ownText, node: node, ctx: ctx) + } + for child in node.children where child.type == "text" { + appendRuns(into: &result, node: child, inherited: ctx) + } + } + + private func makeRun(_ text: String, node: NativeUINode, ctx: RunContext) -> AttributedString { + var run = AttributedString(applyTransform(text, ctx.textTransform)) + + var font = Font.system( + size: CGFloat(ctx.fontSize), + weight: resolveFontWeight(ctx.fontWeightInt), + design: resolveFontDesign(ctx.fontFamilyInt) + ) + if ctx.italic, #available(iOS 16.0, *) { font = font.italic() } + run.font = font + + // Foreground — dark hex wins when in dark mode and one was supplied. + let fg = (colorScheme == .dark && ctx.darkColorArgb != 0) ? ctx.darkColorArgb : ctx.colorArgb + run.foregroundColor = Color(argb: fg) + + // Run background = the inline-code chip. bg_color lives on style; + // dark_bg_color on props (matches NodeStyleModifier's split). Per-run, + // never inherited. + let bgArgb = node.style?.bgColor ?? 0 + let darkBg = colorScheme == .dark ? node.props.getColor("dark_bg_color", default: 0) : 0 + let effectiveBg = darkBg != 0 ? darkBg : bgArgb + if effectiveBg != 0 { + run.backgroundColor = Color(argb: effectiveBg) + } + + // `Text.LineStyle` (underline/strikethrough on AttributedString) is + // iOS 16+, same tier as the leaf path's kerning guard. On iOS 15 an + // inline run simply renders without the decoration. + if #available(iOS 16.0, *) { + if node.props.getInt("underline") == 1 { run.underlineStyle = .single } + if node.props.getInt("line_through") == 1 { run.strikethroughStyle = .single } + } + if ctx.letterSpacingEm != 0 { run.kern = CGFloat(ctx.letterSpacingEm) * CGFloat(ctx.fontSize) } + + return run + } + private func resolveFontWeight(_ weight: Int) -> Font.Weight { switch weight { case 1: return .thin From 9677f07bddf070cdb4b6e703514d1f01ec4a475c Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Thu, 9 Jul 2026 15:11:59 -0400 Subject: [PATCH 4/5] feat(scroll-view): stick-to-bottom anchoring via scroll-anchor="bottom" Chat-style bottom anchoring: opens pinned to the bottom and re-scrolls to the latest content as it grows. iOS uses a ScrollViewReader + zero-height bottom anchor (deterministic, avoids the iOS 17+ .defaultScrollAnchor flakiness); Android re-triggers stick-to-bottom when subtree content changes. --- resources/android/ContainerRenderers.kt | 40 +++++++++- .../ios/NativeUIScrollViewRenderer.swift | 79 +++++++++++++++++-- 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/resources/android/ContainerRenderers.kt b/resources/android/ContainerRenderers.kt index 6f1a71a..d097f5a 100644 --- a/resources/android/ContainerRenderers.kt +++ b/resources/android/ContainerRenderers.kt @@ -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 @@ -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 @@ -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 + * ), 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) { @@ -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 — 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) } diff --git a/resources/ios/NativeUIScrollViewRenderer.swift b/resources/ios/NativeUIScrollViewRenderer.swift index acf6c38..05b1f51 100644 --- a/resources/ios/NativeUIScrollViewRenderer.swift +++ b/resources/ios/NativeUIScrollViewRenderer.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit struct NativeUIScrollViewRenderer: View { let node: NativeUINode @@ -8,6 +9,8 @@ struct NativeUIScrollViewRenderer: View { let showsIndicators = node.props.getBool("shows_indicators", default: true) let spacing = CGFloat(node.layout?.gap ?? 0) let axis = node.props.getString("axis", default: "") + let stickBottom = node.props.getString("scroll_anchor", default: "") == "bottom" + let messageSignal = stickBottom ? Self.descendantCount(node) : 0 // 2D mode. Bypass the Lazy stacks (which force 1D layout) and use a // plain ZStack so each child renders at its declared frame. The @@ -53,17 +56,77 @@ struct NativeUIScrollViewRenderer: View { } .scrollDismissesKeyboard(.interactively) } else { - ScrollView(.vertical, showsIndicators: showsIndicators) { - LazyVStack(alignment: .leading, spacing: spacing) { - ForEach(node.children) { child in - NodeView(node: child) - .equatable() - .frame(maxWidth: .infinity, alignment: .leading) + // Chat-style bottom anchoring (`scroll-anchor="bottom"`). Deterministic + // ScrollViewReader + a zero-height bottom anchor: scroll to it on + // appear (open at the latest message) and whenever the content grows + // (follow new messages). Works on every iOS version and with lazy + // content, unlike `.defaultScrollAnchor` which is iOS 17+ and flaky + // with LazyVStack. `messageSignal` is a recursive descendant count so + // it changes even when messages sit inside a wrapping . + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: showsIndicators) { + LazyVStack(alignment: .leading, spacing: spacing) { + ForEach(node.children) { child in + NodeView(node: child) + .equatable() + .frame(maxWidth: .infinity, alignment: .leading) + } + if stickBottom { + Color.clear + .frame(height: 1) + .id(Self.bottomAnchorID) + } + } + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + .onAppear { + guard stickBottom else { return } + // Defer past first layout — lazy content isn't measured yet + // inside onAppear, so an immediate scrollTo no-ops. + DispatchQueue.main.async { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + } + .onChange(of: messageSignal) { _ in + guard stickBottom else { return } + withAnimation(.easeOut(duration: 0.25)) { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + } + // The keyboard shrinks the scroll viewport (the screen shifts + // up for keyboard avoidance). Re-pin the latest message to the + // bottom so it stays visible just above the input row instead + // of hiding behind it, moving IN SYNC with the keyboard: a + // one-runloop `async` lets SwiftUI register the new (shrunk) + // safe area so `scrollTo` targets the final layout, and the + // scroll animates with the keyboard's own reported duration so + // both travel together. + .onReceive(NotificationCenter.default.publisher( + for: UIResponder.keyboardWillShowNotification) + ) { note in + guard stickBottom else { return } + let duration = (note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 + DispatchQueue.main.async { + withAnimation(.easeOut(duration: duration)) { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } } } - .frame(maxWidth: .infinity) } - .scrollDismissesKeyboard(.interactively) } } + + private static let bottomAnchorID = "nphp.scroll.bottom.anchor" + + /// Recursive descendant count — a content signal that changes whenever a + /// message is added anywhere in the subtree (even inside a wrapping + /// where the scroll-view itself has a single child). + private static func descendantCount(_ node: NativeUINode) -> Int { + var count = node.children.count + for child in node.children { + count += descendantCount(child) + } + return count + } } From 29579db58226edcbf403e09847e112ab3a8a640e Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Thu, 9 Jul 2026 15:11:59 -0400 Subject: [PATCH 5/5] fix(text-input): default Android inputs to full width (iOS parity) Bare/Filled/Outlined inputs now fillMaxWidth() by default, matching the iOS renderer's maxWidth: .infinity. An explicit width in the modifier (FIXED layout mode) still wins since it composes later in the chain. --- resources/android/BareTextInputRenderer.kt | 6 +++++- resources/android/FilledTextInputRenderer.kt | 6 +++++- resources/android/OutlinedTextInputRenderer.kt | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index a118049..0669980 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -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 @@ -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( diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 4be6c0a..ac24e1e 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -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 @@ -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, diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 20e1bfa..8c9bfa0 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -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 @@ -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,