Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 85 additions & 11 deletions docs/maui-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> _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 &gt; 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----
//
Expand Down
89 changes: 89 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/NavStackPage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
namespace Microsoft.AndroidX.Compose.Maui.Sample.Pages;

/// <summary>
/// A single page hosted inside the Phase 4 Slice 1 modal
/// <see cref="NavigationPage"/>. 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.
/// </summary>
/// <remarks>
/// Built entirely in code so the gallery doesn't need an extra
/// XAML pair per depth level. <see cref="ContentPage.Title"/> is
/// what Compose's <c>TopAppBar</c> reads in
/// <see cref="Handlers.NavigationPageHandler"/> — verify the
/// title swaps on every push / pop.
/// </remarks>
public sealed class NavStackPage : ContentPage
{
/// <summary>Construct a stack page at <paramref name="depth"/>.</summary>
/// <param name="depth">1-based depth inside the modal navigation stack.</param>
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,
},
},
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Microsoft.AndroidX.Compose.Maui.Sample.Pages.NavigationDemoPage"
Title="Navigation">

<!--
Phase 4 Slice 1 launcher. The MAUI gallery shell uses stock
`ShellRenderer` to host its content (stock still wins the
`Shell` registration), so the Compose-backed
`NavigationPageHandler` is exercised by pushing a *modal*
`NavigationPage` from this page. Inside the modal, push /
pop / hardware-back all flow through
`IStackNavigation.RequestNavigation` → our handler.
-->
<ScrollView>
<VerticalStackLayout Padding="30,30" Spacing="25">

<Label Text="Stack navigation"
Style="{StaticResource Headline}"
SemanticProperties.HeadingLevel="Level1" />

<Label Text="Tap the button below to push a modal `NavigationPage` whose chrome is rendered by the Compose-backed `NavigationPageHandler` (Material 3 `Scaffold` + `TopAppBar`)."
Style="{StaticResource SubHeadline}"
SemanticProperties.HeadingLevel="Level2" />

<Label Text="Once inside the modal stack:&#x0a;• Tap “Push next” to navigate forward.&#x0a;• Tap the ← arrow in the top bar (or use the hardware back button) to pop.&#x0a;• Tap “Close modal” on any page to dismiss the entire modal stack."
FontSize="13" />

<Button x:Name="LaunchBtn"
Text="Launch navigation stack"
BackgroundColor="{StaticResource Primary}"
TextColor="White"
HorizontalOptions="Fill"
Clicked="OnLaunchClicked" />
</VerticalStackLayout>
</ScrollView>

</ContentPage>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Microsoft.AndroidX.Compose.Maui.Sample.Pages;

/// <summary>
/// Phase 4 Slice 1 launcher. Pushes a modal
/// <see cref="NavigationPage"/> hosting <see cref="NavStackPage"/> so
/// the Compose-backed <see cref="Handlers.NavigationPageHandler"/>
/// renders the chrome instead of stock <c>NavigationViewHandler</c>.
/// </summary>
public partial class NavigationDemoPage : ContentPage
{
/// <summary>Build the page.</summary>
public NavigationDemoPage()
{
InitializeComponent();
}

async void OnLaunchClicked(object? sender, EventArgs e)
{
var nav = new NavigationPage(new NavStackPage(depth: 1));
await Navigation.PushModalAsync(nav);
}
}
30 changes: 30 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui/ComposeElementHandler.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using AndroidX.Compose;
using AndroidX.Compose.Runtime;
using AndroidX.Compose.UI.Platform;
using Microsoft.AndroidX.Compose.Maui.Platform;
using Microsoft.Maui.Handlers;

namespace Microsoft.AndroidX.Compose.Maui;
Expand Down Expand Up @@ -50,13 +51,42 @@ public abstract class ComposeElementHandler<TVirtualView> : ViewHandler<TVirtual
{
readonly MutableState<int> _viewPropertiesVersion = new(0);

/// <summary>
/// The DI-registered <see cref="ThemeManager"/> singleton (or
/// <c>null</c> when the consumer skipped
/// <see cref="Hosting.AppHostBuilderExtensions.UseAndroidXCompose"/>).
/// Cached once per handler from
/// <see cref="ViewHandler.MauiContext"/> via
/// <see cref="SetMauiContext"/> so subclasses can read
/// <see cref="ThemeManager.IsDark"/> inside <see cref="BuildNode"/>
/// without paying a DI lookup per recomposition.
/// </summary>
/// <remarks>
/// Reading <see cref="ThemeManager.IsDark"/>'s
/// <see cref="MutableState{T}.Value"/> inside the composable scope
/// registers a snapshot read, so flipping MAUI's
/// <see cref="Microsoft.Maui.Controls.Application.RequestedTheme"/>
/// recomposes any subscribing leaf against the new theme.
/// </remarks>
protected ThemeManager? Theme { get; private set; }

/// <inheritdoc/>
protected ComposeElementHandler(IPropertyMapper mapper, CommandMapper? commandMapper = null)
: base(mapper, commandMapper) { }

/// <inheritdoc/>
protected sealed override ComposeView CreatePlatformView() => new(Context);

/// <inheritdoc/>
public override void SetMauiContext(IMauiContext mauiContext)
{
base.SetMauiContext(mauiContext);
// Re-cache on every SetMauiContext call so the Theme refresh
// tracks any context swap MAUI may push through (rare in
// practice but contractually allowed).
Theme = mauiContext.Services.GetService<ThemeManager>();
}

/// <inheritdoc/>
public override void SetVirtualView(IView view)
{
Expand Down
40 changes: 39 additions & 1 deletion src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,47 @@ public override ComposableNode BuildNode(IComposer composer)
// Subscribe so padding mapper bumps re-run BuildNode.
_ = _paddingVersion.Value;
var padding = virtualView.Padding;

// Resolve the text color. Three paths:
//
// 1. MAUI's `TextColor` is set → use it verbatim (the most
// common case; users who care set this explicitly).
// 2. `TextColor` is null AND we know the app's active theme
// via the inherited <see cref="Theme"/> cache → pick
// `White` on dark / `Black` on light. This mirrors what
// MAUI's stock `LabelHandler` does (it reads
// `Resources.GetColorStateList` for the active
// configuration) and — critically — fixes #248: when our
// `LabelHandler` runs as a Compose leaf inside a stock
// host (e.g. `Shell`'s built-in `FlyoutItem` template),
// there's no enclosing `MaterialTheme` / `Surface` to set
// `LocalContentColor`. Without an explicit color,
// Compose's `Text` would fall through to
// `LocalContentColor.current` which defaults to
// `Color.Black` — black-on-dark in dark mode, invisible.
// 3. No `ThemeManager` registered (consumer skipped
// `UseAndroidXCompose`) → fall through with `null` so
// `Text` keeps its pre-existing inherited-from-
// `LocalContentColor` behaviour.
//
// Reading `Theme.IsDark.Value` inside the composable scope
// registers a snapshot read, so flipping the MAUI theme at
// runtime recomposes the label against the new fallback.
ComposeColor? color;
if (packed.HasValue)
{
color = new ComposeColor(packed.Value);
}
else
{
color = Theme is null
? null
: Theme.IsDark.Value ? ComposeColor.White : ComposeColor.Black;
}

var text = new ComposeText(_text.Value)
{
Color = packed.HasValue ? new ComposeColor(packed.Value) : null,
Color = color,
FontSize = size.HasValue ? new Sp(size.Value) : null,
FontWeight = bold ? ComposeFontWeight.Bold : null,
Align = align switch
Expand Down
Loading
Loading