Search by

nativephp / mobile-ui

simonhampshanerbaner82

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!

Other

Statistics

Installs: 42 935

Dependents: 6

Suggesters: 0

Stars: 21

Open Issues: 22


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/mobile 4.0+, which ships the text_selection callback 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:model sync mode: pairing @selectionChange with native:model.blur or .debounce still 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 value onto 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-only inputs 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, and cancel-label are 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-hint are plumbed on both platforms; the current selection is additionally announced as the control's accessibility value.
  • min / max are rejected for mode="time". Neither platform can enforce a time-of-day range — SwiftUI's in: bounds an absolute instant, and Material 3's TimePicker has 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 for mode="time" on iOS. SwiftUI's .graphical style 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.300ms have nothing to defer; they throw. Use plain native: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-label renames 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