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
15 changes: 7 additions & 8 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-12 22:58 UTC.
Generated by `scripts/maui-coverage.cs` on 2026-06-15 22: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**: 24 (**55.8%**)
- **Property-mapper keys covered**: 883 / 1224 (**72.1%**)
- **Property-mapper keys covered**: 884 / 1224 (**72.2%**)

### Per-category coverage

| Category | Handlers | Keys |
| --- | --- | --- |
| **Pages / Navigation** | 1/4 (25%) | 33/130 (25%) |
| **Containers** | 5/5 (100%) | 166/174 (95%) |
| **Leaves** | 18/18 (100%) | 684/695 (98%) |
| **Leaves** | 18/18 (100%) | 685/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 Down Expand Up @@ -56,7 +56,7 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem
| 🟡 | `LabelHandler` | Leaves | `Label` | `LabelHandler` | 39 / 40 (98%) |
| 🟡 | `PickerHandler` | Leaves | `Picker` | `PickerHandler` | 40 / 41 (98%) |
| ✅ | `ProgressBarHandler` | Leaves | `ProgressBar` | `ProgressBarHandler` | 33 / 33 (100%) |
| 🟡 | `RadioButtonHandler` | Leaves | `RadioButton` | `RadioButtonHandler` | 36 / 39 (92%) |
| 🟡 | `RadioButtonHandler` | Leaves | `RadioButton` | `RadioButtonHandler` | 37 / 39 (95%) |
| 🟡 | `SearchBarHandler` | Leaves | `SearchBar` | `SearchBarHandler` | 46 / 47 (98%) |
| 🟡 | `SliderHandler` | Leaves | `Slider` | `SliderHandler` | 37 / 38 (97%) |
| ✅ | `StepperHandler` | Leaves | `Stepper` | `StepperHandler` | 35 / 35 (100%) |
Expand Down Expand Up @@ -113,8 +113,8 @@ Most often this is a non-trivial property we haven't wired up yet
dashed stroke patterns on `Border`).

- **`BorderHandler`** (88%) — missing: `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit`
- **`RadioButtonHandler`** (92%) — missing: `CornerRadius`, `StrokeColor`, `StrokeThickness`
- **`ScrollViewHandler`** (94%) — missing: `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility`
- **`RadioButtonHandler`** (95%) — missing: `CornerRadius`, `StrokeThickness`
- **`ImageHandler`** (97%) — missing: `IsAnimationPlaying`
- **`RefreshViewHandler`** (97%) — missing: `RefreshColor`
- **`ImageButtonHandler`** (97%) — missing: `IsAnimationPlaying`
Expand Down Expand Up @@ -1175,12 +1175,11 @@ Backed by `ProgressBarHandler`. **33 / 33 keys (100%)**.

### 🟡 `RadioButtonHandler` — `RadioButton`

Backed by `RadioButtonHandler`. **36 / 39 keys (92%)**.
Backed by `RadioButtonHandler`. **37 / 39 keys (95%)**.

Missing keys:

- [ ] `CornerRadius`
- [ ] `StrokeColor`
- [ ] `StrokeThickness`

<details><summary>All stock keys</summary>
Expand Down Expand Up @@ -1215,7 +1214,7 @@ Missing keys:
- [x] `ScaleY`
- [x] `Semantics`
- [x] `Shadow`
- [ ] `StrokeColor`
- [x] `StrokeColor`
- [ ] `StrokeThickness`
- [x] `TextColor`
- [x] `ToolTip`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* SwitchHandler -> IsOn, OnColor (-> SwitchColors checkedTrack),
ThumbColor (-> SwitchColors checkedThumb)
* RadioButtonHandler -> IsChecked, Content (-> Compose Text inside a Row),
TextColor, FontSize.
TextColor, FontSize, StrokeColor.

Each section has an echo Label updated from the control's
CheckedChanged / Toggled event so manual taps verify the
Expand Down Expand Up @@ -72,7 +72,7 @@
x:Name="SwitchEcho"
Text="Default: True · Tinted: False" />

<Label Text="RadioButton — three options in a group."
<Label Text="RadioButton — three options in a group, plus stroke color."
FontAttributes="Bold" />

<RadioButton
Expand All @@ -94,6 +94,12 @@
FontSize="18"
FontAttributes="Bold"
CheckedChanged="OnFruitChanged" />
<RadioButton
Content="Dragonfruit (red ring)"
Value="dragonfruit"
GroupName="fruit"
BorderColor="#D32F2F"
CheckedChanged="OnFruitChanged" />

<Label
x:Name="FruitEcho"
Expand Down
60 changes: 47 additions & 13 deletions src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AndroidX.Compose;
using AndroidX.Compose.Material3;
using AndroidX.Compose.Runtime;
using Microsoft.AndroidX.Compose.Maui.Platform;
using Microsoft.Maui.Handlers;
Expand Down Expand Up @@ -56,18 +57,28 @@ public partial class RadioButtonHandler : ComposeElementHandler<IRadioButton>
public static IPropertyMapper<IRadioButton, RadioButtonHandler> Mapper =
new PropertyMapper<IRadioButton, RadioButtonHandler>(ViewHandler.ViewMapper)
{
[nameof(IRadioButton.IsChecked)] = MapIsChecked,
[nameof(MauiRadioButton.Content)] = MapContent,
[nameof(ITextStyle.TextColor)] = MapTextColor,
[nameof(ITextStyle.Font)] = MapFont,
[nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
// TODO: IButtonStroke.{StrokeColor, StrokeThickness,
// CornerRadius} — Material 3's RadioButton renders a fixed
// 20dp circle with no public hook for an outer border or
// corner radius (the chrome is wholly owned by the
// composable's internal Canvas drawing). Wiring would
// require a hand-rolled custom radio composable rather than
// a public-facade extension. Out of scope for this PR.
[nameof(IRadioButton.IsChecked)] = MapIsChecked,
[nameof(MauiRadioButton.Content)] = MapContent,
[nameof(ITextStyle.TextColor)] = MapTextColor,
[nameof(ITextStyle.Font)] = MapFont,
[nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
[nameof(IButtonStroke.StrokeColor)] = MapStrokeColor,
// TODO: IButtonStroke.{StrokeThickness, CornerRadius} —
// Material 3's RadioButton draws a fixed 20.dp circle whose
// ring thickness and shape are baked into the composable's
// internal Canvas (`drawCircle` with a hard-coded
// `RadioStrokeWidth = 2.dp`); the public surface
// (`RadioButtonColors`) only exposes the ring colour, not
// its geometry. Honouring these would require replacing
// the call to `RadioButtonKt.RadioButton` with a hand-rolled
// `Canvas { drawCircle / drawArc }` composable, which loses
// Material 3's ripple, state-layer, focus indicator, and
// accessibility chrome. A `CornerRadius` override is also
// semantically off — XAML callers reaching for it on a
// `RadioButton` are typically trying to make a control that
// is no longer recognisable as a radio button. Held back
// deliberately; revisit if a public M3 API surfaces these
// knobs.
};

/// <summary>Command mapper (inherits view-level commands; no extras).</summary>
Expand All @@ -80,6 +91,7 @@ public partial class RadioButtonHandler : ComposeElementHandler<IRadioButton>
readonly MutableState<int?> _fontSize = new((int?)null);
readonly MutableState<bool> _bold = new(false);
readonly MutableState<float?> _letterSpacing = new((float?)null);
readonly MutableState<long?> _strokeColor = new((long?)null);

/// <summary>Construct a handler with the default mappers.</summary>
public RadioButtonHandler() : base(Mapper, CommandMapper) { }
Expand All @@ -99,8 +111,13 @@ public override ComposableNode BuildNode(IComposer composer)
var bold = _bold.Value;
var spacing = _letterSpacing.Value;
var label = _label.Value;
var stroke = _strokeColor.Value;

var radio = new ComposeRadioButton(selected: _checked.Value, onClick: OnSelected);
if (stroke is not null)
radio.Colors = composer.RadioButtonColors(
selectedColor: stroke,
unselectedColor: stroke);
var gestureModifier = Modifier.Companion.ApplyGestures(virtualView, MauiContext).ApplySemantics(virtualView);
if (string.IsNullOrEmpty(label))
{
Expand All @@ -124,7 +141,12 @@ public override ComposableNode BuildNode(IComposer composer)
radio,
text,
};
row.Modifier = gestureModifier;
// Fill the available width so the radio sits at the start of its
// layout slot — without it the Row hugs `radio + label` and the
// MAUI host centers each row independently, which makes a column
// of radios with different label lengths visibly stagger
// horizontally. Matches stock MAUI's left-anchored layout.
row.Modifier = Modifier.Companion.FillMaxWidth().Then(gestureModifier);
return row;
}

Expand Down Expand Up @@ -187,4 +209,16 @@ public static void MapFont(RadioButtonHandler handler, IRadioButton rb)
/// </summary>
public static void MapCharacterSpacing(RadioButtonHandler handler, IRadioButton rb) =>
handler._letterSpacing.Value = rb.CharacterSpacing != 0 ? (float)rb.CharacterSpacing : null;

/// <summary>
/// Map <see cref="IButtonStroke.StrokeColor"/> to the Compose
/// <see cref="RadioButtonColors"/> ring slots — applied to both
/// <c>selectedColor</c> and <c>unselectedColor</c> so the user-
/// supplied colour is visible whether the radio is on or off
/// (matches stock MAUI's <c>UpdateStrokeColor</c>, which tints
/// the ring drawable in every check state). The disabled-state
/// ring colour stays on the M3 theme default.
/// </summary>
public static void MapStrokeColor(RadioButtonHandler handler, IRadioButton rb) =>
handler._strokeColor.Value = ColorMapping.ToPackedLong(rb.StrokeColor);
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ internal static class ComposeReferenceTypes
"AndroidX.Compose.Material3.ButtonColors",
"AndroidX.Compose.Material3.SliderColors",
"AndroidX.Compose.Material3.CheckboxColors",
"AndroidX.Compose.Material3.RadioButtonColors",
"AndroidX.Compose.Material3.SwitchColors",
"AndroidX.Compose.UI.Text.TextStyle",
"AndroidX.Compose.UI.Text.Input.IVisualTransformation",
Expand Down
19 changes: 10 additions & 9 deletions src/Microsoft.AndroidX.Compose/ComposeBridges.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3725,20 +3725,21 @@ public static partial void Switch(bool @checked, IFunction1 onCheckedChange, IMo

[ComposeFacade(Defaults = typeof(RadioButtonDefault))]
public static partial void RadioButton(
bool selected,
IFunction0 onClick,
IModifier? modifier,
bool enabled = true,
int defaults = 0,
IComposer composer = null!);

public static partial void RadioButton(bool selected, IFunction0 onClick, IModifier? modifier, bool enabled, int defaults, IComposer composer)
bool selected,
IFunction0 onClick,
IModifier? modifier,
bool enabled = true,
AndroidX.Compose.Material3.RadioButtonColors? colors = null,
int defaults = 0,
IComposer composer = null!);

public static partial void RadioButton(bool selected, IFunction0 onClick, IModifier? modifier, bool enabled, AndroidX.Compose.Material3.RadioButtonColors? colors, int defaults, IComposer composer)
=> RadioButtonKt.RadioButton(
selected: selected,
onClick: onClick,
modifier: modifier,
enabled: enabled,
colors: null,
colors: colors,
interactionSource: null,
_composer: composer,
p7: 0,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Runtime.CompilerServices;
using AndroidX.Compose.Material3;
using AndroidX.Compose.Runtime;

namespace AndroidX.Compose;

/// <summary>
/// Static factories mirroring Kotlin's
/// <c>androidx.compose.material3.RadioButtonDefaults</c> singleton —
/// returns a <see cref="RadioButtonColors"/> configured for a particular
/// override pattern. Surfaced as <see cref="IComposer"/> extensions so
/// call sites read <c>composer.RadioButtonColors(selectedColor: ...)</c>.
/// </summary>
public static partial class ComposeExtensions
{
/// <summary>
/// Mirrors Kotlin's
/// <c>RadioButtonDefaults.colors(selectedColor = …, unselectedColor = …,
/// disabledSelectedColor = …, disabledUnselectedColor = …)</c>.
///
/// <para>The Kotlin <c>colors(...)</c> factory takes a
/// <c>$default</c> bitmask, but the C# binder strips it because
/// every parameter is the <c>@JvmInline value class Color</c>. We
/// therefore call the parameterless <c>colors(composer, _changed)</c>
/// to obtain the theme defaults and build the result via the bound
/// <c>RadioButtonColors.copy(...)</c> overload — the only
/// four-<c>long</c> entry point that survives the binder strip.</para>
///
/// <para>Each non-<c>null</c> argument substitutes for the
/// corresponding default; <c>null</c> falls back to the value from
/// the current <c>MaterialTheme</c>.</para>
/// </summary>
public static RadioButtonColors RadioButtonColors(
this IComposer composer,
long? selectedColor = null,
long? unselectedColor = null,
long? disabledSelectedColor = null,
long? disabledUnselectedColor = null,
[CallerLineNumber] int line = 0,
[CallerFilePath] string file = "")
{
ArgumentNullException.ThrowIfNull(composer);

composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file));
try
{
var d = AndroidX.Compose.Material3.RadioButtonDefaults.Instance.Colors(composer, 0);
if (selectedColor is null && unselectedColor is null
&& disabledSelectedColor is null && disabledUnselectedColor is null)
return d;

return d.Copy(
selectedColor: selectedColor ?? d.SelectedColor,
unselectedColor: unselectedColor ?? d.UnselectedColor,
disabledSelectedColor: disabledSelectedColor ?? d.DisabledSelectedColor,
disabledUnselectedColor: disabledUnselectedColor ?? d.DisabledUnselectedColor);
}
finally
{
composer.EndReplaceableGroup();
}
}
}
3 changes: 3 additions & 0 deletions src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,8 @@ AndroidX.Compose.PullToRefreshState.DistanceFraction.get -> float
AndroidX.Compose.PullToRefreshState.IsAnimating.get -> bool
AndroidX.Compose.PullToRefreshState.PullToRefreshState() -> void
AndroidX.Compose.RadioButton
AndroidX.Compose.RadioButton.Colors.get -> AndroidX.Compose.Material3.RadioButtonColors?
AndroidX.Compose.RadioButton.Colors.set -> void
AndroidX.Compose.RadioButton.RadioButton(bool selected, System.Action! onClick, bool enabled = true) -> void
AndroidX.Compose.RangeSlider
AndroidX.Compose.RangeSlider.RangeSlider((float Start, float End) value, System.Action<(float Start, float End)>! onValueChange) -> void
Expand Down Expand Up @@ -1629,6 +1631,7 @@ static AndroidX.Compose.ComposeExtensions.ProduceState<T>(this AndroidX.Compose.
static AndroidX.Compose.ComposeExtensions.ProduceState<T>(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, object? key1, System.Func<AndroidX.Compose.MutableState<T>!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> AndroidX.Compose.MutableState<T>!
static AndroidX.Compose.ComposeExtensions.ProduceState<T>(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, System.Func<AndroidX.Compose.MutableState<T>!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> AndroidX.Compose.MutableState<T>!
static AndroidX.Compose.ComposeExtensions.ProduceStateKeyed<T>(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, object?[]! keys, System.Func<AndroidX.Compose.MutableState<T>!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> AndroidX.Compose.MutableState<T>!
static AndroidX.Compose.ComposeExtensions.RadioButtonColors(this AndroidX.Compose.Runtime.IComposer! composer, long? selectedColor = null, long? unselectedColor = null, long? disabledSelectedColor = null, long? disabledUnselectedColor = null, int line = 0, string! file = "") -> AndroidX.Compose.Material3.RadioButtonColors!
static AndroidX.Compose.ComposeExtensions.Remember<T>(this AndroidX.Compose.Runtime.IComposer! composer, System.Func<T>! factory, int line = 0, string! file = "") -> T
static AndroidX.Compose.ComposeExtensions.Remember<T>(this AndroidX.Compose.Runtime.IComposer! composer, System.Func<T>! factory, object? key1, int line = 0, string! file = "") -> T
static AndroidX.Compose.ComposeExtensions.Remember<T>(this AndroidX.Compose.Runtime.IComposer! composer, System.Func<T>! factory, object? key1, object? key2, int line = 0, string! file = "") -> T
Expand Down
Loading