From 5e1abd6c1576270ec0fb33807b405ee9869f198c Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 14 Nov 2026 15:57:59 -0500 Subject: [PATCH] Document mobile 4.8.0: native chrome default, handler assignments, #[Memo], SlideToBottom, app lifecycle events --- .../4/digging-deeper/lifecycle-hooks.md | 45 ++++++++++++++++ .../mobile/4/edge-components/bottom-sheet.md | 4 ++ .../mobile/4/edge-components/introduction.md | 34 ++++++++++++ .../docs/mobile/4/edge-components/modal.md | 4 ++ .../docs/mobile/4/edge-components/top-bar.md | 29 +++++++++-- .../mobile/4/getting-started/upgrade-guide.md | 35 +++++++++++++ .../views/docs/mobile/4/the-basics/events.md | 2 + .../views/docs/mobile/4/the-basics/layouts.md | 43 +++++++++++++++ .../mobile/4/the-basics/nested-components.md | 52 +++++++++++++++++++ .../views/docs/mobile/4/the-basics/routing.md | 29 ++++++++++- 10 files changed, 273 insertions(+), 4 deletions(-) diff --git a/resources/views/docs/mobile/4/digging-deeper/lifecycle-hooks.md b/resources/views/docs/mobile/4/digging-deeper/lifecycle-hooks.md index 0b00bc41..7ba00ae6 100644 --- a/resources/views/docs/mobile/4/digging-deeper/lifecycle-hooks.md +++ b/resources/views/docs/mobile/4/digging-deeper/lifecycle-hooks.md @@ -166,6 +166,51 @@ class Dashboard extends NativeComponent +## The app going to the background + + + +The hooks above follow screens. To react when the whole app leaves the foreground and comes back, listen for the +`AppBackgrounded` and `AppForegrounded` events: + +```php +use Native\Mobile\Attributes\On; +use Native\Mobile\Events\App\AppBackgrounded; +use Native\Mobile\Events\App\AppForegrounded; + +#[On(AppBackgrounded::class)] +public function backgrounded(): void +{ + // Save a draft. The OS may suspend PHP right after this. +} + +#[On(AppForegrounded::class)] +public function foregrounded(int $secondsAway): void +{ + // Away for more than five minutes? Reload anything that may be stale. +} +``` + +- `AppBackgrounded` fires when the user goes home, switches apps, or locks the device. The OS may suspend PHP right + after it, so save what must survive and don't start slow work. +- `AppForegrounded` fires when the app comes back. `$secondsAway` is measured natively on a clock that keeps counting + while the device sleeps, which makes it a good fit for timeouts such as locking the app or refreshing stale data. + A cold launch does not fire it. + +Returning to the app doesn't call `onResume()` — that only runs when a screen is shown again after the one above it +is popped. `AppForegrounded` is the hook for "the user is back". + + + +Both events [broadcast globally](../the-basics/events#broadcasting-globally), so you can also listen anywhere in the +app with `Event::listen(AppBackgrounded::class, ...)`. + ## Observing the lifecycle from outside diff --git a/resources/views/docs/mobile/4/edge-components/bottom-sheet.md b/resources/views/docs/mobile/4/edge-components/bottom-sheet.md index 2a192834..f0750daf 100644 --- a/resources/views/docs/mobile/4/edge-components/bottom-sheet.md +++ b/resources/views/docs/mobile/4/edge-components/bottom-sheet.md @@ -30,6 +30,10 @@ Per Material 3, the container color resolves from `theme.surface`. For a custom ``` @endverbatim +Handlers like `@press="$show = true"` and `@dismiss="$show = false"` set the property directly — no component method +needed . Keep the property in sync on `@dismiss` so the sheet opens again the +next time you set it to `true`. See [Handlers](introduction#handlers) for the full syntax. + ## Props - `visible` - Whether the sheet is shown (required, boolean) diff --git a/resources/views/docs/mobile/4/edge-components/introduction.md b/resources/views/docs/mobile/4/edge-components/introduction.md index a87e9029..d9e0a7d6 100644 --- a/resources/views/docs/mobile/4/edge-components/introduction.md +++ b/resources/views/docs/mobile/4/edge-components/introduction.md @@ -73,6 +73,40 @@ Boolean attributes follow PHP truthiness. A bare attribute (`disabled`) or a bou as you'd expect, but a literal string such as `disabled="false"` is a non-empty string and counts as `true`. Bind booleans with `:` whenever the value can be false. +## Handlers + +Event attributes such as `@press`, `@tap`, `@change`, and `@dismiss` take a handler. A handler is one of: + +- a method name: `@press="save"` +- a method call with literal arguments: `@press="selectItem(@{{ $item->id }})"` +- an assignment to a public property : `@press="$showSheet = true"` +- a bool toggle : `@press="$expanded = !$expanded"` + +@verbatim +```blade + + + + {{-- ... --}} + + + + Settings + +``` +@endverbatim + +The assigned value must be a literal: `true`, `false`, `null`, a number, or a quoted string. Nothing is evaluated, so +`$open = foo()`, `$open = $other`, and `$open = !$other` don't run. A toggle only works on a property that holds a +bool. Assignments go through the same state pipeline as [data binding](../digging-deeper/data-binding), so +`#[Locked]` properties are refused and the `updating` / `updated` hooks fire. The same syntax works in a +[nested component's](../the-basics/nested-components#events-up) event binding. + +A handler that can't run is never dropped silently. With `app.debug` on, it throws +`Native\Mobile\Edge\Exceptions\InvalidHandlerException`, and the error screen names the handler and the component. +With debug off, it's logged and nothing runs. That covers a method the component doesn't have, an expression handlers +don't support, a property that isn't public, and a toggle on a property that isn't a bool. + ## The `native:` prefix is optional `` and `` compile to exactly the same thing — the prefix is optional. We use it throughout diff --git a/resources/views/docs/mobile/4/edge-components/modal.md b/resources/views/docs/mobile/4/edge-components/modal.md index 7aec6c4a..e80213c6 100644 --- a/resources/views/docs/mobile/4/edge-components/modal.md +++ b/resources/views/docs/mobile/4/edge-components/modal.md @@ -31,6 +31,10 @@ Per Material 3, backdrop and surface colors come from `theme.background`. The cl ``` @endverbatim +Handlers like `@press="$show = true"` and `@dismiss="$show = false"` set the property directly — no component method +needed . Keep the property in sync on `@dismiss` so the modal opens again the +next time you set it to `true`. See [Handlers](introduction#handlers) for the full syntax. + ## Props - `visible` - Whether the modal is shown (required, boolean) diff --git a/resources/views/docs/mobile/4/edge-components/top-bar.md b/resources/views/docs/mobile/4/edge-components/top-bar.md index 493d3cf8..35378f28 100644 --- a/resources/views/docs/mobile/4/edge-components/top-bar.md +++ b/resources/views/docs/mobile/4/edge-components/top-bar.md @@ -102,6 +102,9 @@ On iOS, if more than 5 actions are provided, they collapse into an overflow menu - `material-variant` - Material style hint for Android, e.g. `outlined` (optional). Set automatically when you use an `AndroidOutlined` enum case - `label` - Text label. Used for accessibility and displayed in overflow menus (optional but recommended) - `url` - A URL to navigate to when tapped (optional) +- `transition` - The transition used when tapping navigates to `url`, as a + [transition](../the-basics/routing#custom-transitions) value such as `slide_from_bottom` (optional, default: `none`). + Only applies to an action with a `url` and no `@tap` - `destructive` - Render in the destructive (red) tint (optional, boolean) +To present the destination like a sheet, give the action a `transition`: + +@verbatim +```blade static + +``` +@endverbatim + +The settings screen can then dismiss itself with `$this->back(Transition::SlideToBottom)`. See +[Custom transitions](../the-basics/routing#custom-transitions). + ### Platform icons `` resolves icons through the same contract as [``](icon#typed-icon-enums): a @@ -205,9 +225,12 @@ class PhotoViewer extends NativeComponent This is equivalent to returning `NavBarOptions::make()->hidden()` from `navigationOptions()`; if both are set, the explicit builder wins. It's the top-bar parallel to the tab bar's [`hidden()`](bottom-nav#per-screen-tab-bar). -Navigation keeps working while the bar is hidden — pushes, pops, and `@navigate` all behave normally. With the bar -hidden the screen is full-bleed: on Android the content extends up to the very top edge, under the transparent -status bar; use the safe-area utilities on elements that should stay clear of the clock and status icons. On iOS +With native chrome, the bar isn't removed: its config stays on the screen and is marked hidden, so the +`NavigationStack` stays in place. Navigation keeps working while the bar is hidden — pushes, pops, and `@navigate` all +behave normally. + +With the bar hidden the screen is full-bleed: on Android the content extends up to the very top edge, under the +transparent status bar; use the safe-area utilities on elements that should stay clear of the clock and status icons. On iOS the edge-swipe-back gesture is tied to the visible bar, so a pushed screen that hides it should render its own back control (for example a floating button that uses `@navigate` to return to the parent screen). diff --git a/resources/views/docs/mobile/4/getting-started/upgrade-guide.md b/resources/views/docs/mobile/4/getting-started/upgrade-guide.md index bcb3c791..51829932 100644 --- a/resources/views/docs/mobile/4/getting-started/upgrade-guide.md +++ b/resources/views/docs/mobile/4/getting-started/upgrade-guide.md @@ -3,6 +3,41 @@ title: Upgrade Guide order: 3 --- +## Upgrading To 4.8 From 4.7 + +After `composer update nativephp/mobile`, run `php artisan native:install --force`. `composer update` doesn't touch +the native iOS and Android projects, so the navigation and transition changes in 4.8 only reach your app after a +reinstall. + +### Layouts use native chrome by default + +`NativeLayout::usesNativeChrome()` now returns `true`. If your layouts never overrode it, their nav and tab bars switch +from the custom-drawn `Column` chrome to the platform's native renderers (`NavigationStack` / `TabView` on iOS, +`NavHost` / `Scaffold` on Android). Inline `` and `` were already native, so this +brings layout-supplied bars in line with them. + +Open a few existing screens on a device or simulator and check the chrome, especially screens that set +`$hidesNavBar` or `$hidesTabBar`. With native chrome those bars are marked hidden instead of being removed from the +tree. + +To keep the old chrome for now, override the method and return `false`: + +```php +public function usesNativeChrome(): bool +{ + return false; +} +``` + +This opt-out is temporary and will be removed. See [Native chrome](../the-basics/layouts#native-chrome). + +### Handlers that can't run now throw in debug + +Handlers such as `@press="$showSheet = true"` now work (see [Handlers](../edge-components/introduction#handlers)). +Handlers that can't run are no longer dropped silently: with `app.debug` on they throw `InvalidHandlerException`, and +with it off they're logged. If an error screen names a handler after you upgrade, it's usually a typo in a method name +or an assignment whose value isn't a literal. + ## Upgrading To 4.6 From 4.5 ### Translated iOS permission strings need `supported_locales` diff --git a/resources/views/docs/mobile/4/the-basics/events.md b/resources/views/docs/mobile/4/the-basics/events.md index 5690b998..f4d11fe2 100644 --- a/resources/views/docs/mobile/4/the-basics/events.md +++ b/resources/views/docs/mobile/4/the-basics/events.md @@ -409,6 +409,8 @@ These events of the package carry the marker: | Event | Fires when | Arguments | More | | --- | --- | --- | --- | +| `Native\Mobile\Events\App\AppBackgrounded` | The app goes to the background | None | [Lifecycle Hooks](../digging-deeper/lifecycle-hooks#the-app-going-to-the-background) | +| `Native\Mobile\Events\App\AppForegrounded` | The app comes back from the background | `secondsAway` | [Lifecycle Hooks](../digging-deeper/lifecycle-hooks#the-app-going-to-the-background) | | `Native\Mobile\Events\System\AppearanceChanged` | The system switches between light and dark | `mode` | [Theming](../digging-deeper/theming#reacting-to-changes) | | `Native\Mobile\Events\System\OrientationChanged` | The app window turns to portrait or landscape | `orientation` | [System](system#orientation) | | `Native\Mobile\Events\Device\ThermalStateChanged` | The thermal state of the device changes | `state`, `previous` | [Device](device#events) | diff --git a/resources/views/docs/mobile/4/the-basics/layouts.md b/resources/views/docs/mobile/4/the-basics/layouts.md index d755d9a2..f05b68cc 100644 --- a/resources/views/docs/mobile/4/the-basics/layouts.md +++ b/resources/views/docs/mobile/4/the-basics/layouts.md @@ -115,6 +115,42 @@ class SyncUpTabsLayout extends NativeLayout The `$screen` parameter is the live `NativeComponent` instance for the current screen, so the layout can read properties or methods on it (such as `$screen->navTitle()`) to customize the chrome per screen. +## Native chrome + + + +Layouts render their bars with the platform's own navigation chrome: a `NavigationStack` and `TabView` on iOS, a +`NavHost` and `Scaffold` on Android. You get edge-swipe back, predictive back, large titles, and Liquid Glass on +iOS 26+ without any extra setup. This is controlled by `usesNativeChrome()`, which returns `true` by default. + +Before 4.8, the default was `false`, and a layout had to override `usesNativeChrome()` to get native chrome. A layout +that never overrode it now renders native bars. + +To fall back to the custom-drawn `Column` chrome, override it and return `false`: + +```php +use Native\Mobile\Edge\Layouts\NativeLayout; + +class LegacyLayout extends NativeLayout +{ + public function usesNativeChrome(): bool + { + return false; + } +} +``` + +> [!WARNING] +> The `Column` fallback is temporary. The switch will be removed, and native chrome will be the only option. Treat +> `return false` as a stopgap while you move a layout over. + +### Hiding bars with native chrome + +`$hidesNavBar` / `NavBarOptions::make()->hidden()` and `$hidesTabBar` / `TabBarOptions::make()->hidden()` still hide +a bar on one screen, but native chrome hides it differently. The `Column` path drops the bar from the tree. With +native chrome, the bar's config is kept and marked hidden, so the `NavigationStack` and `TabView` stay in place and +pushes, pops, and tab switches keep working across screens that hide a bar. + ## Builder reference The chrome is described entirely with these fluent builders — you never place a top bar or tab bar as an element @@ -421,6 +457,13 @@ When a screen renders, the framework's `wrapWithChrome` flow: 2. Calls `$layout->navBar($screen)` and `$layout->tabBar($screen)`. 3. Merges in any `navigationOptions()` declared on the screen. 4. Merges in any imperative state set via `$this->setNavBar([...])` / `$this->setTabBar([...])`. + +With [native chrome](#native-chrome) (the default), the screen content is then wrapped in a native root element that +carries the bar config as props, and the platform's navigation renderer draws the bars. You don't manage safe areas +for the bars yourself. + +A layout that returns `false` from `usesNativeChrome()` takes the `Column` path instead: + 5. Wraps the screen content in a `Column` filling the screen, with the bars stacked above and below the content. 6. Picks the right safe-area variant for the wrapper: - **TabBar present** → wrapper uses `safeAreaTop()`; the bar handles its own bottom inset. diff --git a/resources/views/docs/mobile/4/the-basics/nested-components.md b/resources/views/docs/mobile/4/the-basics/nested-components.md index 7ef214e3..970ff8fc 100644 --- a/resources/views/docs/mobile/4/the-basics/nested-components.md +++ b/resources/views/docs/mobile/4/the-basics/nested-components.md @@ -139,6 +139,16 @@ public function onTaskToggled(string $source, string $title, bool $done): void } ``` +A tag binding can also set one of the parent's properties instead of calling a method +, using the same `$property = ` syntax as any other +[handler](../edge-components/introduction#handlers). The emit's arguments are ignored: + +@verbatim +```blade static + +``` +@endverbatim + **`#[On]` listeners** — a string-form `#[On('event-name')]` method fires on *any* ancestor, however deep the emitter is. A grandchild's emit reaches the screen without the intermediate child forwarding anything: @@ -244,6 +254,48 @@ parameters other names. +## Skipping unchanged children with #[Memo] + + + +A screen re-runs its Blade view on every frame: every tap, keystroke, async result, or native event. Its children +re-render with it, so a long list of extracted rows rebuilds every row even when only one changed. Add the `#[Memo]` +attribute to a child whose output depends only on its own props and state: + +```php +namespace App\NativeComponents; + +use Illuminate\View\View; +use Native\Mobile\Attributes\Memo; +use Native\Mobile\Edge\NativeComponent; + +#[Memo] +class TaskRow extends NativeComponent +{ + public array $task = []; + + public function render(): View + { + return view('native.task-row'); + } +} +``` + +When nothing the child renders from has changed, it reuses the elements it built last time and skips its Blade view. +It re-renders when any of these change: + +- its public properties, both props and its own state +- the `@event` bindings on its mounting tag +- the public properties of any child nested inside it + +A child's first render and streaming renders are never memoized. + +> [!IMPORTANT] +> `#[Memo]` is opt-in because it only compares props, state, and bindings. If the child's `render()` or view also +> reads the time, the session, config, or the database, a memoized child keeps showing what it showed last. The same +> goes for a `#[Computed]` or `#[Poll]`-driven value that changes without a public property changing. Leave +> `#[Memo]` off those children. + ## Lifecycle - `mount()` runs when a child's key first appears in the parent's tree. diff --git a/resources/views/docs/mobile/4/the-basics/routing.md b/resources/views/docs/mobile/4/the-basics/routing.md index 67ff9e32..a743639a 100644 --- a/resources/views/docs/mobile/4/the-basics/routing.md +++ b/resources/views/docs/mobile/4/the-basics/routing.md @@ -53,6 +53,14 @@ $this->back(); Pops the current screen off the stack and returns to whatever was beneath it. The default transition is a slide-out to the right. +To dismiss a screen you presented from the bottom, pass a transition : + +```php +use Native\Mobile\Edge\Transition; + +$this->back(Transition::SlideToBottom); +``` + ## Replacing the current screen ```php @@ -80,11 +88,23 @@ native experience. ## Custom transitions -Chain `transition()` after a navigation method to override the default animation: +`navigate()`, `back()`, and `replace()` take an optional transition that overrides the default animation +. It's the last argument, after the URI and data: ```php use Native\Mobile\Edge\Transition; +$this->navigate('/settings', [], Transition::SlideFromBottom); +$this->replace('/login', [], Transition::None); +$this->back(Transition::SlideToBottom); +``` + +You can pass a `Transition` case or its string value, such as `'slide_from_bottom'`. Calls without a transition +behave as they did before. + +You can also chain `transition()` after a navigation method: + +```php $this->navigate('/item/42')->transition(Transition::SlideFromBottom); ``` @@ -93,6 +113,7 @@ Available cases: - `Transition::SlideFromRight` - default for `navigate()` - `Transition::SlideFromLeft` - default for `back()` - `Transition::SlideFromBottom` - modal-style presentation +- `Transition::SlideToBottom` - modal-style dismissal, the inverse of `SlideFromBottom`: the outgoing screen slides down off the bottom edge, revealing the screen beneath it. Pair the two for sheet-style present and dismiss - `Transition::Fade` - default for `replace()` - `Transition::FadeFromBottom` - subtle vertical fade - `Transition::ScaleFromCenter` - zoom-in effect @@ -100,6 +121,12 @@ Available cases: - `Transition::Morph` - cross-fade while elements that share a `ref` move between the two screens. See [Morphing elements between screens](#morphing-elements-between-screens) - `Transition::None` - swap with no animation + + ## Navigating from Blade The `@navigate` directive triggers navigation directly from your view, with no component method required. It works on