From da97e4757d89201f10315c2ddd23422cf14c5a6b Mon Sep 17 00:00:00 2001 From: Heikki Lampinen Date: Wed, 4 Nov 2026 16:48:12 +0200 Subject: [PATCH] Give a bottom sheet a title, drawn as each platform draws its own A was a bare panel: no title, no close button, its content starting at the top edge under the grabber. Apps drew their own title row and X in every sheet and guessed the gap under the handle. `title` (fluent `title()`) now draws the sheet the way the platform's own sheets look. On iOS the content goes in a NavigationStack whose inline bar carries the title and the system's close button on its leading edge, as Apple's HIG places a sheet's Cancel/Close: `Button(role: .close)` on iOS 26, the filled xmark circle before it. Close sets `isPresented` to false, so onDismiss reports it as `@dismiss`, as it does a swipe-down. A permanent sheet gets no close button. A fit sheet adds the bar's measured height to its content's. Material's modal bottom sheet has no top bar or close button, it closes on a swipe down, a tap on the scrim or back. So on Android the title is titleLarge heading text under the drag handle, at a 20dp inset. `close-label` (fluent `closeLabel()`) sets the close button's VoiceOver label, since the package can't translate it. Unset, the system's own label stays. Untitled sheets are unchanged. Co-Authored-By: Claude Opus 5.5 --- resources/android/BottomSheetRenderer.kt | 30 +++++++ .../boost/skills/nativephp-mobile-ui/SKILL.md | 2 +- .../ios/NativeUIBottomSheetRenderer.swift | 81 ++++++++++++++++++- src/Elements/BottomSheet.php | 42 ++++++++++ tests/BottomSheetRendererTest.php | 50 +++++++++++- tests/CollectorElementsTest.php | 32 ++++++++ 6 files changed, 234 insertions(+), 3 deletions(-) diff --git a/resources/android/BottomSheetRenderer.kt b/resources/android/BottomSheetRenderer.kt index 9bac9e9..4eb478d 100644 --- a/resources/android/BottomSheetRenderer.kt +++ b/resources/android/BottomSheetRenderer.kt @@ -3,11 +3,15 @@ package com.nativephp.plugins.native_ui.ui import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,8 +20,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.mobile.ui.nativerender.RenderNode @@ -43,6 +49,8 @@ import com.nativephp.plugins.native_ui.NativeUITheme * content's height, capped below the status bar. M3's partial stop sits * at half the screen whatever the content, so the other detents listed * beside `fit` have nothing to snap to and are ignored. + * + * A `title` is Material heading text under the drag handle (`SheetHeading`). */ object BottomSheetRenderer { @OptIn(ExperimentalMaterial3Api::class) @@ -60,6 +68,7 @@ object BottomSheetRenderer { // touches; a Maps-style pane over a live background is what // `sheet_pane` is for. val permanent = p.getBool("permanent") + val title = p.getString("title") val fits = hasDetent(detentsStr, "fit") val skipPartial = fits || !hasPartialDetent(detentsStr) @@ -109,12 +118,33 @@ object BottomSheetRenderer { // current detent's height, leaving drag-room to the next one. // A fit sheet wants exactly that content-sized default. Column(modifier = if (fits) sheetModifier else sheetModifier.fillMaxHeight()) { + if (title.isNotEmpty()) { + SheetHeading(title = title, color = theme.onSurface) + } node.children.forEach { RenderNode(it) } } } } } +/** + * A titled sheet's heading under the drag handle. Material's modal bottom + * sheet has no top bar or close button — it closes on a swipe down, a tap + * on the scrim or back — so the title is plain heading text. + */ +@Composable +private fun SheetHeading(title: String, color: Color) { + Text( + text = title, + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, bottom = 8.dp), + style = MaterialTheme.typography.titleLarge, + fontFamily = nuiDefaultFontFamily(), + color = color, + ) +} + /** * Returns true iff the detents string requests at least one partial stop. * diff --git a/resources/boost/skills/nativephp-mobile-ui/SKILL.md b/resources/boost/skills/nativephp-mobile-ui/SKILL.md index ca9a795..c9653eb 100644 --- a/resources/boost/skills/nativephp-mobile-ui/SKILL.md +++ b/resources/boost/skills/nativephp-mobile-ui/SKILL.md @@ -31,7 +31,7 @@ dropped without an error. | `icon` | `name` (`star.fill`, `trash`, `plus`), or per platform `ios="checkmark.circle.fill" android="check_circle"`, `:size="20"`, `class="text-theme-primary"` | | `top-bar` | `title`, `subtitle`, `back`. Put it at the top level of the view | | `emoji-picker` | `:visible`, `@change` (gets the emoji), `@dismiss`, `done-label`. The system picker: iOS emoji keyboard, Android `EmojiPickerView` in a sheet. Stays open until `visible` is false | -| `bottom-sheet` | `:visible`, `@dismiss`, `detents` small\|medium\|large\|full\|fit or a fraction (`0.4`), comma-separated (`fit,large`); `fit` sizes the sheet to its content. `permanent`, `background-interaction` (iOS) | +| `bottom-sheet` | `:visible`, `@dismiss`, `detents` small\|medium\|large\|full\|fit or a fraction (`0.4`), comma-separated (`fit,large`); `fit` sizes the sheet to its content. `title`: iOS shows it in the sheet's own bar with the system close button (sends `@dismiss`), Android as heading text under the handle; don't draw your own title row. `close-label` (the close button's VoiceOver label), `permanent`, `background-interaction` (iOS) | | `sheet-pane` | Inline pane drawn inside the screen that drags between `detents`: heights in points or `fit` (the content's height), comma-separated (`88,fit`). `detent` (a number or `fit`) and `@change` (gets the settled detent, `fit` included); store it and bind it back to `detent`. `corner-radius`, `inset-x`, `inset-bottom` | | also | `toggle`, `checkbox`, `slider`, `select`, `radio-group`, `chip`, `badge`, `date-picker`, `modal`, `tab-row`, `progress-bar`, `activity-indicator` | diff --git a/resources/ios/NativeUIBottomSheetRenderer.swift b/resources/ios/NativeUIBottomSheetRenderer.swift index 0b135cb..62f3c29 100644 --- a/resources/ios/NativeUIBottomSheetRenderer.swift +++ b/resources/ios/NativeUIBottomSheetRenderer.swift @@ -4,6 +4,9 @@ import SwiftUI /// SwiftUI's `.sheet` with `presentationDetents` so system drag-to-dismiss /// and detent snapping come for free. /// +/// A `title` gives the sheet the system's own bar, as Apple's sheets have +/// (`SheetTitleBar`). An untitled sheet is the bare panel. +/// /// Model 3: container color from `theme.surface`. No per-instance override. struct NativeUIBottomSheetRenderer: View { let node: NativeUINode @@ -13,6 +16,9 @@ struct NativeUIBottomSheetRenderer: View { @State private var isPresented: Bool = false @State private var fitHeight: CGFloat? + // A titled fit sheet is as tall as its content and its bar together. + @State private var contentHeight: CGFloat = 0 + @State private var barHeight: CGFloat = 0 @State private var selectedDetent: PresentationDetent = .medium var body: some View { @@ -26,6 +32,8 @@ struct NativeUIBottomSheetRenderer: View { // behind stays interactive (HIG "sheets with interaction behind"). let permanent = node.props.getBool("permanent") let bgInteraction = node.props.getBool("background_interaction") + let title = node.props.getString("title") + let closeLabel = node.props.getString("close_label") Color.clear.frame(width: 0, height: 0) .sheet(isPresented: $isPresented, onDismiss: { @@ -45,10 +53,22 @@ struct NativeUIBottomSheetRenderer: View { } .fixedSize(horizontal: false, vertical: fits) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in - if fits, height > 0 { fitHeight = height } + contentHeight = height + if fits, height > 0 { fitHeight = height + barHeight } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(theme.surface) + .modifier(SheetTitleBar( + title: title, + closeLabel: closeLabel, + closable: !permanent, + background: theme.surface, + close: { isPresented = false }, + measured: { height in + barHeight = height + if fits, contentHeight > 0 { fitHeight = contentHeight + height } + } + )) } .onAppear { NativeUIKeyboardAccessoryState.shared.sheetDepth += 1 } .onDisappear { NativeUIKeyboardAccessoryState.shared.sheetDepth -= 1 } @@ -118,6 +138,65 @@ private struct DetentsModifier: ViewModifier { } } +/// A titled sheet is drawn as Apple's own sheets are (HIG, Sheets): its +/// content sits in a NavigationStack whose inline bar carries the title and +/// the system's close button on its leading edge, and the bar keeps the +/// content clear of the grabber. Closing is the user's dismissal, so it goes +/// through `isPresented` and onDismiss reports it as `@dismiss`, like a +/// swipe-down. `measured` hands over the bar's height, which a fit sheet +/// adds to its content's. +private struct SheetTitleBar: ViewModifier { + let title: String + let closeLabel: String + let closable: Bool + let background: Color + let close: () -> Void + let measured: (CGFloat) -> Void + + func body(content: Content) -> some View { + if title.isEmpty { + content + } else { + NavigationStack { + content + .background { + // Reaches up under the bar, so its top inset IS the bar. + background + .ignoresSafeArea() + .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { measured($0) } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + if closable { + ToolbarItem(placement: .cancellationAction) { closeButton } + } + } + } + } + } + + /// The system's close button: `role: .close` on iOS 26, the filled + /// circle sheets used before it. `close_label` replaces the system's + /// VoiceOver label, so an app can pass its own translation. + @ViewBuilder + private var closeButton: some View { + if #available(iOS 26.0, *) { + Button(role: .close, action: close) + .modifier(A11yLabelModifier(label: closeLabel)) + } else { + Button(action: close) { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.hierarchical) + .font(.title2) + .foregroundStyle(.secondary) + } + .accessibilityLabel(closeLabel.isEmpty ? "Close" : closeLabel) + } + } +} + private struct A11yLabelModifier: ViewModifier { let label: String func body(content: Content) -> some View { diff --git a/src/Elements/BottomSheet.php b/src/Elements/BottomSheet.php index 8298644..f481e87 100644 --- a/src/Elements/BottomSheet.php +++ b/src/Elements/BottomSheet.php @@ -9,6 +9,15 @@ * Bottom sheet — dismissible panel that slides up from the bottom. Visibility * driven by `visible`; `@dismiss` fires on drag-down or tap-outside. * + * A `title` draws the sheet the way each platform designs its own. iOS + * puts the content in a navigation stack whose inline bar carries the + * title and the system's close button (Apple's HIG, Sheets); the close + * button reports `@dismiss` like a swipe-down. Android shows the title as + * Material heading text under the drag handle, with no close button: + * Material's sheet closes on a swipe down, a tap outside or back. + * + * + * * Model 3: container colors from theme. No per-instance `backgroundColor` * override — wrap content in `` if a * non-standard surface is truly needed, but prefer the theme. @@ -43,6 +52,14 @@ public function applyAttributes(array $attrs): void $this->backgroundInteraction(filter_var($attrs[$key], FILTER_VALIDATE_BOOLEAN)); } } + if (isset($attrs['title'])) { + $this->title((string) $attrs['title']); + } + foreach (['close-label', 'closeLabel'] as $key) { + if (isset($attrs[$key])) { + $this->closeLabel((string) $attrs[$key]); + } + } $this->applyA11yAttributes($attrs); } @@ -103,6 +120,31 @@ public function backgroundInteraction(bool $value = true): static return $this; } + /** + * The sheet's title. iOS shows it in the sheet's own navigation bar, + * beside the system's close button (left out on a `permanent` sheet); + * Android shows it as heading text under the drag handle. An empty + * title leaves the sheet untitled. + */ + public function title(string $title): static + { + $this->sheetProps['title'] = $title; + + return $this; + } + + /** + * What VoiceOver reads for the close button of a titled iOS sheet. The + * system's own label when unset; pass your app's translation of + * "Close" if the system's language may differ from your app's. + */ + public function closeLabel(string $label): static + { + $this->sheetProps['close_label'] = $label; + + return $this; + } + public function onDismiss(string $method): static { $this->dismissCallback = $method; diff --git a/tests/BottomSheetRendererTest.php b/tests/BottomSheetRendererTest.php index 0b6c7e4..21791e3 100644 --- a/tests/BottomSheetRendererTest.php +++ b/tests/BottomSheetRendererTest.php @@ -33,7 +33,55 @@ ->toMatch('/height\.map \{ \.height\(\$0\) \}/') ->toMatch('/\.fixedSize\(horizontal: false, vertical: fits\)\s*' .'\.onGeometryChange\(for: CGFloat\.self\) \{ \$0\.size\.height \} action: \{ height in\s*' - .'if fits, height > 0 \{ fitHeight = height \}/s'); + .'contentHeight = height\s*' + .'if fits, height > 0 \{ fitHeight = height \+ barHeight \}/s'); +}); + +test('puts a titled iOS sheet in a navigation stack with its title inline', function () { + $renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIBottomSheetRenderer.swift'); + + expect($renderer) + ->toBeString() + ->toMatch('/let title = node\.props\.getString\("title"\)/') + ->toMatch('/\.background\(theme\.surface\)\s*\.modifier\(SheetTitleBar\(\s*title: title,/s') + ->toMatch('/if title\.isEmpty \{\s*content\s*\} else \{\s*NavigationStack \{\s*content/s') + ->toMatch('/\.navigationTitle\(title\)\s*\.navigationBarTitleDisplayMode\(\.inline\)/s'); +}); + +test('closes a titled iOS sheet with the system close button, as the user\'s dismissal', function () { + $renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIBottomSheetRenderer.swift'); + + expect($renderer) + ->toBeString() + ->toMatch('/closable: !permanent,/') + ->toMatch('/close: \{ isPresented = false \},/') + ->toMatch('/if closable \{\s*ToolbarItem\(placement: \.cancellationAction\) \{ closeButton \}/s') + ->toMatch('/if #available\(iOS 26\.0, \*\) \{\s*Button\(role: \.close, action: close\)\s*' + .'\.modifier\(A11yLabelModifier\(label: closeLabel\)\)/s') + ->toMatch('/Image\(systemName: "xmark\.circle\.fill"\)/'); +}); + +test('makes a titled iOS fit sheet as tall as its content and its bar', function () { + $renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIBottomSheetRenderer.swift'); + + expect($renderer) + ->toBeString() + ->toMatch('/@State private var barHeight: CGFloat = 0/') + ->toMatch('/measured: \{ height in\s*barHeight = height\s*' + .'if fits, contentHeight > 0 \{ fitHeight = contentHeight \+ height \}/s') + ->toMatch('/\.onGeometryChange\(for: CGFloat\.self\) \{ \$0\.safeAreaInsets\.top \} action: \{ measured\(\$0\) \}/'); +}); + +test('shows a titled Android sheet\'s title as heading text above its content', function () { + $renderer = file_get_contents(dirname(__DIR__).'/resources/android/BottomSheetRenderer.kt'); + + expect($renderer) + ->toBeString() + ->toMatch('/val title = p\.getString\("title"\)/') + ->toMatch('/if \(title\.isNotEmpty\(\)\) \{\s*SheetHeading\(title = title, color = theme\.onSurface\)\s*\}\s*' + .'node\.children\.forEach \{ RenderNode\(it\) \}/s') + ->toMatch('/private fun SheetHeading\(title: String, color: Color\) \{.*?' + .'style = MaterialTheme\.typography\.titleLarge/s'); }); test('keeps an iOS fit sheet on its fit detent when the content height changes', function () { diff --git a/tests/CollectorElementsTest.php b/tests/CollectorElementsTest.php index 116e768..d29529b 100644 --- a/tests/CollectorElementsTest.php +++ b/tests/CollectorElementsTest.php @@ -557,6 +557,38 @@ expect($tree['props']['background_interaction'])->toBeFalse(); }); +it('applies the sheet title and close label', function () { + NativeElementCollector::leaf('bottom_sheet', [ + 'title' => 'Filters', + 'close-label' => 'Sulje', + ]); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['title'])->toBe('Filters'); + expect($tree['props']['close_label'])->toBe('Sulje'); +}); + +it('accepts the camelCase close label and the fluent sheet title', function () { + NativeElementCollector::leaf('bottom_sheet', ['closeLabel' => 'Fermer']); + + expect(NativeElementCollector::collect()->toArray(new CallbackRegistry)['props']['close_label'])->toBe('Fermer'); + + $props = BottomSheet::make()->title('Filters')->closeLabel('Close')->toArray(new CallbackRegistry)['props']; + + expect($props['title'])->toBe('Filters'); + expect($props['close_label'])->toBe('Close'); +}); + +it('leaves a sheet untitled when no title is given', function () { + NativeElementCollector::leaf('bottom_sheet', ['visible' => true]); + + $props = NativeElementCollector::collect()->toArray(new CallbackRegistry)['props']; + + expect($props)->not->toHaveKey('title'); + expect($props)->not->toHaveKey('close_label'); +}); + // ── autocapitalize (mobile-air #304) ───────────────────────────────────────── it('serializes autocapitalization via both fluent APIs', function () {