diff --git a/docs/maui-backend.md b/docs/maui-backend.md index e47423b5..ecbbb42d 100644 --- a/docs/maui-backend.md +++ b/docs/maui-backend.md @@ -1890,17 +1890,91 @@ 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 works; the + visible regression was that the built-in `FlyoutItem` template's + MAUI `Label`s rendered through our `LabelHandler` without any + enclosing Compose composition — `Color.Unspecified` falls + through to `LocalContentColor.current` which defaults to + `Color.Black`, so flyout titles disappeared on dark mode. Slice + 1 fixes that narrowly: `LabelHandler` now resolves + `ThemeManager` once during `SetMauiContext` and, when MAUI's + `TextColor` is unset, picks `Color.White` on dark / `Color.Black` + on light from the cached `IsDark` `MutableState` (snapshot read + in `BuildNode` so theme flips recompose). This matches MAUI's + own stock `LabelHandler` (which resolves the same defaults from + the active configuration's `colorPrimaryText` state list). No + base-class wrap, no per-leaf `Surface` (which would paint + mismatched tiles on top of stock chrome). The Shell chrome + itself remains stock `DrawerLayout`; a full Compose Shell + handler (`Scaffold` + `ModalNavigationDrawer` + `NavigationBar` + + URI-routed `NavHost`) stays scoped out as a multi-week + follow-up. +- **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 14eeeefe..b7e10b01 100644 --- a/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs @@ -36,6 +36,7 @@ public AppShell() Routing.RegisterRoute("refresh", typeof(RefreshPage)); Routing.RegisterRoute("indicator", typeof(IndicatorPage)); Routing.RegisterRoute("semantics", typeof(SemanticsPage)); + Routing.RegisterRoute("navigation", typeof(NavigationDemoPage)); // Phase 5 — pages that exercise self-drawing AndroidView-hosted // controls (Shapes, GraphicsView). Kept as on-device reproducers diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs index 740beda6..5242ab62 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"), // ---- Phase 5 — self-drawing AndroidView fallback ---- // 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 @@ + + + + + + + +