Skip to content
Merged
34 changes: 12 additions & 22 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 14:48 UTC.
Generated by `scripts/maui-coverage.cs` on 2026-06-16 14:59 UTC.

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

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

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

### Per-category coverage

| Category | Handlers | Keys |
| --- | --- | --- |
| **Pages / Navigation** | 2/4 (50%) | 64/130 (49%) |
| **Containers** | 5/5 (100%) | 171/174 (98%) |
| **Leaves** | 18/18 (100%) | 690/695 (99%) |
| **Containers** | 5/5 (100%) | 172/174 (99%) |
| **Leaves** | 18/18 (100%) | 691/695 (99%) |
| **Menus / Toolbar** | 0/7 (0%) | 0/1 (0%) |
| **Shapes** | 0/1 (0%) | 0/41 (0%) |
| **App / Window** | 0/2 (0%) | 0/8 (0%) |
Expand All @@ -42,7 +42,7 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem
| ✅ | `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%) |
| | `RefreshViewHandler` | Containers | `RefreshView` | `RefreshViewHandler` | 35 / 35 (100%) |
| 🟡 | `ScrollViewHandler` | Containers | `ScrollView` | `ScrollViewHandler` | 33 / 35 (94%) |
| ✅ | `ActivityIndicatorHandler` | Leaves | `ActivityIndicator` | `ActivityIndicatorHandler` | 33 / 33 (100%) |
| ✅ | `ButtonHandler` | Leaves | `Button` | `ButtonHandler` | 40 / 40 (100%) |
Expand All @@ -58,7 +58,7 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem
| ✅ | `ProgressBarHandler` | Leaves | `ProgressBar` | `ProgressBarHandler` | 33 / 33 (100%) |
| 🟡 | `RadioButtonHandler` | Leaves | `RadioButton` | `RadioButtonHandler` | 37 / 39 (95%) |
| ✅ | `SearchBarHandler` | Leaves | `SearchBar` | `SearchBarHandler` | 47 / 47 (100%) |
| 🟡 | `SliderHandler` | Leaves | `Slider` | `SliderHandler` | 37 / 38 (97%) |
| | `SliderHandler` | Leaves | `Slider` | `SliderHandler` | 38 / 38 (100%) |
| ✅ | `StepperHandler` | Leaves | `Stepper` | `StepperHandler` | 35 / 35 (100%) |
| ✅ | `SwitchHandler` | Leaves | `Switch` | `SwitchHandler` | 34 / 34 (100%) |
| ✅ | `TimePickerHandler` | Leaves | `TimePicker` | `TimePickerHandler` | 37 / 37 (100%) |
Expand Down Expand Up @@ -114,9 +114,7 @@ dashed stroke patterns on `Border`).
- **`ScrollViewHandler`** (94%) — missing: `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility`
- **`RadioButtonHandler`** (95%) — missing: `CornerRadius`, `StrokeThickness`
- **`ImageHandler`** (97%) — missing: `IsAnimationPlaying`
- **`RefreshViewHandler`** (97%) — missing: `RefreshColor`
- **`ImageButtonHandler`** (97%) — missing: `IsAnimationPlaying`
- **`SliderHandler`** (97%) — missing: `ThumbImageSource`

## Per-handler property detail

Expand Down Expand Up @@ -439,13 +437,9 @@ Extra keys we map (no stock counterpart):

</details>

### 🟡 `RefreshViewHandler` — `RefreshView`
### `RefreshViewHandler` — `RefreshView`

Backed by `RefreshViewHandler`. **34 / 35 keys (97%)**.

Missing keys:

- [ ] `RefreshColor`
Backed by `RefreshViewHandler`. **35 / 35 keys (100%)**.

<details><summary>All stock keys</summary>

Expand All @@ -468,7 +462,7 @@ Missing keys:
- [x] `MinimumHeight`
- [x] `MinimumWidth`
- [x] `Opacity`
- [ ] `RefreshColor`
- [x] `RefreshColor`
- [x] `Rotation`
- [x] `RotationX`
- [x] `RotationY`
Expand Down Expand Up @@ -1257,13 +1251,9 @@ Extra keys we map (no stock counterpart):

</details>

### 🟡 `SliderHandler` — `Slider`

Backed by `SliderHandler`. **37 / 38 keys (97%)**.

Missing keys:
### ✅ `SliderHandler` — `Slider`

- [ ] `ThumbImageSource`
Backed by `SliderHandler`. **38 / 38 keys (100%)**.

<details><summary>All stock keys</summary>

Expand Down Expand Up @@ -1297,7 +1287,7 @@ Missing keys:
- [x] `Semantics`
- [x] `Shadow`
- [x] `ThumbColor`
- [ ] `ThumbImageSource`
- [x] `ThumbImageSource`
- [x] `ToolTip`
- [x] `Toolbar`
- [x] `TranslationX`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
VerticalStackLayout folds into the parent Page composition.
-->
<RefreshView x:Name="Refresh"
RefreshColor="#FF6B35"
Refreshing="OnRefreshing">
<ScrollView>
<VerticalStackLayout x:Name="ItemsHost"
Expand All @@ -44,10 +45,12 @@
<Label x:Name="Item4" Text="• Item 5" />

<!-- New mapper coverage: IsRefreshEnabled toggles whether
the pull gesture even kicks off a refresh. -->
the pull gesture even kicks off a refresh.
RefreshColor (above on the RefreshView) tints the
spinner glyph orange. -->
<Label Text="-- New mapper coverage --"
FontAttributes="Bold" />
<Label Text="Toggle below disables/enables IsRefreshEnabled. Pull-to-refresh ignores gestures while disabled." />
<Label Text="The spinner glyph is tinted via RefreshColor=&quot;#FF6B35&quot;. The toggle below disables/enables IsRefreshEnabled. Pull-to-refresh ignores gestures while disabled." />
<Switch x:Name="EnabledSwitch"
IsToggled="True"
Toggled="OnRefreshEnabledToggled"
Expand Down
15 changes: 15 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SlidersPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@
x:Name="ColoredValueLabel"
Text="value = 0" />

<Label Text="-- Slider with ThumbImageSource --"
FontAttributes="Bold" />

<Slider
x:Name="ThumbImageSlider"
Minimum="0"
Maximum="100"
Value="50"
ThumbImageSource="dotnet_bot.png"
ValueChanged="OnThumbImageSliderChanged" />

<Label
x:Name="ThumbImageValueLabel"
Text="value = 50" />

<Label Text="-- Stepper bound to a label (Increment=2) --"
FontAttributes="Bold" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,19 @@ void OnColoredSliderChanged(object? sender, ValueChangedEventArgs e) =>
ColoredValueLabel.Text =
$"value = {e.NewValue.ToString("0.0", CultureInfo.InvariantCulture)}";

void OnThumbImageSliderChanged(object? sender, ValueChangedEventArgs e) =>
ThumbImageValueLabel.Text =
$"value = {e.NewValue.ToString("0", CultureInfo.InvariantCulture)}";

void OnStepperChanged(object? sender, ValueChangedEventArgs e) =>
StepperValueLabel.Text =
e.NewValue.ToString("0", CultureInfo.InvariantCulture);

void OnResetClicked(object? sender, EventArgs e)
{
DefaultSlider.Value = 0.25;
ColoredSlider.Value = 0;
DemoStepper.Value = 6;
DefaultSlider.Value = 0.25;
ColoredSlider.Value = 0;
ThumbImageSlider.Value = 50;
DemoStepper.Value = 6;
}
}
15 changes: 12 additions & 3 deletions src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,18 @@ public partial class ImageButtonHandler : ComposeElementHandler<MauiIImageButton
[nameof(IButtonStroke.CornerRadius)] = MapCornerRadius,
[nameof(IPadding.Padding)] = MapPadding,
// TODO: IImage.IsAnimationPlaying — same caveat as
// ImageHandler. Compose animates GIFs/WebPs only through
// coil-compose, which isn't wired into ImageSourceLoader.
// Leaving unmapped so the coverage report flags the gap.
// ImageHandler.MapSource. `ImageSourceLoader` rasterises
// the resolved `Drawable` to a static `Bitmap` before
// wrapping it as a `BitmapPainter`, so even when the
// underlying source is an `AnimatedImageDrawable` the
// snapshot in Compose's slot table doesn't animate.
// Honouring this property needs either a hand-rolled
// `DrawablePainter` JCW whose `DrawScope.onDraw` forwards
// into `Drawable.draw(Canvas)` (with `start()` / `stop()`
// gated on the slot), or routing source resolution
// through coil-compose's `AsyncImagePainter`. Leaving
// unmapped so the coverage report flags the gap rather
// than claiming a no-op wire.
};

/// <summary>Command mapper (inherits view-level commands; no extras).</summary>
Expand Down
25 changes: 17 additions & 8 deletions src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,23 @@ public partial class ImageHandler : ComposeElementHandler<MauiIImage>
{
[nameof(IImageSourcePart.Source)] = MapSource,
[nameof(MauiIImage.Aspect)] = MapAspect,
// TODO: IImage.IsAnimationPlaying — Compose's Image composable
// animates GIFs/WebPs only when the painter wraps a Coil
// ImageRequest. ImageSourceLoader currently materialises a
// static BitmapPainter (or PainterResource for packaged
// drawables); routing through coil-compose is a separate
// dependency + handler rewrite. Leaving unmapped so the
// coverage report flags the gap rather than claiming a no-op
// wire.
// TODO: IImage.IsAnimationPlaying — Compose's `Image`
// composable takes a `Painter`, not an animated
// `Drawable`, and our `ImageSourceLoader` rasterises the
// resolved `Drawable` to a static `Bitmap` (via
// `DrawableKt.ToBitmap`) before wrapping it in a
// `BitmapPainter`. The Android-native `AnimatedImageDrawable`
// (API 28+) keeps animating, but the snapshot we hand to
// Compose doesn't — its first frame is what gets sampled.
// Honouring this property therefore needs either (a) a
// hand-rolled `DrawablePainter` JCW subclass of
// `androidx.compose.ui.graphics.painter.Painter` whose
// `DrawScope.onDraw` forwards into `Drawable.draw(Canvas)`
// and toggles `start()` / `stop()` based on the slot, or
// (b) routing source resolution through coil-compose's
// `AsyncImagePainter`. Both are larger surgery + a real
// new dependency. Leaving unmapped so the coverage report
// flags the gap rather than claiming a no-op wire.
};

/// <summary>Command mapper (inherits view-level commands; no extras).</summary>
Expand Down
68 changes: 51 additions & 17 deletions src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers;
/// <see cref="LayoutHandler"/> / <see cref="ContentViewHandler"/>, so
/// nested Compose-backed views fold into the parent composition.</para>
///
/// <para><see cref="IRefreshView.RefreshColor"/> isn't wired yet: the
/// current C# <c>PullToRefreshBox</c> facade doesn't expose
/// <c>containerColor</c> / <c>contentColor</c> slots. Until it does,
/// the spinner uses Material 3's default theme tint. Tracked as a
/// follow-up for the next slice.</para>
/// <para><see cref="IRefreshView.RefreshColor"/> maps to the spinner
/// glyph color via the public
/// <see cref="PullToRefreshIndicator"/> facade, which wraps
/// <c>PullToRefreshDefaults.Instance.Indicator(...)</c>. When unset
/// the spinner falls back to Material 3's default tint.</para>
/// </remarks>
public partial class RefreshViewHandler : ComposeElementHandler<IRefreshView>
{
Expand All @@ -56,25 +56,21 @@ public partial class RefreshViewHandler : ComposeElementHandler<IRefreshView>
{
[nameof(IRefreshView.IsRefreshing)] = MapIsRefreshing,
[nameof(IRefreshView.IsRefreshEnabled)] = MapIsRefreshEnabled,
[nameof(IRefreshView.RefreshColor)] = MapRefreshColor,
["Content"] = MapContent,
// TODO: IRefreshView.RefreshColor — Compose Material 3's
// PullToRefreshBox doesn't currently expose containerColor /
// contentColor through our facade (the bound Kotlin overload
// takes a PullToRefreshState parameter we don't surface).
// Wiring this needs a PullToRefreshBox facade extension —
// larger surgery than fits this PR.
};

/// <summary>Command mapper (inherits view-level commands; no extras).</summary>
public static CommandMapper<IRefreshView, RefreshViewHandler> CommandMapper =
new(ViewCommandMapper);

readonly MutableState<bool> _isRefreshing = new(false);
readonly MutableState<bool> _isEnabled = new(true);
readonly MutableState<bool> _isRefreshing = new(false);
readonly MutableState<bool> _isEnabled = new(true);
readonly MutableState<long?> _refreshColor = new((long?)null);
// Bumped whenever Content swaps so BuildNode reads the live
// PresentedContent reference (IView itself doesn't fit in
// MutableState<T>; same trick as ContentViewHandler).
readonly MutableState<int> _contentVersion = new(0);
readonly MutableState<int> _contentVersion = new(0);

/// <summary>Construct a handler with the default mappers.</summary>
public RefreshViewHandler() : base(Mapper, CommandMapper) { }
Expand All @@ -95,9 +91,18 @@ public override ComposableNode BuildNode(IComposer composer)
var context = MauiContext
?? throw new InvalidOperationException("MauiContext not set on RefreshViewHandler.");

bool isEnabled = _isEnabled.Value;
bool isEnabled = _isEnabled.Value;
bool isRefreshing = _isRefreshing.Value;

// Share one PullToRefreshState wrapper between the box and
// the optional Indicator override. The box's Render populates
// state.Jvm during the first composition; the indicator
// lambda runs inside that same Render afterwards, so it sees
// the populated handle.
var state = new PullToRefreshState();

var box = new ComposePullToRefreshBox(
isRefreshing: _isRefreshing.Value,
isRefreshing: isRefreshing,
onRefresh: () =>
{
// Mirror MAUI's stock Android RefreshView handler: only
Expand All @@ -118,7 +123,19 @@ public override ComposableNode BuildNode(IComposer composer)
if (!isEnabled) return;
_isRefreshing.Value = true;
view.IsRefreshing = true;
});
},
state: state);

// RefreshColor → spinner glyph tint. null/non-SolidPaint
// falls through to Material 3's default; non-null swaps in
// the public PullToRefreshIndicator facade.
if (_refreshColor.Value is long packedColor)
{
box.Indicator = new PullToRefreshIndicator(state, isRefreshing)
{
Color = packedColor,
};
}

// FillMaxSize so the gesture region matches MAUI's full-bleed
// RefreshView semantics, plus ApplyViewProperties for Opacity /
Expand Down Expand Up @@ -153,4 +170,21 @@ public static void MapContent(RefreshViewHandler handler, IRefreshView _) =>
/// </summary>
public static void MapIsRefreshEnabled(RefreshViewHandler handler, IRefreshView view) =>
handler._isEnabled.Value = view.IsRefreshEnabled;

/// <summary>
/// Map <see cref="IRefreshView.RefreshColor"/> to the spinner
/// glyph color. <c>null</c> (or a non-<see cref="SolidPaint"/>
/// brush) falls back to Material 3's default theme tint; a
/// non-null solid color swaps in a
/// <see cref="PullToRefreshIndicator"/> with the packed color.
/// </summary>
/// <remarks>
/// <see cref="IRefreshView.RefreshColor"/> is a
/// <see cref="Paint"/> on the interface so consumers can in theory
/// bind a gradient brush — but MAUI's stock platform handlers all
/// flatten that to a single tint, so we do the same. Gradient
/// paints are silently treated as "no color set".
/// </remarks>
public static void MapRefreshColor(RefreshViewHandler handler, IRefreshView view) =>
handler._refreshColor.Value = ColorMapping.ToPackedLong((view.RefreshColor as SolidPaint)?.Color);
}
19 changes: 13 additions & 6 deletions src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,19 @@ public partial class ScrollViewHandler : ComposeElementHandler<IScrollView>
[nameof(IScrollView.Orientation)] = MapOrientation,
[nameof(IScrollView.Content)] = MapContent,
// TODO: IScrollView.HorizontalScrollBarVisibility /
// VerticalScrollBarVisibility — Compose's
// Modifier.verticalScroll / horizontalScroll API doesn't
// expose a scrollbar-visibility flag. Suppressing the
// overlay scrollbar requires a hand-built rememberScrollState
// + custom Scrollbar overlay; revisit when porting
// PullToRefresh-style scroll affordances.
// VerticalScrollBarVisibility — Compose Foundation 1.9 /
// 1.10 / 1.11 (and the Material3 layer on top) don't
// expose any public Scrollbar / scrollbar API on Android.
// The androidx.compose.foundation.v2.scrollbar extension
// exists only on the Multiplatform desktop target;
// Modifier.verticalScroll / horizontalScroll have no
// scrollbar-visibility flag at all. Wiring even the
// Default / Always / Never enum to a visible affordance
// therefore requires a hand-built ScrollState +
// animated overlay drawn into a sibling Box — meaningful
// surgery and a non-trivial new public surface. Leaving
// unmapped so the coverage report flags the gap rather
// than claiming a no-op wire.
};

/// <summary>Command mapper (inherits view-level commands; no extras).</summary>
Expand Down
Loading
Loading