From e644d9bc0b1b7d055e6bbf16d6fb93c2a50b0349 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 12 Jun 2026 17:34:41 -0500 Subject: [PATCH 1/6] =?UTF-8?q?MAUI=20Phase=204=20Slice=201=20=E2=80=94=20?= =?UTF-8?q?NavigationPageHandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces stock NavigationViewHandler (AppCompat Toolbar + Fragment backstack) with a Compose-backed handler that renders the NavigationPage stack through Material 3 Scaffold + TopAppBar. - Handlers/NavigationPageHandler.cs: ViewHandler + INavigationViewHandler. CommandMapper["RequestNavigation"] snapshots the new stack, bumps a MutableState version counter (read inside the body builder to register a snapshot read), and calls view.NavigationFinished synchronously so MAUI's NavigationProxy completes the outstanding Push/PopAsync Task. The body hosts the current top page in a long-lived AndroidView { factory = FrameLayout } whose update lambda swaps its single child; popped pages keep their PlatformView (no DisconnectHandler) so back-navigation reuses them. The TopAppBar reads Page.Title and renders an IconButton back arrow whenever stack depth > 1; hardware back falls through MAUI's standard HandleBackButton plumbing. - Hosting registration: handlers.AddHandler() inside UseAndroidXCompose, plus an updated remarks bullet. - Sample: "Navigation" demo (NavigationDemoPage) pushes a modal NavigationPage hosting NavStackPage so the gallery shell (still stock-rendered) can verify push / pop / hardware back / back arrow end-to-end. - docs/maui-backend.md: expanded Phase 4 section with Slice 1 details + investigation summaries for the deferred Slices 2-5 (TabbedPage, FlyoutPage, Shell, ModalNavigationManager) — each documents the stock behaviour, the M3 gap, and the smallest narrow fix. NavigationRequest.Animated is captured but ignored for v1; the body swap is immediate. Wrapping it in Compose AnimatedContent for slide / fade motion is a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/maui-backend.md | 88 +++++- .../AppShell.xaml.cs | 1 + .../HomePage.xaml.cs | 5 + .../Pages/NavStackPage.cs | 89 ++++++ .../Pages/NavigationDemoPage.xaml | 39 +++ .../Pages/NavigationDemoPage.xaml.cs | 22 ++ .../Handlers/NavigationPageHandler.cs | 257 ++++++++++++++++++ .../Hosting/AppHostBuilderExtensions.cs | 17 ++ 8 files changed, 507 insertions(+), 11 deletions(-) create mode 100644 src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavStackPage.cs create mode 100644 src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavigationDemoPage.xaml create mode 100644 src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavigationDemoPage.xaml.cs create mode 100644 src/Microsoft.AndroidX.Compose.Maui/Handlers/NavigationPageHandler.cs diff --git a/docs/maui-backend.md b/docs/maui-backend.md index 39c3fd97..c34a25a3 100644 --- a/docs/maui-backend.md +++ b/docs/maui-backend.md @@ -1890,17 +1890,83 @@ graph + theming inherited from the page root. ### Phase 4 — navigation (target: real apps) -- `NavigationViewHandler` → Compose `NavHost` + `NavController` (already - wrapped). Push/pop maps to `navController.Navigate` / `PopBackStack`. -- `TabbedViewHandler` → `NavigationBar` (bottom) or `TabRow` (top - depending on `BarPosition`). -- `FlyoutViewHandler` → `ModalNavigationDrawer`. -- `ShellHandler` → `Scaffold` + `ModalNavigationDrawer` + `NavigationBar` - - `NavHost` (URI-routed). This is the biggest single handler — keep - scope tight, target Shell's tabbed shape first, flyout second, routing - third. -- `ModalNavigationManager` — overlay `ComposeView` on the activity's - decor view; animate with `AnimatedVisibility`. +#### Slice 1 — `NavigationPageHandler` ✅ shipped + +`Microsoft.Maui.Controls.NavigationPage` is the simplest of the four +navigation surfaces and lands first. Stock MAUI registers +`NavigationPage → NavigationViewHandler` (the handler hosts pushed +pages in `Fragment`s under a `FragmentContainerView` + AppCompat +toolbar). `UseAndroidXCompose()` now overrides that mapping with +`NavigationPageHandler`, which owns a single root `ComposeView` and +renders the stack through Material 3 `Scaffold` + `TopAppBar`: + +- The current top page is hosted by a long-lived + `AndroidView { factory = … FrameLayout … }` whose `update` lambda + swaps the hosted `view.ToPlatform(context)` whenever the navigation + stack changes. Compose caches the `FrameLayout` across + recompositions, so push / pop only churns the inner child; the + popped page's `IElementHandler` is **not** disconnected, so popping + back reuses the same `PlatformView`. +- `IStackNavigation.RequestNavigation` is wired through the + `CommandMapper["RequestNavigation"] = MapRequestNavigation` entry. + It snapshots `request.NavigationStack`, bumps a + `MutableState _stackVersion` counter (read inside the body + builder so writes trigger recomposition), then calls + `view.NavigationFinished(...)` synchronously so MAUI's + cross-platform `NavigationProxy` completes the outstanding + `PushAsync` / `PopAsync` `Task`. +- The top app bar reads `Page.Title` and renders an + `IconButton(onClick: PopAsync)` back arrow whenever the stack + depth is > 1. Hardware back falls back to MAUI's standard + `Window.HandleBackButton` plumbing — that round-trips through + `IStackNavigation.RequestNavigation`, so no extra wiring needed. +- `NavigationRequest.Animated` is captured but ignored for v1. + Wrapping the body swap in Compose's `AnimatedContent` (slide / + fade) is a follow-up — needs a state-holder facade for + `AnimatedContent` first. + +The sample's "Navigation" demo pushes a modal `NavigationPage` +hosting `NavStackPage` (in-code, no XAML per depth level) so the +gallery can verify push / pop / hardware-back / top-bar back +arrow without converting the Shell host itself. + +#### Slices 2-5 — deferred follow-ups + +All three remaining navigation surfaces work **functionally** via +stock today (each registers against its concrete type, so our +`PageHandler` doesn't accidentally intercept them). The pages they +host already get our converted leaves. The remaining gap is purely +visual chrome: + +- **Slice 2 — `TabbedPageHandler`** (`TabbedPage` → + `NavigationBar` for `BarPosition.Bottom`, `TabRow` for + `BarPosition.Top`). Stock = AppCompat `BottomNavigationView`. + Compose-side facades (`NavigationBar`, `NavigationBarItem`, + `TabRow`) already shipped. Two-way `CurrentPage` binding + + per-tab content swap via the same `AndroidView` host pattern + Slice 1 uses. +- **Slice 3 — `FlyoutPageHandler`** (`FlyoutPage` → + `ModalNavigationDrawer`). Stock = `DrawerLayout`. The + `ModalNavigationDrawer` facade + `[ConfirmStateChange]` adapter + pattern (Phase 10 + 4c, see `ModalBottomSheet`) are in place; + hand-write a drawer state holder wired to `IFlyoutView`'s + `IsPresented`. +- **Slice 4 — `ShellHandler`** (closes #248). Stock `ShellRenderer` + works; the issue is its built-in `FlyoutItem` template uses MAUI + `Label`s, which our `LabelHandler` renders **without** any + Material chrome (no padding, font, ripple) — bare text. The + cleanest narrow fix is per-leaf "host context detection" inside + `LabelHandler` (when the leaf's parent chain goes through + `BaseShellItem`, switch to a `ListItem`-styled Compose body) + rather than rebuilding Shell on Compose. A full Shell handler + (`Scaffold` + `ModalNavigationDrawer` + `NavigationBar` + + URI-routed `NavHost`) is a multi-week effort and stays scoped + out. +- **Slice 5 — `ModalNavigationManager`**. Needs a DispatchProxy + intercept on MAUI's per-window `IModalNavigationManager`, + similar to the `ComposeAlertManagerSubscription` pattern (Phase + 2 Slice 9). Modal page rendered into a Compose `Dialog` / + fullscreen surface above the decor view's `ComposeView`. ### Phase 5 — graphics, gestures, shapes, BlazorWebView, infra diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs index 9e4b1d87..7645ca19 100644 --- a/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs @@ -36,5 +36,6 @@ public AppShell() Routing.RegisterRoute("refresh", typeof(RefreshPage)); Routing.RegisterRoute("indicator", typeof(IndicatorPage)); Routing.RegisterRoute("semantics", typeof(SemanticsPage)); + Routing.RegisterRoute("navigation", typeof(NavigationDemoPage)); } } diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs index ff1a0196..0fb84ddd 100644 --- a/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs @@ -135,6 +135,11 @@ public HomePage() "SemanticProperties.Description / Hint / HeadingLevel + AutomationId routed to Compose `Modifier.Semantics { … }`.", Color.FromArgb("#3F51B5"), "semantics"), + new DemoEntry( + "Navigation", + "Push / pop a modal `NavigationPage` rendered through Compose's Material 3 `Scaffold` + `TopAppBar`.", + Color.FromArgb("#1565C0"), + "navigation"), }; } diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavStackPage.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavStackPage.cs new file mode 100644 index 00000000..bb9bdc3d --- /dev/null +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavStackPage.cs @@ -0,0 +1,89 @@ +namespace Microsoft.AndroidX.Compose.Maui.Sample.Pages; + +/// +/// A single page hosted inside the Phase 4 Slice 1 modal +/// . Each instance pushes a deeper +/// copy on tap so the user can verify the back arrow + hardware +/// back button pop one level at a time. The "Close modal" button +/// pops the entire modal stack to return to the gallery. +/// +/// +/// Built entirely in code so the gallery doesn't need an extra +/// XAML pair per depth level. is +/// what Compose's TopAppBar reads in +/// — verify the +/// title swaps on every push / pop. +/// +public sealed class NavStackPage : ContentPage +{ + /// Construct a stack page at . + /// 1-based depth inside the modal navigation stack. + public NavStackPage(int depth) + { + Title = $"Stack page {depth}"; + + var header = new Label + { + Text = $"Depth {depth}", + FontSize = 28, + FontAttributes = FontAttributes.Bold, + HorizontalTextAlignment = TextAlignment.Center, + }; + + var caption = new Label + { + Text = depth == 1 + ? "You're at the root of the modal navigation stack. The top bar shows the page Title; there's no back arrow at depth 1." + : "Tap the ← arrow in the top bar (or hardware back) to pop back to the previous page in the stack.", + HorizontalTextAlignment = TextAlignment.Center, + FontSize = 14, + }; + + var pushBtn = new Button + { + Text = "Push next", + HorizontalOptions = LayoutOptions.Fill, + }; + pushBtn.Clicked += async (_, _) => + await Navigation.PushAsync(new NavStackPage(depth + 1)); + + var popBtn = new Button + { + Text = "Pop (Navigation.PopAsync)", + IsEnabled = depth > 1, + HorizontalOptions = LayoutOptions.Fill, + }; + popBtn.Clicked += async (_, _) => + { + if (Navigation.NavigationStack.Count > 1) + await Navigation.PopAsync(); + }; + + var closeBtn = new Button + { + Text = "Close modal", + BackgroundColor = Color.FromArgb("#B00020"), + TextColor = Colors.White, + HorizontalOptions = LayoutOptions.Fill, + }; + closeBtn.Clicked += async (_, _) => + await Navigation.PopModalAsync(); + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = new Thickness(30, 30), + Spacing = 20, + Children = + { + header, + caption, + pushBtn, + popBtn, + closeBtn, + }, + }, + }; + } +} diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavigationDemoPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavigationDemoPage.xaml new file mode 100644 index 00000000..09f4001c --- /dev/null +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavigationDemoPage.xaml @@ -0,0 +1,39 @@ + + + + + + + +