diff --git a/docs/maui-coverage.md b/docs/maui-coverage.md
index b15f0ce..6094154 100644
--- a/docs/maui-coverage.md
+++ b/docs/maui-coverage.md
@@ -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`).
@@ -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%) |
@@ -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%) |
@@ -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`
@@ -293,17 +292,9 @@ _No Compose backend handler. Stock MAUI handler keeps the AppCompat backend._
-### 🟡 `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):
@@ -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`
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/VisualsPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/VisualsPage.xaml
index a78c4f0..d677b4d 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/VisualsPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/VisualsPage.xaml
@@ -56,6 +56,73 @@
TextColor="#003E37" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui/ColorMapping.cs b/src/Microsoft.AndroidX.Compose.Maui/ColorMapping.cs
index 764f585..17576f9 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/ColorMapping.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/ColorMapping.cs
@@ -36,6 +36,21 @@ public static ComposeColor ToCompose(MauiColor c) =>
public static long? ToPackedLong(MauiColor? c) =>
c is null ? null : (long)ToCompose(c);
+ ///
+ /// Convert a MAUI to a packed 32-bit
+ /// 0xAARRGGBB integer suitable for
+ /// /
+ /// Android.Graphics.Color. Alpha defaults to opaque when
+ /// the input is .
+ ///
+ 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);
+
///
/// Convert a normalised 0..1 channel to an 8-bit value with
/// round-to-nearest semantics. (byte)(x * 255f) truncates and
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs
index 0c2dccd..f3f4e55 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs
@@ -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;
@@ -32,6 +34,21 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers;
/// Stroke only honours SolidPaint; gradient/image
/// brushes silently drop the stroke (mirrors stock MAUI's Android
/// border drawable, which has the same constraint).
+///
+/// Dashed-stroke geometry
+/// (,
+/// ,
+/// ,
+/// ,
+/// ) can't be expressed via
+/// Compose's Modifier.border (solid-only). When any of those
+/// properties diverges from the defaults the handler switches to a
+/// Modifier.drawBehind path driven by
+/// , which paints the stroke
+/// directly via +
+/// . The fast path
+/// continues to use Modifier.Border for trivial solid borders so
+/// the simple case still benefits from Compose's stroke renderer.
///
public partial class BorderHandler : ComposeElementHandler
{
@@ -52,13 +69,16 @@ public partial class BorderHandler : ComposeElementHandler
["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,
};
/// Command mapper (inherits view-level commands; no extras).
@@ -73,6 +93,17 @@ public partial class BorderHandler : ComposeElementHandler
readonly MutableState _shapeVersion = new(0);
readonly MutableState _paddingVersion = new(0);
readonly MutableState _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 _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();
/// Construct a handler with the default mappers.
public BorderHandler() : base(Mapper, CommandMapper) { }
@@ -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.");
@@ -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(
@@ -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;
+ }
+
/// Map by extracting
/// ..
public static void MapStroke(BorderHandler handler, MauiBorder border) =>
@@ -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;
+
+ ///
+ /// Bump the stroke-geometry version slot in response to
+ /// changes. The actual
+ /// pattern array is read live inside .
+ ///
+ public static void MapStrokeDashPattern(BorderHandler handler, MauiBorder _) =>
+ handler._strokeGeometryVersion.Value++;
+
+ /// Bump the stroke-geometry version slot for
+ /// .
+ public static void MapStrokeDashOffset(BorderHandler handler, MauiBorder _) =>
+ handler._strokeGeometryVersion.Value++;
+
+ /// Bump the stroke-geometry version slot for
+ /// .
+ public static void MapStrokeLineCap(BorderHandler handler, MauiBorder _) =>
+ handler._strokeGeometryVersion.Value++;
+
+ /// Bump the stroke-geometry version slot for
+ /// .
+ public static void MapStrokeLineJoin(BorderHandler handler, MauiBorder _) =>
+ handler._strokeGeometryVersion.Value++;
+
+ /// Bump the stroke-geometry version slot for
+ /// .
+ public static void MapStrokeMiterLimit(BorderHandler handler, MauiBorder _) =>
+ handler._strokeGeometryVersion.Value++;
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Platform/BorderStrokeDrawCallback.cs b/src/Microsoft.AndroidX.Compose.Maui/Platform/BorderStrokeDrawCallback.cs
new file mode 100644
index 0000000..053e26f
--- /dev/null
+++ b/src/Microsoft.AndroidX.Compose.Maui/Platform/BorderStrokeDrawCallback.cs
@@ -0,0 +1,241 @@
+using Android.Graphics;
+using Android.Runtime;
+using Kotlin.Jvm.Functions;
+using AndroidColor = Android.Graphics.Color;
+using AndroidPaint = Android.Graphics.Paint;
+using AndroidPath = Android.Graphics.Path;
+using AndroidRectF = Android.Graphics.RectF;
+using ComposeBridges = AndroidX.Compose.ComposeBridges;
+using LineCap = Microsoft.Maui.Graphics.LineCap;
+using LineJoin = Microsoft.Maui.Graphics.LineJoin;
+using MauiBorderShape = Microsoft.Maui.Controls.Shapes;
+
+namespace Microsoft.AndroidX.Compose.Maui.Platform;
+
+///
+/// Function1<DrawScope, Unit> adapter that paints a MAUI
+/// stroke directly via
+/// + .
+/// Used by when any of the
+/// dashed-stroke / line-cap / line-join / miter-limit knobs diverge
+/// from the trivial solid-border defaults — Compose's
+/// Modifier.border only paints a solid stroke and so can't
+/// model those properties.
+///
+///
+/// Allocated once per
+/// instance (held as a readonly field) so the JNI peer
+/// stays stable across recompositions. The handler mutates the public
+/// fields below before calling composer.recompose(); the
+/// body reads the latest values on each draw pass.
+/// Re-allocating the JCW per recomposition would churn the JNI ref
+/// table without any payoff.
+///
+/// The drawing path bypasses Compose's DrawScope drawing
+/// primitives — every one of those takes an inline-class param
+/// (Color, Offset, Size, CornerRadius,
+/// BlendMode) and is consequently stripped from the binding.
+/// Instead we walk DrawScope.drawContext.canvas down to the
+/// native and draw with directly-bound
+/// + APIs.
+///
+/// Public so it can be referenced from a readonly field
+/// on the handler. Not part of the developer-facing API.
+///
+[Register("net/compose/maui/BorderStrokeDrawCallback")]
+public sealed class BorderStrokeDrawCallback : Java.Lang.Object, IFunction1
+{
+ ///
+ /// Reused across draw passes — properties are reconfigured
+ /// in-place on each call.
+ ///
+ readonly AndroidPaint _paint = new(PaintFlags.AntiAlias)
+ {
+ Dither = true,
+ };
+
+ /// Stroke colour as packed 0xAARRGGBB; 0 = no stroke.
+ public int StrokeArgb { get; set; }
+
+ /// Stroke thickness in DIPs (matches MAUI's StrokeThickness).
+ public float StrokeThicknessDip { get; set; }
+
+ ///
+ /// Dash on/off lengths in stroke-thickness units (matches
+ /// MAUI semantics). Multiplied by stroke thickness in pixels at
+ /// draw time. or fewer than two entries =
+ /// solid stroke.
+ ///
+ public float[]? StrokeDashPattern { get; set; }
+
+ /// Dash phase offset in stroke-thickness units.
+ public float StrokeDashOffset { get; set; }
+
+ /// Cap style (Butt / Round / Square).
+ public LineCap StrokeLineCap { get; set; } = LineCap.Butt;
+
+ /// Join style (Miter / Round / Bevel).
+ public LineJoin StrokeLineJoin { get; set; } = LineJoin.Miter;
+
+ /// Miter limit (Skia/MAUI default is 10).
+ public float StrokeMiterLimit { get; set; } = 10f;
+
+ ///
+ /// Shape to stroke. , ,
+ /// , and are
+ /// honoured; everything else falls back to a plain rectangle.
+ ///
+ public Microsoft.Maui.Graphics.IShape? Shape { get; set; }
+
+ /// Display density in pixels-per-DIP, captured by the handler.
+ public float Density { get; set; } = 1f;
+
+ ///
+ /// Kotlin Function1.invoke entry point.
+ /// is the Compose DrawScope the runtime hands back inside
+ /// Modifier.drawBehind.
+ ///
+ public Java.Lang.Object? Invoke(Java.Lang.Object? p0)
+ {
+ var unit = Kotlin.Unit.Instance
+ ?? throw new InvalidOperationException("Kotlin.Unit.Instance not available.");
+
+ if (p0 is null || StrokeArgb == 0 || StrokeThicknessDip <= 0f || Density <= 0f)
+ return unit;
+
+ var nativeCanvas = ComposeBridges.DrawScopeGetNativeCanvas(p0.Handle);
+ if (nativeCanvas is null)
+ return unit;
+
+ long packedSize = ComposeBridges.DrawScopeGetSize(p0.Handle);
+ float widthPx = ComposeBridges.UnpackSizeWidth(packedSize);
+ float heightPx = ComposeBridges.UnpackSizeHeight(packedSize);
+ if (widthPx <= 0f || heightPx <= 0f)
+ return unit;
+
+ float strokePx = StrokeThicknessDip * Density;
+ ConfigurePaint(strokePx);
+
+ // Match Modifier.border semantics — the stroke straddles the
+ // bounds, so inset by half the stroke width so the visible
+ // stroke draws inside the geometry.
+ float inset = strokePx / 2f;
+ float left = inset;
+ float top = inset;
+ float right = widthPx - inset;
+ float bottom = heightPx - inset;
+ if (right <= left || bottom <= top)
+ return unit;
+
+ switch (Shape)
+ {
+ case MauiBorderShape.RoundRectangle rr:
+ DrawRoundRectangle(nativeCanvas, rr, left, top, right, bottom);
+ break;
+ case MauiBorderShape.Ellipse:
+ DrawOvalInternal(nativeCanvas, left, top, right, bottom);
+ break;
+ case MauiBorderShape.Rectangle:
+ case null:
+ default:
+ DrawRectInternal(nativeCanvas, left, top, right, bottom);
+ break;
+ }
+
+ return unit;
+ }
+
+ void ConfigurePaint(float strokePx)
+ {
+ _paint.SetStyle(AndroidPaint.Style.Stroke);
+ _paint.Color = new AndroidColor(StrokeArgb);
+ _paint.StrokeWidth = strokePx;
+ _paint.StrokeCap = MapLineCap(StrokeLineCap);
+ _paint.StrokeJoin = MapLineJoin(StrokeLineJoin);
+ _paint.StrokeMiter = StrokeMiterLimit;
+ _paint.SetPathEffect(BuildDashEffect(strokePx));
+ }
+
+ DashPathEffect? BuildDashEffect(float strokePx)
+ {
+ var pattern = StrokeDashPattern;
+ if (pattern is null || pattern.Length < 2)
+ return null;
+
+ // MAUI's pattern values are multiples of stroke thickness.
+ // Convert to pixels for Android. DashPathEffect requires an
+ // even number of intervals (alternating on/off pairs); double
+ // the array when odd so the user-visible pattern still cycles
+ // the way MAUI documents it.
+ bool needsDouble = (pattern.Length & 1) == 1;
+ int outLength = needsDouble ? pattern.Length * 2 : pattern.Length;
+ var intervalsPx = new float[outLength];
+ for (int i = 0; i < pattern.Length; i++)
+ intervalsPx[i] = Math.Max(0f, pattern[i] * strokePx);
+ if (needsDouble)
+ Array.Copy(intervalsPx, 0, intervalsPx, pattern.Length, pattern.Length);
+
+ return new DashPathEffect(intervalsPx, StrokeDashOffset * strokePx);
+ }
+
+ void DrawRoundRectangle(Canvas canvas, MauiBorderShape.RoundRectangle rr, float left, float top, float right, float bottom)
+ {
+ var corners = rr.CornerRadius;
+ float tlX = (float)corners.TopLeft * Density;
+ float trX = (float)corners.TopRight * Density;
+ float brX = (float)corners.BottomRight * Density;
+ float blX = (float)corners.BottomLeft * Density;
+
+ // Fast path — uniform corners use Canvas.drawRoundRect, which
+ // is cheaper than a full Path round-trip.
+ if (tlX == trX && trX == brX && brX == blX)
+ {
+ using var rect = new AndroidRectF(left, top, right, bottom);
+ canvas.DrawRoundRect(rect, tlX, tlX, _paint);
+ return;
+ }
+
+ // Per-corner radii — fall back to a Path. Each corner takes
+ // (rx, ry); we use rx==ry since MAUI's CornerRadius is a single
+ // scalar per corner.
+ var radii = new float[]
+ {
+ tlX, tlX, // top-left
+ trX, trX, // top-right
+ brX, brX, // bottom-right
+ blX, blX, // bottom-left
+ };
+ using var path = new AndroidPath();
+ using var pathRect = new AndroidRectF(left, top, right, bottom);
+ var direction = AndroidPath.Direction.Cw
+ ?? throw new InvalidOperationException("Path.Direction.Cw not available.");
+ path.AddRoundRect(pathRect, radii, direction);
+ canvas.DrawPath(path, _paint);
+ }
+
+ void DrawRectInternal(Canvas canvas, float left, float top, float right, float bottom)
+ {
+ using var rect = new AndroidRectF(left, top, right, bottom);
+ canvas.DrawRect(rect, _paint);
+ }
+
+ void DrawOvalInternal(Canvas canvas, float left, float top, float right, float bottom)
+ {
+ using var rect = new AndroidRectF(left, top, right, bottom);
+ canvas.DrawOval(rect, _paint);
+ }
+
+ static AndroidPaint.Cap MapLineCap(LineCap cap) => cap switch
+ {
+ LineCap.Round => AndroidPaint.Cap.Round ?? throw new InvalidOperationException("Paint.Cap.Round not available."),
+ LineCap.Square => AndroidPaint.Cap.Square ?? throw new InvalidOperationException("Paint.Cap.Square not available."),
+ _ => AndroidPaint.Cap.Butt ?? throw new InvalidOperationException("Paint.Cap.Butt not available."),
+ };
+
+ static AndroidPaint.Join MapLineJoin(LineJoin join) => join switch
+ {
+ LineJoin.Round => AndroidPaint.Join.Round ?? throw new InvalidOperationException("Paint.Join.Round not available."),
+ LineJoin.Bevel => AndroidPaint.Join.Bevel ?? throw new InvalidOperationException("Paint.Join.Bevel not available."),
+ _ => AndroidPaint.Join.Miter ?? throw new InvalidOperationException("Paint.Join.Miter not available."),
+ };
+}
diff --git a/src/Microsoft.AndroidX.Compose/AssemblyInfo.cs b/src/Microsoft.AndroidX.Compose/AssemblyInfo.cs
new file mode 100644
index 0000000..1dc9093
--- /dev/null
+++ b/src/Microsoft.AndroidX.Compose/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Runtime.CompilerServices;
+
+// The Microsoft.AndroidX.Compose.Maui backend reaches into a small set
+// of low-level JNI plumbing helpers on `ComposeBridges` (DrawScope →
+// native Canvas walk, Size unpackers) when wiring custom-drawn
+// modifiers (border dashes, etc.). Exposing those publicly would
+// pollute the developer-facing surface; this attribute keeps them
+// internal to the assembly while still letting the trusted Maui
+// backend depend on them.
+[assembly: InternalsVisibleTo("Microsoft.AndroidX.Compose.Maui")]
diff --git a/src/Microsoft.AndroidX.Compose/ComposeBridges.cs b/src/Microsoft.AndroidX.Compose/ComposeBridges.cs
index 12047bc..576ba13 100644
--- a/src/Microsoft.AndroidX.Compose/ComposeBridges.cs
+++ b/src/Microsoft.AndroidX.Compose/ComposeBridges.cs
@@ -249,6 +249,92 @@ internal static IntPtr ModifierClipRoundedCorners(IntPtr modifier, float dp)
}
}
+ // ---- DrawScope JNI helpers ----
+ //
+ // The Compose `DrawScope` binding has no usable instance methods —
+ // every drawing primitive on it carries an inline-class param
+ // (Color, Offset, Size, CornerRadius, BlendMode), so the binder
+ // strips the lot and the C# IDrawScope interface is empty. The
+ // canvas-getter chain (`getDrawContext().getCanvas()`) avoids the
+ // mangling — neither method takes an inline class — but the binder
+ // still drops them because `IDrawScope` itself isn't surfaced.
+ //
+ // Hand-written below: enough JNI to extract the native
+ // `Android.Graphics.Canvas` and the packed `Size` long from a
+ // DrawScope handle, which is everything a `Modifier.drawBehind`
+ // callback needs to draw via directly-bound `Paint` + `Canvas`.
+
+ static IntPtr s_drawScopeClass;
+ static IntPtr s_drawContextClass;
+ static IntPtr s_drawScopeGetSizeMethodId;
+ static IntPtr s_drawScopeGetDrawContextMethodId;
+ static IntPtr s_drawContextGetCanvasMethodId;
+
+ static void EnsureDrawScopeMethodIds()
+ {
+ if (s_drawScopeGetSizeMethodId != IntPtr.Zero)
+ return;
+ s_drawScopeClass = JNIEnv.FindClass("androidx/compose/ui/graphics/drawscope/DrawScope");
+ s_drawContextClass = JNIEnv.FindClass("androidx/compose/ui/graphics/drawscope/DrawContext");
+ s_drawScopeGetSizeMethodId = JNIEnv.GetMethodID(s_drawScopeClass, "getSize-NH-jbRc", "()J");
+ s_drawScopeGetDrawContextMethodId = JNIEnv.GetMethodID(s_drawScopeClass, "getDrawContext", "()Landroidx/compose/ui/graphics/drawscope/DrawContext;");
+ s_drawContextGetCanvasMethodId = JNIEnv.GetMethodID(s_drawContextClass, "getCanvas", "()Landroidx/compose/ui/graphics/Canvas;");
+ }
+
+ // Read the modifier's bounds out of a DrawScope. The Kotlin
+ // property `DrawScope.size: Size` is mangled because `Size` is a
+ // `@JvmInline value class` over a packed `long`. Returned as the
+ // raw packed value; callers unpack with
+ // / .
+ internal static long DrawScopeGetSize(IntPtr drawScope)
+ {
+ EnsureDrawScopeMethodIds();
+ return JNIEnv.CallLongMethod(drawScope, s_drawScopeGetSizeMethodId);
+ }
+
+ /// Unpack a Compose Size's width — high 32 bits as float.
+ internal static float UnpackSizeWidth(long packed) =>
+ BitConverter.Int32BitsToSingle((int)((ulong)packed >> 32));
+
+ /// Unpack a Compose Size's height — low 32 bits as float.
+ internal static float UnpackSizeHeight(long packed) =>
+ BitConverter.Int32BitsToSingle((int)((ulong)packed & 0xFFFFFFFFL));
+
+ // Walk DrawScope → DrawContext → Compose Canvas → native
+ // android.graphics.Canvas. Returns null when the DrawScope handle
+ // is null or the canvas chain yields a null intermediate (defensive
+ // — Compose normally guarantees a non-null canvas inside drawBehind).
+ internal static Android.Graphics.Canvas? DrawScopeGetNativeCanvas(IntPtr drawScope)
+ {
+ if (drawScope == IntPtr.Zero)
+ return null;
+ EnsureDrawScopeMethodIds();
+ IntPtr drawContextLocal = JNIEnv.CallObjectMethod(drawScope, s_drawScopeGetDrawContextMethodId);
+ if (drawContextLocal == IntPtr.Zero)
+ return null;
+ IntPtr composeCanvasLocal = IntPtr.Zero;
+ try
+ {
+ composeCanvasLocal = JNIEnv.CallObjectMethod(drawContextLocal, s_drawContextGetCanvasMethodId);
+ if (composeCanvasLocal == IntPtr.Zero)
+ return null;
+ // GetObject(.., DoNotTransfer) — Mono creates its own
+ // global ref internally; we still own `composeCanvasLocal`
+ // and free it in the outer finally.
+ var composeCanvas = Java.Lang.Object.GetObject(
+ composeCanvasLocal, JniHandleOwnership.DoNotTransfer);
+ return composeCanvas is null
+ ? null
+ : AndroidX.Compose.UI.Graphics.AndroidCanvas_androidKt.GetNativeCanvas(composeCanvas);
+ }
+ finally
+ {
+ if (composeCanvasLocal != IntPtr.Zero)
+ JNIEnv.DeleteLocalRef(composeCanvasLocal);
+ JNIEnv.DeleteLocalRef(drawContextLocal);
+ }
+ }
+
// androidx.compose.ui.res.PainterResources_androidKt.painterResource —
// returns a NEW local Painter ref the caller is responsible for
// DeleteLocalRef'ing once it's been handed to the consuming
@@ -2026,6 +2112,21 @@ internal static partial IntPtr ModifierBackgroundBrush(
internal static partial IntPtr ModifierBorderBrush(
IntPtr modifier, float width, AndroidX.Compose.UI.Graphics.Brush brush, Shape? shape);
+ // androidx.compose.ui.draw.DrawModifierKt.drawBehind —
+ // (Modifier, Function1). Plain Kotlin static
+ // extension; the binder strips it because the Function1 generic
+ // erases at JVM level. No $default — caller must always supply
+ // the lambda. The Function1 is invoked by Compose on every redraw
+ // pass with a DrawScope receiver, which the caller can dispatch
+ // off via raw JNI (the Compose DrawScope binding is interface-
+ // only; instance methods are mangled by inline-class params).
+ [ComposeBridge(
+ Class = "androidx/compose/ui/draw/DrawModifierKt",
+ JvmName = "drawBehind",
+ Signature = "(Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;)" +
+ "Landroidx/compose/ui/Modifier;")]
+ internal static partial IntPtr ModifierDrawBehind(IntPtr modifier, IFunction1 onDraw);
+
// androidx.compose.foundation.ClickableKt.clickable-XHw0xAI$default —
// (Modifier, Boolean enabled, String onClickLabel, Role role,
// Function0 onClick). Returns a Modifier directly — the lambda is
diff --git a/src/Microsoft.AndroidX.Compose/ModifierExtensions.cs b/src/Microsoft.AndroidX.Compose/ModifierExtensions.cs
index 1cf80ec..443b332 100644
--- a/src/Microsoft.AndroidX.Compose/ModifierExtensions.cs
+++ b/src/Microsoft.AndroidX.Compose/ModifierExtensions.cs
@@ -292,6 +292,37 @@ public static Modifier Border(
return modifier.Append(curr => ComposeBridges.ModifierBorderBrush(curr, w, brush, shape));
}
+ ///
+ /// Modifier.drawBehind(onDraw: DrawScope.() -> Unit) —
+ /// installs as a behind-the-content draw
+ /// callback. Compose invokes the lambda on every draw pass with a
+ /// DrawScope receiver (passed as a Java Object handle
+ /// to the JCW) before painting the modifier's children.
+ ///
+ ///
+ /// The Compose DrawScope binding is interface-only —
+ /// every drawing primitive on it (drawRect, drawRoundRect,
+ /// drawPath, …) carries an inline-class param (Color,
+ /// Offset, Size, CornerRadius, BlendMode),
+ /// so the binder strips them and the C# instance API is empty.
+ /// Callbacks reach into DrawScope.drawContext.canvas.nativeCanvas
+ /// via raw JNI to get an and
+ /// draw with the directly-bound
+ /// API.
+ ///
+ /// The JCW is captured by the
+ /// closure so its Java peer stays alive across recompositions —
+ /// pass the same JCW instance each pass to keep modifier
+ /// equality stable; allocating a fresh
+ /// every recomposition rebuilds the underlying
+ /// DrawBehindElement on every frame.
+ ///
+ public static Modifier DrawBehind(this Modifier modifier, Kotlin.Jvm.Functions.IFunction1 onDraw)
+ {
+ ArgumentNullException.ThrowIfNull(onDraw);
+ return modifier.Append(curr => ComposeBridges.ModifierDrawBehind(curr, onDraw));
+ }
+
///
/// Modifier.clip(RoundedCornerShape()) —
/// rounds the four corners by the same radius and clips drawing to the
diff --git a/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt b/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt
index ff1f2aa..644811d 100644
--- a/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt
@@ -2168,6 +2168,7 @@ static AndroidX.Compose.ModifierExtensions.DetectTransformGestures(this AndroidX
static AndroidX.Compose.ModifierExtensions.DisplayCutoutPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier!
static AndroidX.Compose.ModifierExtensions.DragAndDropTarget(this AndroidX.Compose.Modifier! modifier, System.Func! shouldStartDragAndDrop, AndroidX.Compose.DragAndDropTarget! target) -> AndroidX.Compose.Modifier!
static AndroidX.Compose.ModifierExtensions.Draggable(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.DraggableState! state, AndroidX.Compose.Orientation orientation, bool enabled = true) -> AndroidX.Compose.Modifier!
+static AndroidX.Compose.ModifierExtensions.DrawBehind(this AndroidX.Compose.Modifier! modifier, Kotlin.Jvm.Functions.IFunction1! onDraw) -> AndroidX.Compose.Modifier!
static AndroidX.Compose.ModifierExtensions.FillMaxHeight(this AndroidX.Compose.Modifier! modifier, float fraction = 1) -> AndroidX.Compose.Modifier!
static AndroidX.Compose.ModifierExtensions.FillMaxSize(this AndroidX.Compose.Modifier! modifier, float fraction = 1) -> AndroidX.Compose.Modifier!
static AndroidX.Compose.ModifierExtensions.FillMaxWidth(this AndroidX.Compose.Modifier! modifier, float fraction = 1) -> AndroidX.Compose.Modifier!