Skip to content

Give a bottom sheet a title, drawn as each platform draws its own - #199

Open
Hesesses wants to merge 1 commit into
NativePHP:mainfrom
Hesesses:feat/sheet-title
Open

Hesesses wants to merge 1 commit into
NativePHP:mainfrom
Hesesses:feat/sheet-title

Conversation

@Hesesses

@Hesesses Hesesses commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

A <bottom-sheet> is a bare panel. It has no title and no close button, and its content starts at the top edge, under the grabber. So an app that wants a titled sheet draws its own title row and X inside it and guesses the gap under the handle. The result looks almost like a system sheet, but not quite, and the app has to work out the iOS and Android differences itself.

This adds a title attribute and a fluent title(). A titled sheet looks like the platform's own sheets:

  • iOS. Apple's HIG (Sheets) puts a sheet's title in its toolbar, with Cancel or Close on the toolbar's leading edge, and people expect to swipe a sheet down as well. A titled sheet's content now sits in a NavigationStack with an inline .navigationTitle. The system's close button is at .cancellationAction: Button(role: .close) on iOS 26, the filled xmark circle before it. A permanent sheet gets no close button.
  • Android. Material's modal bottom sheet has no top app bar and no close button. It closes on a swipe down, a tap on the scrim or back. So the title is titleLarge heading text under the drag handle, at a 20dp inset, in the theme's on-surface color and default font.

Untitled sheets don't change.

We built this in our app first, as a local patch on 0.9.0, and moved our sheets to it. This PR is that patch, so we can delete it.

Try it

use Illuminate\View\View;
use Native\Mobile\Edge\NativeComponent;

class SheetTitle extends NativeComponent
{
    public bool $open = false;

    public function openSheet(): void
    {
        $this->open = true;
    }

    public function closeSheet(): void
    {
        logger('closeSheet()');
        $this->open = false;
    }

    public function render(): View
    {
        return view('sheet-title');
    }
}
<column class="w-full h-full p-6 gap-4">
    <button label="Open" @tap="openSheet" />

    <bottom-sheet :visible="$open" @dismiss="closeSheet" title="Filters" close-label="Close" detents="fit">
        <column class="w-full p-6 gap-4">
            <text>Open now</text>
            <text>Indoor courts</text>
        </column>
    </bottom-sheet>
</column>

Tap Open. On iOS, "Filters" is in the sheet's bar with a close button on the left. Tapping it logs closeSheet(), as a swipe-down does. On Android, "Filters" sits under the drag handle.

How

Element. title and close-label (or closeLabel) become the title and close_label props. close-label is the close button's VoiceOver label. The package can't translate it, so an app whose language can differ from the system's passes its own. Unset, iOS 26 keeps the system's label and the older button reads "Close".

iOS. A SheetTitleBar modifier puts a titled sheet's content in the NavigationStack. An untitled sheet's content is returned as it is. The close button sets isPresented to false, so onDismiss sends @dismiss, the same as for a swipe-down. That still holds with #196, which only skips the closes PHP starts through visible.

A fit sheet also measures the bar and adds its height to the content's, so the fit detent holds both. The bar's height is the top safe-area inset of a background that reaches up under the bar.

Android. A SheetHeading composable comes first in the sheet's column when the title isn't empty.

Docs. The element's docblock and the bottom-sheet row of the Boost skill.

Testing

  • tests/BottomSheetRendererTest.php gets four tests that pin the Swift and the Kotlin, and tests/CollectorElementsTest.php gets three for the element's props. All of them fail without the change, except the one that checks an untitled sheet sends no title.
  • One existing test pinned the fit sheet's height line. That line now adds the bar's height, which is 0 on an untitled sheet, so the test is updated.
  • composer test (492 passed) and composer lint:test pass.
  • iOS: I ran the new renderer code in a small SwiftUI app on two simulators, an iPhone 17 Pro (iOS 26.3) and an iPhone 16 (iOS 18.4). The title and close button show in the bar, and a fit sheet is tall enough for the bar and its content. Tapping close dismisses the sheet through onDismiss.
  • Android: I added the sheet renderer to the tests/android Robolectric module locally, with stubs for sendSheetDismissEvent and RenderNode. A titled fit sheet draws its title above its content, and an untitled one draws none. I left that test out of this PR, because the module doesn't build the sheet renderer today. Happy to add it if you'd like it.
  • Our app has run this as a patch on 0.9.0, and we checked it on an iPhone.

This branch, #196 and #200 (the grabber) all edit the same renderer and test file. Any two of them, or all three, merge cleanly in either order, and the suite passes on the merged tree.

🤖 Generated with Claude Code

A <bottom-sheet> 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 <noreply@anthropic.com>
@simonhamp

Copy link
Copy Markdown
Member

Would be great to see screenshots of before and after, dark and light of this for both platforms

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants