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
31 changes: 11 additions & 20 deletions docs/maui-coverage.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# .NET MAUI ⇄ Microsoft.AndroidX.Compose.Maui backend coverage

Generated by `scripts/maui-coverage.cs` on 2026-06-16 13:56 UTC.
Generated by `scripts/maui-coverage.cs` on 2026-06-16 14:48 UTC.

Pinned MAUI version: **10.0.20** (from `Directory.Build.targets`).

Expand All @@ -15,14 +15,14 @@ collected transitively across base mappers (`ViewHandler.ViewMapper`,

- **Stock MAUI handlers in scope**: 43
- **Handlers we override**: 25 (**58.1%**)
- **Property-mapper keys covered**: 920 / 1224 (**75.2%**)
- **Property-mapper keys covered**: 925 / 1224 (**75.6%**)

### Per-category coverage

| Category | Handlers | Keys |
| --- | --- | --- |
| **Pages / Navigation** | 2/4 (50%) | 64/130 (49%) |
| **Containers** | 5/5 (100%) | 166/174 (95%) |
| **Containers** | 5/5 (100%) | 171/174 (98%) |
| **Leaves** | 18/18 (100%) | 690/695 (99%) |
| **Menus / Toolbar** | 0/7 (0%) | 0/1 (0%) |
| **Shapes** | 0/1 (0%) | 0/41 (0%) |
Expand All @@ -39,7 +39,7 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem
| ✅ | `NavigationViewHandler` | Pages / Navigation | `NavigationPage` | `NavigationPageHandler` | 31 / 31 (100%) |
| ✅ | `PageHandler` | Pages / Navigation | `Page` | `PageHandler` | 33 / 33 (100%) |
| ❌ | `TabbedViewHandler` | Pages / Navigation | `TabbedPage` | — | 0 / 31 (0%) |
| 🟡 | `BorderHandler` | Containers | `Border` | `BorderHandler` | 35 / 40 (88%) |
| | `BorderHandler` | Containers | `Border` | `BorderHandler` | 40 / 40 (100%) |
| ✅ | `ContentViewHandler` | Containers | `ContentView`, `IContentView` | `ContentViewHandler` | 32 / 32 (100%) |
| ✅ | `LayoutHandler` | Containers | `Layout` | `LayoutHandler` | 32 / 32 (100%) |
| 🟡 | `RefreshViewHandler` | Containers | `RefreshView` | `RefreshViewHandler` | 34 / 35 (97%) |
Expand Down Expand Up @@ -111,7 +111,6 @@ Most often this is a non-trivial property we haven't wired up yet
(`CharacterSpacing`, `Font`, `Padding` on `Button`; `CornerRadius`,
dashed stroke patterns on `Border`).

- **`BorderHandler`** (88%) — missing: `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit`
- **`ScrollViewHandler`** (94%) — missing: `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility`
- **`RadioButtonHandler`** (95%) — missing: `CornerRadius`, `StrokeThickness`
- **`ImageHandler`** (97%) — missing: `IsAnimationPlaying`
Expand Down Expand Up @@ -293,17 +292,9 @@ _No Compose backend handler. Stock MAUI handler keeps the AppCompat backend._

</details>

### 🟡 `BorderHandler` — `Border`
### `BorderHandler` — `Border`

Backed by `BorderHandler`. **35 / 40 keys (88%)**.

Missing keys:

- [ ] `StrokeDashOffset`
- [ ] `StrokeDashPattern`
- [ ] `StrokeLineCap`
- [ ] `StrokeLineJoin`
- [ ] `StrokeMiterLimit`
Backed by `BorderHandler`. **40 / 40 keys (100%)**.

Extra keys we map (no stock counterpart):

Expand Down Expand Up @@ -340,11 +331,11 @@ Extra keys we map (no stock counterpart):
- [x] `Shadow`
- [x] `Shape`
- [x] `Stroke`
- [ ] `StrokeDashOffset`
- [ ] `StrokeDashPattern`
- [ ] `StrokeLineCap`
- [ ] `StrokeLineJoin`
- [ ] `StrokeMiterLimit`
- [x] `StrokeDashOffset`
- [x] `StrokeDashPattern`
- [x] `StrokeLineCap`
- [x] `StrokeLineJoin`
- [x] `StrokeMiterLimit`
- [x] `StrokeThickness`
- [x] `ToolTip`
- [x] `Toolbar`
Expand Down
67 changes: 67 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/VisualsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,73 @@
TextColor="#003E37" />
</Border>

<!-- Dashed-stroke geometry showcase -->
<Label Text="Border — dashed strokes, caps, joins."
FontAttributes="Bold" />

<Border Stroke="#3F51B5"
StrokeThickness="3"
StrokeDashArray="4,2"
Background="#E8EAF6"
Padding="16">
<Border.StrokeShape>
<RoundRectangle CornerRadius="10" />
</Border.StrokeShape>
<Label Text="Dash pattern 4,2 — indigo."
TextColor="#1A237E" />
</Border>

<Border Stroke="#FF5722"
StrokeThickness="4"
StrokeDashArray="6,3,2,3"
StrokeDashOffset="2"
Background="#FBE9E7"
Padding="16">
<Border.StrokeShape>
<RoundRectangle CornerRadius="14" />
</Border.StrokeShape>
<Label Text="Pattern 6,3,2,3 with offset 2 — orange."
TextColor="#BF360C" />
</Border>

<Border Stroke="#673AB7"
StrokeThickness="6"
StrokeDashArray="1,3"
StrokeLineCap="Round"
Background="#EDE7F6"
Padding="16">
<Border.StrokeShape>
<RoundRectangle CornerRadius="10" />
</Border.StrokeShape>
<Label Text="Round caps + dotted dash — deep purple."
TextColor="#311B92" />
</Border>

<Border Stroke="#2E7D32"
StrokeThickness="6"
StrokeLineJoin="Bevel"
Background="#E8F5E9"
Padding="16">
<Border.StrokeShape>
<Rectangle />
</Border.StrokeShape>
<Label Text="Bevel join — green, no dash."
TextColor="#1B5E20" />
</Border>

<Border Stroke="#C62828"
StrokeThickness="6"
StrokeLineJoin="Round"
StrokeDashArray="8,4"
Background="#FFEBEE"
Padding="16">
<Border.StrokeShape>
<RoundRectangle CornerRadius="4" />
</Border.StrokeShape>
<Label Text="Round join + 8,4 dash — red."
TextColor="#B71C1C" />
</Border>

<!-- BoxView showcase -->
<Label Text="BoxView — pastel rectangles."
FontAttributes="Bold" />
Expand Down
15 changes: 15 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui/ColorMapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ public static ComposeColor ToCompose(MauiColor c) =>
public static long? ToPackedLong(MauiColor? c) =>
c is null ? null : (long)ToCompose(c);

/// <summary>
/// Convert a MAUI <see cref="MauiColor"/> to a packed 32-bit
/// <c>0xAARRGGBB</c> integer suitable for
/// <see cref="Android.Graphics.Paint.Color"/> /
/// <c>Android.Graphics.Color</c>. Alpha defaults to opaque when
/// the input is <see langword="null"/>.
/// </summary>
public static int ToArgb(MauiColor? c) =>
c is null
? unchecked((int)0xFF000000)
: (ToByte(c.Alpha) << 24) |
(ToByte(c.Red) << 16) |
(ToByte(c.Green) << 8) |
ToByte(c.Blue);

/// <summary>
/// Convert a normalised <c>0..1</c> channel to an 8-bit value with
/// round-to-nearest semantics. <c>(byte)(x * 255f)</c> truncates and
Expand Down
138 changes: 126 additions & 12 deletions src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
using AndroidX.Compose.Runtime;
using Microsoft.AndroidX.Compose.Maui.Platform;
using Microsoft.Maui.Handlers;
using ComposeColor = AndroidX.Compose.Color;
using MauiBorder = Microsoft.Maui.Controls.Border;
using MauiBorderShape = Microsoft.Maui.Controls.Shapes;
using ComposeColor = AndroidX.Compose.Color;
using MauiBorder = Microsoft.Maui.Controls.Border;
using MauiBorderShape = Microsoft.Maui.Controls.Shapes;
using MauiLineCap = Microsoft.Maui.Graphics.LineCap;
using MauiLineJoin = Microsoft.Maui.Graphics.LineJoin;

namespace Microsoft.AndroidX.Compose.Maui.Handlers;

Expand Down Expand Up @@ -32,6 +34,21 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers;
/// <para><c>Stroke</c> only honours <c>SolidPaint</c>; gradient/image
/// brushes silently drop the stroke (mirrors stock MAUI's Android
/// border drawable, which has the same constraint).</para>
///
/// <para><strong>Dashed-stroke geometry</strong>
/// (<see cref="MauiBorder.StrokeDashPattern"/>,
/// <see cref="MauiBorder.StrokeDashOffset"/>,
/// <see cref="MauiBorder.StrokeLineCap"/>,
/// <see cref="MauiBorder.StrokeLineJoin"/>,
/// <see cref="MauiBorder.StrokeMiterLimit"/>) can't be expressed via
/// Compose's <c>Modifier.border</c> (solid-only). When any of those
/// properties diverges from the defaults the handler switches to a
/// <c>Modifier.drawBehind</c> path driven by
/// <see cref="BorderStrokeDrawCallback"/>, which paints the stroke
/// directly via <see cref="Android.Graphics.Paint"/> +
/// <see cref="Android.Graphics.DashPathEffect"/>. The fast path
/// continues to use <c>Modifier.Border</c> for trivial solid borders so
/// the simple case still benefits from Compose's stroke renderer.</para>
/// </remarks>
public partial class BorderHandler : ComposeElementHandler<MauiBorder>
{
Expand All @@ -52,13 +69,16 @@ public partial class BorderHandler : ComposeElementHandler<MauiBorder>
["Content"] = MapContent,
[nameof(IPadding.Padding)] = MapPadding,
[nameof(IView.Background)] = MapBackground,
// TODO: dashed-stroke / line-cap / line-join / miter-limit
// properties have no Compose equivalent on
// `Modifier.Border`, which only paints a solid border. A
// faithful implementation needs a custom `Modifier.drawWithCache`
// shader, which is a much larger refactor.
// Stock MAUI keys: StrokeDashOffset, StrokeDashPattern,
// StrokeLineCap, StrokeLineJoin, StrokeMiterLimit.
// Dashed-stroke / cap / join / miter geometry — Compose's
// Modifier.border can't express any of these, so the handler
// switches to a Modifier.drawBehind path driven by
// BorderStrokeDrawCallback when any of these diverge from
// the trivial solid-border defaults.
[nameof(MauiBorder.StrokeDashPattern)] = MapStrokeDashPattern,
[nameof(MauiBorder.StrokeDashOffset)] = MapStrokeDashOffset,
[nameof(MauiBorder.StrokeLineCap)] = MapStrokeLineCap,
[nameof(MauiBorder.StrokeLineJoin)] = MapStrokeLineJoin,
[nameof(MauiBorder.StrokeMiterLimit)] = MapStrokeMiterLimit,
};

/// <summary>Command mapper (inherits view-level commands; no extras).</summary>
Expand All @@ -73,6 +93,17 @@ public partial class BorderHandler : ComposeElementHandler<MauiBorder>
readonly MutableState<int> _shapeVersion = new(0);
readonly MutableState<int> _paddingVersion = new(0);
readonly MutableState<int> _contentVersion = new(0);
// Dashed-stroke geometry version slot — bumped from any of the five
// MapStroke* mappers below. The actual values live as fields on
// `_strokeDrawCallback` and are re-read inside `BuildNode` (which
// pushes them into the JCW just before the draw lambda is set).
readonly MutableState<int> _strokeGeometryVersion = new(0);

// Allocated once per handler instance so the JNI peer (and the
// backing Paint) survives every recomposition. Mappers mutate the
// fields in place; BuildNode reconfigures the rest just before
// taking it on the modifier chain.
readonly BorderStrokeDrawCallback _strokeDrawCallback = new();

/// <summary>Construct a handler with the default mappers.</summary>
public BorderHandler() : base(Mapper, CommandMapper) { }
Expand All @@ -89,6 +120,7 @@ public override ComposableNode BuildNode(IComposer composer)
_ = _shapeVersion.Value;
_ = _paddingVersion.Value;
_ = _contentVersion.Value;
_ = _strokeGeometryVersion.Value;

var context = MauiContext
?? throw new InvalidOperationException("MauiContext not set on BorderHandler.");
Expand All @@ -108,8 +140,23 @@ public override ComposableNode BuildNode(IComposer composer)
modifier = (modifier ?? Modifier.Companion)
.Background(new ComposeColor(bg.Value), shape);
if (stroke.HasValue && width > 0f)
modifier = (modifier ?? Modifier.Companion)
.Border(new Dp(width), new ComposeColor(stroke.Value), shape);
{
// Custom geometry (dashes / non-default cap / join / miter)
// can't be expressed via Modifier.border, which paints a
// solid stroke only. Switch to a Paint+Canvas drawBehind
// path when any of the five geometry knobs diverge from
// the trivial defaults.
if (HasCustomStrokeGeometry(border))
{
ConfigureStrokeDrawCallback(border, width);
modifier = (modifier ?? Modifier.Companion).DrawBehind(_strokeDrawCallback);
}
else
{
modifier = (modifier ?? Modifier.Companion)
.Border(new Dp(width), new ComposeColor(stroke.Value), shape);
}
}
if (padding != Thickness.Zero)
{
modifier = (modifier ?? Modifier.Companion).Padding(
Expand Down Expand Up @@ -156,6 +203,45 @@ public override ComposableNode BuildNode(IComposer composer)
_ => null,
};

// Returns true when at least one of the five stroke-geometry knobs
// diverges from the trivial defaults (solid stroke, butt caps,
// miter joins, default miter limit). When false we can take the
// fast `Modifier.border` path which uses Compose's stroke renderer.
// Reads via IBorderStroke because the concrete MauiBorder surface
// uses XAML-flavoured types (PenLineCap, double, ...).
static bool HasCustomStrokeGeometry(MauiBorder border)
{
var stroke = (IBorderStroke)border;
return stroke.StrokeDashPattern is { Length: >= 2 } ||
stroke.StrokeDashOffset != 0f ||
stroke.StrokeLineCap != MauiLineCap.Butt ||
stroke.StrokeLineJoin != MauiLineJoin.Miter ||
stroke.StrokeMiterLimit != 10f;
}

void ConfigureStrokeDrawCallback(MauiBorder border, float strokeThickness)
{
// ARGB int for native Paint — derived directly from the live
// SolidPaint Color so the dashed path stays in sync with the
// brush even when MapStroke doesn't refire (e.g. opacity tweak
// bubbling through SolidPaint).
var solidColor = (border as IStroke)?.Stroke is SolidPaint solid
? solid.Color
: null;
var stroke = (IBorderStroke)border;
_strokeDrawCallback.StrokeArgb = ColorMapping.ToArgb(solidColor);
_strokeDrawCallback.StrokeThicknessDip = strokeThickness;
_strokeDrawCallback.StrokeDashPattern = stroke.StrokeDashPattern;
_strokeDrawCallback.StrokeDashOffset = stroke.StrokeDashOffset;
_strokeDrawCallback.StrokeLineCap = stroke.StrokeLineCap;
_strokeDrawCallback.StrokeLineJoin = stroke.StrokeLineJoin;
_strokeDrawCallback.StrokeMiterLimit = stroke.StrokeMiterLimit;
_strokeDrawCallback.Shape = stroke.Shape;
var metrics = global::Android.Content.Res.Resources.System?.DisplayMetrics
?? throw new InvalidOperationException("Resources.System.DisplayMetrics not available.");
_strokeDrawCallback.Density = metrics.Density;
}

/// <summary>Map <see cref="MauiBorder.Stroke"/> by extracting
/// <see cref="SolidPaint"/>.<see cref="SolidPaint.Color"/>.</summary>
public static void MapStroke(BorderHandler handler, MauiBorder border) =>
Expand Down Expand Up @@ -185,4 +271,32 @@ public static void MapBackground(BorderHandler handler, MauiBorder border) =>
handler._backgroundColor.Value = (border as IView)?.Background is SolidPaint solid
? ColorMapping.ToPackedLong(solid.Color)
: null;

/// <summary>
/// Bump the stroke-geometry version slot in response to
/// <see cref="MauiBorder.StrokeDashPattern"/> changes. The actual
/// pattern array is read live inside <see cref="BuildNode"/>.
/// </summary>
public static void MapStrokeDashPattern(BorderHandler handler, MauiBorder _) =>
handler._strokeGeometryVersion.Value++;

/// <summary>Bump the stroke-geometry version slot for
/// <see cref="MauiBorder.StrokeDashOffset"/>.</summary>
public static void MapStrokeDashOffset(BorderHandler handler, MauiBorder _) =>
handler._strokeGeometryVersion.Value++;

/// <summary>Bump the stroke-geometry version slot for
/// <see cref="MauiBorder.StrokeLineCap"/>.</summary>
public static void MapStrokeLineCap(BorderHandler handler, MauiBorder _) =>
handler._strokeGeometryVersion.Value++;

/// <summary>Bump the stroke-geometry version slot for
/// <see cref="MauiBorder.StrokeLineJoin"/>.</summary>
public static void MapStrokeLineJoin(BorderHandler handler, MauiBorder _) =>
handler._strokeGeometryVersion.Value++;

/// <summary>Bump the stroke-geometry version slot for
/// <see cref="MauiBorder.StrokeMiterLimit"/>.</summary>
public static void MapStrokeMiterLimit(BorderHandler handler, MauiBorder _) =>
handler._strokeGeometryVersion.Value++;
}
Loading
Loading