nativephp / mobile-ui
Native SwiftUI and Jetpack Composer components for NativePHP
Package info
github.com/NativePHP/mobile-ui
Language:Swift
Type:nativephp-plugin
pkg:composer/nativephp/mobile-ui
Fund package maintenance!
Requires
- php: ^8.3
- nativephp/mobile: ^4.7
Requires (Dev)
- laravel/pint: ^1.24
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 0.9.0
- 0.8.0
- 0.7.0
- 0.6.0
- 0.5.0
- 0.4.0
- 0.3.0
- 0.2.0
- 0.1.0
- dev-security-policy
- dev-claude/ci-android-robolectric-512a2b
- dev-feat/webview-moves-to-mobile
- dev-combined/dev-null-app
- dev-feat/sign-in-with-apple-button
- dev-cursor/tree-shaking-renderers-5a2a
- dev-feat/boost-guidelines
- dev-fix/text-input-stale-echo
- dev-fix/list-item-trailing-press
- dev-feat/list-item-strikethrough
- dev-fix/355-image-per-corner-radius
- dev-feat/view-transitions
- dev-feat/macos-renderers
- dev-feat/scroll-view-auto-scroll-to
- dev-feat/error-display-audit
- dev-chore/add-pint-dev-dep
- dev-feat/text-input-selection
- dev-review/selection-contract-parity
- dev-fix/drop-edge-bridge-references
- dev-feat/success-outline-variant-tokens
- dev-add-testing-suite
- dev-chore/ios-min-version-font-fixtures
- dev-layout-drawer
- dev-native-ui-rewrite
This package is auto-updated.
Last update: 2026-10-30 06:50:41 UTC
README
A NativePHP Mobile plugin
Installation
composer require nativephp/mobile-ui
Usage
use Native\Mobile\UI\Facades\NativeUI; // Execute functionality $result = NativeUI::execute(['option1' => 'value']); // Get status $status = NativeUI::getStatus();
Listening for Events
use Livewire\Attributes\On; #[On('native:Native\Mobile\UI\Events\NativeUICompleted')] public function handleNativeUICompleted($result, $id = null) { // Handle the event }
Theming & Colors
Theme tokens live in config/native-ui.php (publish with
php artisan vendor:publish --tag=native-ui-config). Every authored color —
theme tokens, element color props, and arbitrary-value classes — accepts the
same grammar:
'light' => [ 'primary' => 'violet-600', // Tailwind palette name 'secondary' => 'fuchsia-500/70', // opacity modifier → tonal fill 'surface' => '#F8FAFC', // plain hex (#RGB / #RRGGBB) 'accent' => '#00AAA680', // CSS alpha hex (#RRGGBBAA) ],
Alpha hex is authored in CSS #RRGGBBAA order; the framework converts to the
native wire format. Dark mode is auto-derived from light (alpha preserved)
unless a dark block overrides specific tokens.
Disabled controls draw from the surface-variant (fill) and
on-surface-variant (label) tokens on both platforms — adjust those two
tokens to tune disabled contrast app-wide.
outlined-text-input draws a transparent box by default (Material 3's
outlined container), so on a colored screen the field reads as part of the
page rather than as a field. Declare the optional input-fill / on-input
pair to give it a body of its own — input-fill paints the box, on-input
recolors everything inside it (typed text, placeholder, icons,
prefix/suffix). Leave them unset and nothing changes.
Icons accept platform enum overrides in Blade, matching the fluent API:
<native:icon :ios="Ios::House" :android="Android::Home" :size="24" />
Lists
The native:list element supports an optional end-reached buffer for loading more items before the user reaches the end of the list.
<native:list on-end-reached="loadMore" :end-reached-buffer="5" />
A buffer of 5 starts the existing on-end-reached callback when the list reaches the configured buffer near the end. The default buffer is 3 when the attribute is omitted.
The same option is available through the fluent API:
use Native\Mobile\UI\Elements\NativeList; NativeList::make() ->endReachedBuffer(5) ->onEndReached('loadMore');
Opening a list at a row
auto-scroll-to opens a native:list or native:virtual-list with that row at the top: a screen restored after a relaunch, a deep link to a record, or the row just added. Rows count from 0, the way on-visible-range counts them, with a section's rows in their place.
<native:list :auto-scroll-to="$openAt"> @foreach ($posts as $post) <native:list-item native:key="post-{{ $post->id }}" :headline="$post->title" /> @endforeach </native:list>
The list scrolls when it is created and again when the index changes, never while it stays the same, so re-renders don't pull it away from where the reader scrolled. A row that hasn't loaded yet is scrolled to when it arrives. You don't need it to keep a list in place across a pushed screen: going back already does.
Like ScrollView::autoScrollTo(), negative indices disable auto-scroll. An index past the end waits for that row instead of clamping to the last row. Once applied, content changes alone don't reapply it, even if the list empties and fills again. To request the same row another time, render with auto-scroll disabled, then set the index in a later render.
A virtual list only draws the rows inside its window, so the window has to hold the row. scrollVirtualListTo() on the HasVirtualListWindow screen moves the window there and sets $virtualScrollTo for the list:
public function mount(int $contact): void { $this->scrollVirtualListTo($contact); }
<native:virtual-list :count="$total" :from="$virtualWindowFrom" :to="$virtualWindowTo" :auto-scroll-to="$virtualScrollTo" item="rows.contact" on-window-change="setVirtualWindow" />
Both elements take it through the fluent API too: NativeList::make()->autoScrollTo(12).
Sticky headers
A native:sticky-header in a list is a row that pins to the top of the list once you scroll it there, while the rows after it scroll underneath, like CSS position: sticky. The next sticky header pushes it off. It lays its children out like a column, so give it a background or the rows will show through.
<native:list> @include('partials.composer') <native:sticky-header class="w-full items-center py-3 bg-theme-surface {{ $newPosts ? '' : 'hidden' }}"> @if ($newPosts) <native:button @press="showNewPosts">{{ count($newPosts) }} new posts</native:button> @endif </native:sticky-header> @foreach ($posts as $post) @include('partials.post') @endforeach </native:list>
A hidden sticky header takes no space, so it can wait in its place until it has something to show. On iOS it's the header of a plain list section, and on Android a sticky LazyColumn item. It only pins as a direct child of a list with no native:list-sections.
use Native\Mobile\UI\Elements\StickyHeader; StickyHeader::make($newPostsButton);
Popovers
<native:popover> floats its children in a small bubble beside the spot it
is laid over, while visible is true. It draws nothing itself and takes no
touches, so put it over the view it points at, in a stack, beside that view
rather than inside a pressable:
<native:stack :width="30" :height="30"> <native:pressable @press="openReactions({{ $id }})" a11y-label="React"> <native:icon name="face.smiling" /> </native:pressable> <native:popover class="absolute top-0 bottom-0 left-0 right-0" :visible="$reactingTo === $id" @dismiss="closeReactions" > <native:row class="gap-2 p-2"> <native:button @press="react('👍')">👍</native:button> <native:button @press="react('🎉')">🎉</native:button> </native:row> </native:popover> </native:stack>
The bubble opens above the spot, or below it with placement="bottom", and
takes the other side when it wouldn't fit between the spot and the system
bars. iOS draws a system popover with its arrow, kept a popover on iPhone;
Android draws a raised surface in a popup, centred on the spot. Both take
their colour from theme.surface.
A tap outside it (or back, on Android) fires @dismiss. Close it from PHP
by setting visible to false, for instance once something in it is chosen;
that never fires @dismiss.
Accessibility
Every element accepts a screen-reader label and an optional hint, via Blade
attributes (a11y-label / a11y-hint, or the camelCase spellings
a11yLabel / a11yHint) or the fluent API (->a11yLabel() / ->a11yHint()).
The label maps to accessibilityLabel on iOS and contentDescription on
Android; the hint maps to accessibilityHint on iOS and is appended to the
content description on Android.
<native:button icon="trash" a11y-label="Delete draft" a11y-hint="Deletes the draft permanently" @press="deleteDraft" />
use Native\Mobile\UI\Elements\Button; Button::make() ->icon('plus') ->a11yLabel('Add item') ->a11yHint('Adds a new item to the list') ->onPress('addItem');
Always set a11y-label on icon-only buttons, chips, and tabs — without
visible text there is nothing for VoiceOver / TalkBack to announce. Icons are
decorative (silent to screen readers) unless given an a11y-label. List items
with a trailing icon button take trailing-a11y-label (fluent:
->trailingA11yLabel()) to label that button separately from the row.
Caret & Selection Reporting
Requires
nativephp/mobile4.0+, which ships thetext_selectioncallback kind — already enforced by this package's composer constraint.
The text inputs (<native:bare-text-input>, <native:outlined-text-input>,
<native:filled-text-input>) can report caret position and text selection back
to PHP via @selectionChange. The handler receives the current text plus the
selection range:
public function onCaretMove(string $text, int $selectionStart, int $selectionEnd) { // $selectionStart === $selectionEnd when the caret is a plain cursor; // they differ when a range of text is selected. }
Offsets are Unicode code points into the text (not UTF-16 units or bytes),
so emoji and other astral characters count as one — safe to feed straight into
mb_substr(..., encoding: 'UTF-8').
<native:outlined-text-input label="Message" @selectionChange="onCaretMove" selection-debounce-ms="100" />
use Native\Mobile\UI\Elements\OutlinedTextInput; OutlinedTextInput::make() ->label('Message') ->onSelectionChange('onCaretMove') ->selectionDebounceMs(100);
Events are coalesced on the native side — by default at most one every 150ms
while the caret moves (the trailing position always fires). Tune the window per
input with selection-debounce-ms / selectionDebounceMs (fluent:
->selectionDebounceMs()); when unset, nothing is serialized and the renderer
default applies. A value of 0 (or less) also means "use the default"; positive
values are floored at one frame (16ms).
Every event carries the full current text, and every event costs a bridge frame plus a full component re-render. That is independent of the
native:modelsync mode: pairing@selectionChangewithnative:model.bluror.debouncestill ships the field contents to PHP on the selection cadence, not the model cadence. Budget the debounce window accordingly.
@selectionChange is never emitted for secure inputs. The callback is not
serialized at all when secure is set, and both renderers additionally refuse
to emit — so caret telemetry can't leak password-field context.
Contract details
- Programmatic value pushes. When PHP pushes a new
valueonto the input, the field is replaced wholesale and the caret drops at the end. Both platforms report that immediately as a single(text, length, length)event, bypassing the debounce. A handler that rewrites the bound model will therefore see one follow-up event. - Discontiguous selections (multi-range, iOS) are reported as a single span
from the lowest start to the highest end — the reported range covers the gaps,
so
mb_substr($text, $start, $end - $start)includes text the user did not select. read-onlyinputs report selection on Android (which keeps them focusable for copy) but not on iOS (where read-only implies disabled, so the field never focuses).
A typical use is typeahead / mention triggers, where @change alone can't tell
you where the user is typing:
public function onCaretMove(string $text, int $start, int $end) { // Look backwards from the caret for an "@mention" trigger. $before = mb_substr($text, 0, $start, 'UTF-8'); if (preg_match('/@(\w*)$/u', $before, $m)) { $this->showMentionSuggestions($m[1]); } else { $this->hideMentionSuggestions(); } }
Date & Time Pickers
<native:date-picker> wraps SwiftUI's DatePicker and Material 3's
DatePicker / TimePicker behind one API.
<native:date-picker label="Appointment" mode="datetime" native:model="appointmentAt" min="2026-01-01" max="2026-12-31" timezone="Europe/Berlin" locale="de-DE" @change="appointmentChanged" />
use Native\Mobile\UI\Elements\DatePicker; DatePicker::make() ->label('Appointment') ->mode('datetime') ->value($this->appointmentAt) // string or any DateTimeInterface ->min('2026-01-01') ->timezone('Europe/Berlin') ->locale('de-DE') ->onChange('appointmentChanged');
The value contract
Values cross the bridge as wall-clock ISO 8601 strings with no offset,
shaped by mode:
| mode | wire value | example |
|---|---|---|
date (default) |
Y-m-d |
2026-07-25 |
time |
H:i, always 24-hour |
14:30 |
datetime |
Y-m-d\TH:i |
2026-07-25T14:30 |
No UTC conversion ever crosses the bridge. That is deliberate: it is what
keeps the classic off-by-one-day bug out of the element. Android's
DatePickerState reports UTC-midnight epoch millis and SwiftUI's DatePicker
binds an instant, so each renderer converts on its own side against one
agreed calendar — neither ever ships an instant.
value, min, and max accept an ISO string or any DateTimeInterface
(Carbon included), and a value finer than the mode needs is truncated — so a
datetime column can drive a date-only picker without reformatting:
->mode('date')->value('2026-07-25T14:30:59Z') // serializes as 2026-07-25
An empty string clears the selection; an unparseable one throws.
Timezones and internationalization
timezone takes an IANA identifier and names the calendar the picker
operates in — what "today" means for an empty picker, and on iOS the
calendar used to convert between the bound instant and the wall-clock string.
It does not shift the wire value. Set it when your app pins a business
timezone instead of following the device; leave it unset to follow the device.
locale takes a BCP-47 tag and drives display only — month and weekday
names, weekday order, and the default clock convention. It never changes the
wire value, and the wire formatter is pinned to a Gregorian POSIX calendar so
a Buddhist- or Japanese-era locale can't leak a non-Gregorian year onto the
bridge.
hour-format (auto | 12 | 24) overrides the clock convention. auto
resolves from the locale on both platforms — Android asks
getBestDateTimePattern(locale, "jm") rather than reading the device's
24-hour system setting, so the same locale gives the same result either
side.
Display styles
picker-style picks the presentation, mapped to the nearest native idiom.
(It is not called display — that name is already flex/layout display on every
element.)
picker-style |
iOS | Android |
|---|---|---|
compact (default) |
.compact — tap to popover |
trigger field + modal dialog |
inline |
.graphical — embedded calendar |
embedded picker |
wheel |
.wheel — drum |
no drum in Material; falls back to embedded |
Platform notes
title,confirm-label, andcancel-labelare Android only — iOS commits on selection and has no dialog chrome to label. They are still user-visible strings, so pass translated values:->confirmLabel(__('Done')).- On iOS with
picker-style="compact"and no value, a placeholder trigger stands in until first tap, because SwiftUI's compact picker always renders a concrete date and has no empty state. - With
picker-style="inline"and no initial value, neither platform commits the seeded "today" — you get a change event only once the user actually picks. a11y-label/a11y-hintare plumbed on both platforms; the current selection is additionally announced as the control's accessibility value.min/maxare rejected formode="time". Neither platform can enforce a time-of-day range — SwiftUI'sin:bounds an absolute instant, and Material 3'sTimePickerhas no bounds API — so passing them throws rather than silently doing nothing. Validate the chosen time in your component.picker-style="inline"falls back to compact formode="time"on iOS. SwiftUI's.graphicalstyle is date-only. Android embeds the time picker as asked.- Sync-mode modifiers are rejected. A picker commits discretely, so
native:model.blur/native:model.debounce.300mshave nothing to defer; they throw. Use plainnative:model.
Testing
The plugin registers picker vocabulary on the test harness, so screens read in picker terms rather than raw select-change plumbing:
Native::visit('/booking') ->pickDate('startsOn', '2026-12-24') ->pickTime('opensAt', new DateTimeImmutable('18:05')) ->pickDateTime('appointment', '2027-03-01T07:45') ->clearPicker('deadline') ->assertPicker('Starts', 'date') ->assertPickerValue('Starts', '2026-12-24') ->assertPickerEmpty('Deadline');
The pick* macros take an ISO string or any DateTimeInterface and
normalize to the wire shape for that mode before dispatching, so a test using
a Carbon instance or a full timestamp still sends exactly what the renderer
would. assertPicker* match on the picker's label.
Macros register only under a test runner, and only on a core whose
TestableComponent is macroable — the same method_exists gate the camera
plugin uses for its FakeBridge macros.
Emoji Picker
<native:emoji-picker> shows the platform's own emoji chooser, so people get
search, recents and skin tones without the app drawing a grid of its own.
It takes no space in the layout; put it anywhere in the screen.
<native:emoji-picker :visible="$picking" @change="react" @dismiss="closePicker" />
public bool $picking = false; public function react(string $emoji): void { $this->picking = false; // ... } public function closePicker(): void { $this->picking = false; }
- iOS has no public emoji picker view, so the element raises the system
emoji keyboard on a hidden field, with a Done bar above it (
done-labelrenames it). This is the same keyboard Messages opens for an emoji Tapback. If someone has removed the emoji keyboard in Settings they get their default keyboard, and anything that isn't an emoji is ignored. - Android shows Jetpack's
EmojiPickerView(androidx.emoji2) in a Material 3 bottom sheet: categories, recently used, and skin-tone, gender and direction variants on a long press.
@change gets each picked emoji as a string. The picker stays open until you
set visible to false, so close it in that handler for a single pick or leave
it open to collect several. @dismiss fires when the person closes it
themselves, never when PHP does.
The emoji is whatever the platform hands over: it can carry a skin-tone modifier or a variation selector, or be a sequence newer than your server accepts. Check it before you store or send it.
In tests, pick with ->select('react', '👍') and close with
->dismissSheet('closePicker').
Testing
Theme normalization and config write-back are pure PHP — no device, emulator,
or bridge round-trip required. Theme::load() / Theme::merge() resolve
authored color tokens (Tailwind names, red-300/20 opacity modifiers, CSS
#RRGGBBAA alpha hex) to wire-format hex, auto-derive a dark block, and mirror
the effective set into config('native-ui.theme.…'). You can assert every step
of that in a unit test:
use Illuminate\Config\Repository; use Illuminate\Container\Container; use Native\Mobile\UI\Theme; it('normalizes tokens and mirrors them into config', function () { Container::getInstance()->instance('config', new Repository); try { Theme::load([ 'light' => ['primary' => 'red-300', 'accent' => '#8B5CF680'], 'dark' => ['primary' => 'red-800'], ]); // Normalized tokens are readable via Theme::get('mode.token'): expect(Theme::get('light.primary'))->toBe('#FCA5A5'); // palette name expect(Theme::get('light.accent'))->toBe('#808B5CF6'); // CSS alpha → wire ARGB // …and mirrored back so core's theme() helper reads wire-format hex: expect(config('native-ui.theme.light.primary'))->toBe('#FCA5A5'); expect(config('native-ui.theme.dark.primary'))->toBe('#991B1B'); } finally { Container::setInstance(null); } });
Element color and typography props share the same grammar and serialize the
same way. Elements expose toArray(new CallbackRegistry) (via
NativeElementCollector), so you can assert what lands on the wire:
use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\UI\Elements\Button; it('serializes typography props on an element', function () { $props = Button::make('Save')->font('Inter-Bold')->toArray(new CallbackRegistry)['props']; expect($props['font_name'])->toBe('Inter-Bold'); });
Keeping Theme::pushToNative() off the wire
Theme::load() / merge() fire a NativeUI.Theme.Set bridge call on every
change. In a full Laravel test app, pushToNative()'s runningUnitTests()
guard suppresses it. In plain Pest (no booted app), that guard can't trip,
so mute the bridge in beforeEach() — the same pattern the plugin's own tests
use — and reset() between tests:
use Native\Mobile\JumpBridge; use Native\Mobile\UI\Theme; beforeEach(function () { JumpBridge::instance()->mute(); Theme::reset(); }); afterEach(fn () => Theme::reset());
License
MIT