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
152 changes: 138 additions & 14 deletions docs/maui-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -1904,20 +1904,144 @@ graph + theming inherited from the page root.

### Phase 5 — graphics, gestures, shapes, BlazorWebView, infra

- `GraphicsViewHandler` → reuse MAUI's `PlatformGraphicsView` (Android
`View` subclass) inside an `AndroidView { ... }` composable, OR map
`ICanvas` to Compose's `Canvas` composable.
- `WebViewHandler` / `HybridWebViewHandler` → Android `WebView` inside
`AndroidView { ... }`. `BlazorWebViewHandler` already has an
Android-specific implementation in
`Microsoft.AspNetCore.Components.WebView.Maui` — reuse, just plug into
Compose's interop.
- Shapes (`Rectangle`/`Ellipse`/`Path`/...) → Compose `Canvas` drawing
via `DrawScope`.
- Gestures: MAUI's gesture recognizers already work on any Android
`View`. The challenge is `Tap`/`Pan`/`Pinch` competing with Compose's
own gesture detectors — `Modifier.PointerInput { }` may need to
forward gestures to MAUI's `GestureManager`.
#### Phase 5 Slice 1 — AndroidView fallback investigation ✅ shipped

Earlier phases (and the Phase 2 Slice 2 single-`ComposeView`-per-page
refactor in particular) folded the only "real" Compose backend
behaviour we needed for Phase 5 into `ComposeWalker`'s default
branch. Anything whose handler is not registered as `IComposeHandler`
goes through

```csharp
return new AndroidView(factory: _ => view.ToPlatform(mauiContext))
{
Modifier = sizeFromWidthHeightRequest,
};
```

— MAUI's stock handler still produces an Android `View`, and Compose
hosts it as `androidx.compose.ui.viewinterop.AndroidView`. MAUI's
mapper (`ShapeViewHandler`, `GraphicsViewHandler`, `WebViewHandler`,
…) keeps owning the platform view's measure / arrange / draw /
lifecycle.

##### Bug found and fixed in this slice

The first cut of the investigation (the version that originally
landed on this branch) shipped without on-device verification and
claimed that **every** Phase 5 control worked unchanged via the
fallback. On-device verification in a follow-up pass proved that
wrong for self-drawing views: `MauiShapeView` and
`PlatformGraphicsView` painted nothing — even forcing
`platformView.SetBackgroundColor(Color.Magenta)` produced an
invisible View, proving zero size.

Root cause: the fallback never applied a Compose `Modifier.Size` (or
`.Width` / `.Height`) derived from MAUI's `WidthRequest` /
`HeightRequest`. Without it Compose handed `AndroidView` an
unbounded slot, the embedded View measured at wrap-content, and
self-drawing Views with no intrinsic size collapsed to 0 × 0 and
their `Drawable` painted nothing. Compose-native handlers like
`BoxViewHandler` already do this themselves via
`IComposeHandler.BuildNode`; the fallback was missing the same
mapping.

The fix sits entirely inside `ComposeWalker.Render` — read
`WidthRequest` / `HeightRequest` off the `VisualElement` and
translate to `Modifier.Size` / `Modifier.Width` /
`Modifier.FillMaxWidth().Height(...)` exactly the way
`BoxViewHandler` does. No new public surface, no new handler.

##### Per-control matrix

| Control | Handler today | Path | Verdict |
|---|---|---|---|
| `Microsoft.Maui.Controls.Shapes.Rectangle` / `RoundRectangle` / `Ellipse` / `Line` / `Polygon` / `Polyline` / `Path` | stock `ShapeViewHandler` → `MauiShapeView` (Android `View` painting via `android.graphics.Canvas`) | `AndroidView` fallback | **Works via fallback** after the size-modifier fix above. Verified on-device on a Pixel 5 (`Pages/ShapesPage.xaml`): all seven shape kinds render, including `LinearGradientBrush` fills and SVG `Path` data. The `Fill-width Rectangle` row at the bottom of the page additionally exercises the `WidthRequest = -1, HeightRequest >= 0` branch (`Modifier.FillMaxWidth().Height(...)`). |
| `Microsoft.Maui.Controls.GraphicsView` (+ `IDrawable`) | stock `GraphicsViewHandler` → `PlatformGraphicsView` (Android `View` over `Microsoft.Maui.Graphics`'s Android canvas) | `AndroidView` fallback | **Works via fallback** after the size-modifier fix. Verified on-device (`Pages/GraphicsViewPage.xaml`): `IDrawable.Draw` paints the canvas, and `Invalidate()` repaints — tap-to-reshuffle bumps the seed and the line plot redraws. |
| `Microsoft.Maui.Controls.WebView` | stock `WebViewHandler` → `MauiWebView : android.webkit.WebView` | `AndroidView` fallback | **Works via fallback.** Caller must give the cell explicit `HeightRequest` (a Compose `Column` doesn't constrain unbounded children). Sample page deleted from this slice — the WebView is rich enough to host its own scroll, and the AndroidView host has no Compose-specific interaction; left to consumer apps to verify against their own URLs. |
| `Microsoft.Maui.Controls.HybridWebView` | stock `HybridWebViewHandler` (MAUI 9+) | `AndroidView` fallback (expected) | **Deferred.** Spot-checked while building the slice; not part of the on-device verification matrix. The handler shape is identical to plain `WebView` so we expect parity, but we have not exercised `SendRawMessage` / `RawMessageReceived` end-to-end yet. |
| `BlazorWebView` (`Microsoft.AspNetCore.Components.WebView.Maui`) | Android-specific stock handler shipped by the Blazor WebView package | `AndroidView` fallback (consumer adds the package reference) | **Works via fallback by construction** — `BlazorWebViewHandler` derives from `ViewHandler`, produces an Android `WebView`, and registers itself through stock MAUI hosting. The Compose backend does not need to know about Blazor. Not exercised in the sample to avoid pulling the package as a hard dependency. |
| `Microsoft.Maui.Controls.Grid` / `AbsoluteLayout` / `FlexLayout` / `StackLayout` | stock `LayoutHandler` → `LayoutViewGroup` (already used by `HomePage.xaml` since Phase 1) | `AndroidView` fallback | **Works via fallback.** Children inside the layout resolve through MAUI's normal handler resolution — Compose-backed leaves degrade to per-leaf `ComposeView` islands (Phase 1 shape). Sample page deleted from this slice — these layouts already ship in working form across other sample pages (e.g. `HomePage.xaml`'s `Grid` chrome) and adding a dedicated demo doesn't surface new Compose-specific behaviour. The cross-sibling-animation / per-layout-theming improvement requires the Compose-native `Layout {}` adapter (#144 — see follow-ups). |

##### What's intentionally not in Slice 1

- **Compose-native shape handlers via `Canvas` + `DrawScope`** —
parked behind issue #64 (drawing primitives binding). The
fallback paints correctly; lifting shapes into the page
composition only buys us cross-sibling animations between
shapes, which is a thin payoff against the size of #64 (medium-
high effort: `DrawScope` is ~30 hashed bridges over packed
inline-class params, plus a `Path` builder and `Brush` /
`Shape` factories).
- **Compose-native `GraphicsView` via `Canvas` + `IDrawable` →
`DrawScope` shim** — same trade-off as shapes, plus an extra
adapter to translate `Microsoft.Maui.Graphics.ICanvas` calls into
`DrawScope`. Out of scope until #64 ships.
- **Custom `Layout {}` adapter (#144)** — would let us register
`Grid`, `AbsoluteLayout`, `FlexLayout` as `IComposeHandler`s and
fold their measure-policy into the page composition (closing
the "per-leaf `ComposeView` island inside a fallback-hosted
layout" caveat above). Real bridge + generator work: bind
`androidx.compose.ui.layout.Layout`, `Measurable`, `Placeable`,
`MeasureScope`, `MeasureResult`, and the `PlacementScope`
DSL — out of scope for Slice 1, tracked by #144.
- **`HybridWebView` on-device verification** — handler shape is
identical to plain `WebView`; spot-check deferred to a
follow-up because it didn't surface new Compose-specific
behaviour worth the device time.
- **Hardening `Modifier.PointerInput` for MAUI `GestureManager`
hand-off** — gestures already work end-to-end through the
Phase 2 Slice 10 `GestureBridge` for Compose-backed leaves, and
through the embedded Android view's own touch system for
fallback leaves. No new work needed at the Phase 5 layer.

**Delivered:**

- `src/Microsoft.AndroidX.Compose.Maui/ComposeWalker.cs` — the
fallback path now reads `WidthRequest` / `HeightRequest` off the
MAUI `VisualElement` and applies a matching Compose `Modifier`
to the `AndroidView`. Internal change; no public-API delta.
- `src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ShapesPage.xaml(.cs)`
+ `GraphicsViewPage.xaml(.cs)` — on-device reproducers for the
bug above and proofs of the fix.
- `AppShell.xaml.cs` routes + `HomePage.xaml.cs` catalog entries
for those two pages.
- This investigation report.

**Verified:**

- `dotnet test src/Microsoft.AndroidX.Compose.SourceGenerators.Tests`
= 155 passed.
- `dotnet build src/Microsoft.AndroidX.Compose` clean.
- `dotnet build src/Microsoft.AndroidX.Compose.Maui` clean.
- `dotnet build src/Microsoft.AndroidX.Compose.Maui.Sample` clean
(zero warnings, zero errors).
- `dotnet build src/Microsoft.AndroidX.Compose.Gallery` clean.
- On-device on a Pixel 5: deployed, exercised `Pages/ShapesPage`
(all 7 shape kinds + fill-width Rectangle render) and
`Pages/GraphicsViewPage` (canvas paints, tap-to-reshuffle
invalidates and repaints).

#### Phase 5 follow-ups (not in Slice 1)

- `GraphicsViewHandler` Compose-native variant routing
`IDrawable.Draw` through a `Canvas { … }` composable wrapping
`DrawScope` (depends on #64).
- Compose-native shape handlers (Rectangle / Ellipse / Path / …)
via `DrawScope` (depends on #64).
- Custom `Layout {}` adapter (#144) → `GridHandler`,
`AbsoluteLayoutHandler`, `FlexLayoutHandler` that lift those
layouts into the page composition for cross-sibling animations
and per-layout theming. Most of the cost is the
`Measurable`/`Placeable`/`MeasureScope`/`PlacementScope`
bridge, not the per-layout adapter.
- `HybridWebView` on-device spot-check (parity with
`WebView` via the same fallback path expected, not yet
exercised).
- Gestures — MAUI's gesture recognizers already work on any
Android `View`. The Phase 2 Slice 10 `GestureBridge` covers the
Compose-folded leaves; fallback leaves use the embedded view's
own touch system. Nothing additional to do at Phase 5.

### Phase 6 — Essentials (parallel work, optional)

Expand Down
7 changes: 7 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,12 @@ public AppShell()
Routing.RegisterRoute("refresh", typeof(RefreshPage));
Routing.RegisterRoute("indicator", typeof(IndicatorPage));
Routing.RegisterRoute("semantics", typeof(SemanticsPage));

// Phase 5 — pages that exercise self-drawing AndroidView-hosted
// controls (Shapes, GraphicsView). Kept as on-device reproducers
// for the missing-`Modifier.Size` bug fixed in this slice. See
// docs/maui-backend.md (Phase 5).
Routing.RegisterRoute("shapes", typeof(ShapesPage));
Routing.RegisterRoute("graphics-view", typeof(GraphicsViewPage));
}
}
20 changes: 20 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,26 @@ public HomePage()
"SemanticProperties.Description / Hint / HeadingLevel + AutomationId routed to Compose `Modifier.Semantics { … }`.",
Color.FromArgb("#3F51B5"),
"semantics"),

// ---- Phase 5 — self-drawing AndroidView fallback ----
//
// These two entries exercise controls (`Shapes`,
// `GraphicsView`) whose stock Android view has no intrinsic
// size and so collapses to 0 × 0 inside Compose's `AndroidView`
// unless the fallback applies a `Modifier.Size` derived from
// `WidthRequest`/`HeightRequest`. Kept on the gallery as
// on-device reproducers for the bug fixed in this slice; see
// `docs/maui-backend.md` Phase 5 for the verdict matrix.
new DemoEntry(
"Shapes",
"Rectangle / Ellipse / Line / Polygon / Polyline / Path / RoundRectangle.",
Color.FromArgb("#7B1FA2"),
"shapes"),
new DemoEntry(
"GraphicsView",
"IDrawable canvas; tap to re-shuffle.",
Color.FromArgb("#00897B"),
"graphics-view"),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?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.GraphicsViewPage"
Title="GraphicsView">

<!--
Phase 5 investigation page — `GraphicsView` ships with the stock
`GraphicsViewHandler` whose platform view is `PlatformGraphicsView`
(an Android `View` subclass that calls `IDrawable.Draw` against a
`Microsoft.Maui.Graphics` canvas wrapping `android.graphics.Canvas`).

We host it through the `AndroidView` fallback in `ComposeWalker`.
The drawable below renders a sparkline + filled gradient circle —
if the fallback works the figure appears at the requested 320 × 200,
re-paints when `Invalidate()` is called, and reacts to taps.
-->
<ScrollView>
<VerticalStackLayout
Padding="30,30"
Spacing="22">

<Label
Text="GraphicsView"
Style="{StaticResource Headline}" />

<Label
Text="`GraphicsView` / `IDrawable` should render via the stock `GraphicsViewHandler` → `PlatformGraphicsView` hosted by Compose `AndroidView`. Tap the canvas to bump the random seed and invalidate."
FontSize="13" />

<Border Padding="0" StrokeThickness="1" Stroke="#1A000000" StrokeShape="RoundRectangle 8">
<GraphicsView
x:Name="GraphicsCanvas"
HeightRequest="200"
WidthRequest="320"
HorizontalOptions="Center"
StartInteraction="OnTap" />
</Border>

<Label x:Name="SeedLabel" Text="Seed: 0 · Tap to re-shuffle" FontSize="13" HorizontalTextAlignment="Center" />

</VerticalStackLayout>
</ScrollView>

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

/// <summary>
/// GraphicsView demo — drives an <see cref="IDrawable"/> through the
/// stock <c>GraphicsViewHandler</c> hosted by Compose
/// <see cref="AndroidX.Compose.AndroidView"/>. Tapping the canvas
/// bumps a seed and re-shuffles the drawing, which exercises both
/// the fallback's repaint path and tap routing.
/// </summary>
public partial class GraphicsViewPage : ContentPage
{
readonly SparkDrawable _drawable = new();

/// <summary>Build the page and seed the drawable.</summary>
public GraphicsViewPage()
{
InitializeComponent();
GraphicsCanvas.Drawable = _drawable;
}

void OnTap(object? sender, TouchEventArgs e)
{
_drawable.Seed++;
SeedLabel.Text = $"Seed: {_drawable.Seed} · Tap to re-shuffle";
GraphicsCanvas.Invalidate();
}

/// <summary>
/// Synthetic drawable — random sparkline with a gradient-filled
/// circle on the right edge. Kept inside the page so the demo is
/// self-contained.
/// </summary>
sealed class SparkDrawable : IDrawable
{
public int Seed { get; set; }

public void Draw(ICanvas canvas, RectF dirtyRect)
{
var random = new Random(Seed);

// Background fill.
canvas.FillColor = Color.FromArgb("#FAFAFA");
canvas.FillRectangle(dirtyRect);

// Border.
canvas.StrokeColor = Color.FromArgb("#1A000000");
canvas.StrokeSize = 1f;
canvas.DrawRectangle(dirtyRect);

// Spark line — 12 random points across the width.
const int Points = 12;
float stepX = dirtyRect.Width / (Points - 1);
var path = new PathF();
for (int i = 0; i < Points; i++)
{
float x = i * stepX;
float y = 20f + (float)random.NextDouble() * (dirtyRect.Height - 40f);
if (i == 0) path.MoveTo(x, y);
else path.LineTo(x, y);
}
canvas.StrokeColor = Color.FromArgb("#512BD4");
canvas.StrokeSize = 3f;
canvas.DrawPath(path);

// Right-edge circle.
float cx = dirtyRect.Right - 32f;
float cy = dirtyRect.Bottom - 32f;
canvas.FillColor = Color.FromArgb("#E91E63");
canvas.FillCircle(cx, cy, 22f);
canvas.FontColor = Colors.White;
canvas.FontSize = 14f;
canvas.DrawString(Seed.ToString(), cx - 10f, cy - 8f, 20f, 16f, HorizontalAlignment.Center, VerticalAlignment.Center);
}
}
}
Loading
Loading