diff --git a/docs/maui-coverage.md b/docs/maui-coverage.md
index 6094154..a737508 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 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`).
@@ -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%) |
@@ -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%) |
@@ -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%) |
@@ -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
@@ -439,13 +437,9 @@ Extra keys we map (no stock counterpart):
-### 🟡 `RefreshViewHandler` — `RefreshView`
+### ✅ `RefreshViewHandler` — `RefreshView`
-Backed by `RefreshViewHandler`. **34 / 35 keys (97%)**.
-
-Missing keys:
-
-- [ ] `RefreshColor`
+Backed by `RefreshViewHandler`. **35 / 35 keys (100%)**.
All stock keys
@@ -468,7 +462,7 @@ Missing keys:
- [x] `MinimumHeight`
- [x] `MinimumWidth`
- [x] `Opacity`
-- [ ] `RefreshColor`
+- [x] `RefreshColor`
- [x] `Rotation`
- [x] `RotationX`
- [x] `RotationY`
@@ -1257,13 +1251,9 @@ Extra keys we map (no stock counterpart):
-### 🟡 `SliderHandler` — `Slider`
-
-Backed by `SliderHandler`. **37 / 38 keys (97%)**.
-
-Missing keys:
+### ✅ `SliderHandler` — `Slider`
-- [ ] `ThumbImageSource`
+Backed by `SliderHandler`. **38 / 38 keys (100%)**.
All stock keys
@@ -1297,7 +1287,7 @@ Missing keys:
- [x] `Semantics`
- [x] `Shadow`
- [x] `ThumbColor`
-- [ ] `ThumbImageSource`
+- [x] `ThumbImageSource`
- [x] `ToolTip`
- [x] `Toolbar`
- [x] `TranslationX`
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml
index 1d033fc..3c0d6b7 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml
@@ -18,6 +18,7 @@
VerticalStackLayout folds into the parent Page composition.
-->
+ the pull gesture even kicks off a refresh.
+ RefreshColor (above on the RefreshView) tints the
+ spinner glyph orange. -->
-
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SlidersPage.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SlidersPage.xaml.cs
index 56789a3..1eb44a9 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SlidersPage.xaml.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SlidersPage.xaml.cs
@@ -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;
}
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs
index 8c3849f..5a2b969 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs
@@ -53,9 +53,18 @@ public partial class ImageButtonHandler : ComposeElementHandlerCommand mapper (inherits view-level commands; no extras).
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs
index e3e6187..c4c9718 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs
@@ -53,14 +53,23 @@ public partial class ImageHandler : ComposeElementHandler
{
[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.
};
/// Command mapper (inherits view-level commands; no extras).
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs
index d828aef..6c36125 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs
@@ -39,11 +39,11 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers;
/// / , so
/// nested Compose-backed views fold into the parent composition.
///
-/// isn't wired yet: the
-/// current C# PullToRefreshBox facade doesn't expose
-/// containerColor / contentColor slots. Until it does,
-/// the spinner uses Material 3's default theme tint. Tracked as a
-/// follow-up for the next slice.
+/// maps to the spinner
+/// glyph color via the public
+/// facade, which wraps
+/// PullToRefreshDefaults.Instance.Indicator(...). When unset
+/// the spinner falls back to Material 3's default tint.
///
public partial class RefreshViewHandler : ComposeElementHandler
{
@@ -56,25 +56,21 @@ public partial class RefreshViewHandler : ComposeElementHandler
{
[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.
};
/// Command mapper (inherits view-level commands; no extras).
public static CommandMapper CommandMapper =
new(ViewCommandMapper);
- readonly MutableState _isRefreshing = new(false);
- readonly MutableState _isEnabled = new(true);
+ readonly MutableState _isRefreshing = new(false);
+ readonly MutableState _isEnabled = new(true);
+ readonly MutableState _refreshColor = new((long?)null);
// Bumped whenever Content swaps so BuildNode reads the live
// PresentedContent reference (IView itself doesn't fit in
// MutableState; same trick as ContentViewHandler).
- readonly MutableState _contentVersion = new(0);
+ readonly MutableState _contentVersion = new(0);
/// Construct a handler with the default mappers.
public RefreshViewHandler() : base(Mapper, CommandMapper) { }
@@ -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
@@ -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 /
@@ -153,4 +170,21 @@ public static void MapContent(RefreshViewHandler handler, IRefreshView _) =>
///
public static void MapIsRefreshEnabled(RefreshViewHandler handler, IRefreshView view) =>
handler._isEnabled.Value = view.IsRefreshEnabled;
+
+ ///
+ /// Map to the spinner
+ /// glyph color. null (or a non-
+ /// brush) falls back to Material 3's default theme tint; a
+ /// non-null solid color swaps in a
+ /// with the packed color.
+ ///
+ ///
+ /// is a
+ /// 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".
+ ///
+ public static void MapRefreshColor(RefreshViewHandler handler, IRefreshView view) =>
+ handler._refreshColor.Value = ColorMapping.ToPackedLong((view.RefreshColor as SolidPaint)?.Color);
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs
index 59445f6..6dd2d10 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs
@@ -35,12 +35,19 @@ public partial class ScrollViewHandler : ComposeElementHandler
[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.
};
/// Command mapper (inherits view-level commands; no extras).
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs
index ae24de4..081aa8c 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs
@@ -1,10 +1,13 @@
using AndroidX.Compose;
using AndroidX.Compose.Material3;
using AndroidX.Compose.Runtime;
+using AndroidX.Compose.UI.Platform;
using Kotlin.Ranges;
+using Microsoft.AndroidX.Compose.Maui.Loaders;
using Microsoft.AndroidX.Compose.Maui.Platform;
using Microsoft.Maui.Handlers;
-using ComposeSlider = AndroidX.Compose.Slider;
+using ComposeImage = AndroidX.Compose.Image;
+using ComposeSlider = AndroidX.Compose.Slider;
namespace Microsoft.AndroidX.Compose.Maui.Handlers;
@@ -38,6 +41,14 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers;
/// MAUI exposes (thumbColor, activeTrackColor,
/// inactiveTrackColor) are wired — tick colours and the four
/// disabled siblings stay at the Material default.
+///
+/// is resolved through
+/// the shared (the same helper that
+/// backs ). When the loader resolves a
+/// painter (or drawable id) the handler assigns a
+/// to ;
+/// while the load is in flight (or no source is set) the slider
+/// keeps its default Material 3 thumb circle.
///
public partial class SliderHandler : ComposeElementHandler
{
@@ -54,11 +65,7 @@ public partial class SliderHandler : ComposeElementHandler
[nameof(ISlider.MinimumTrackColor)] = MapMinimumTrackColor,
[nameof(ISlider.MaximumTrackColor)] = MapMaximumTrackColor,
[nameof(ISlider.ThumbColor)] = MapThumbColor,
- // TODO: ISlider.ThumbImageSource — Material 3's Slider draws
- // its thumb as a fixed circle; supplying a custom drawable
- // requires the lower-level Slider(state, ..., thumb = { ... })
- // overload plus piping the resolved Painter through our
- // ImageSourceLoader. Larger surgery than fits this PR.
+ [nameof(ISlider.ThumbImageSource)] = MapThumbImageSource,
};
/// Command mapper (inherits view-level commands; no extras).
@@ -71,6 +78,14 @@ public partial class SliderHandler : ComposeElementHandler
readonly MutableState _thumbColor = new((long?)null);
readonly MutableState _minTrackColor = new((long?)null);
readonly MutableState _maxTrackColor = new((long?)null);
+ ImageSourceLoader? _thumbLoader;
+
+ // Lazy — sliders without ThumbImageSource set never allocate the
+ // loader or its IImageSourcePart adapter.
+ ImageSourceLoader ThumbLoader =>
+ _thumbLoader ??= new ImageSourceLoader(
+ this,
+ () => VirtualView is ISlider s ? new SliderThumbImageSourcePart(s) : null);
/// Construct a handler with the default mappers.
public SliderHandler() : base(Mapper, CommandMapper) { }
@@ -85,14 +100,26 @@ public override ComposableNode BuildNode(IComposer composer)
var virtualView = VirtualView
?? throw new InvalidOperationException("VirtualView not set on SliderHandler.");
- var min = _min.Value;
- var max = _max.Value;
- var thumb = _thumbColor.Value;
- var minTrack = _minTrackColor.Value;
- var maxTrack = _maxTrackColor.Value;
+ var min = _min.Value;
+ var max = _max.Value;
+ var thumb = _thumbColor.Value;
+ var minTrack = _minTrackColor.Value;
+ var maxTrack = _maxTrackColor.Value;
var slider = new ComposeSlider(_value.Value, OnValueChanged);
+ // ThumbImageSource → ComposeImage in the Thumb slot. Painter
+ // wins over drawable id (matches ImageHandler), so a freshly
+ // loaded BitmapPainter immediately replaces any stale fast-path
+ // resource id.
+ if (_thumbLoader is { } loader)
+ {
+ if (loader.Painter.Value is { } painter)
+ slider.Thumb = new ComposeImage(painter) { Modifier = s_thumbSize };
+ else if (loader.DrawableResourceId.Value is int drawableId)
+ slider.Thumb = new ComposeImage(drawableId) { Modifier = s_thumbSize };
+ }
+
// Only allocate a Kotlin ClosedFloatingPointRange when the
// bounds aren't Compose's stock [0, 1] — RangeTo always
// allocates so this is the cheapest skip.
@@ -111,6 +138,18 @@ public override ComposableNode BuildNode(IComposer composer)
return slider;
}
+ // Compose's stock thumb is a 20-dp filled circle; sizing the
+ // image slightly bigger keeps the source bitmap visible without
+ // overflowing the slider's intrinsic height.
+ static readonly Modifier s_thumbSize = Modifier.Size(40);
+
+ ///
+ protected override void DisconnectHandler(ComposeView platformView)
+ {
+ _thumbLoader?.Reset();
+ base.DisconnectHandler(platformView);
+ }
+
void OnValueChanged(float newValue)
{
// Update Compose state synchronously so the rendered position
@@ -148,4 +187,40 @@ public static void MapMaximumTrackColor(SliderHandler handler, ISlider slider) =
/// Map to SliderColors.thumbColor.
public static void MapThumbColor(SliderHandler handler, ISlider slider) =>
handler._thumbColor.Value = ColorMapping.ToPackedLong(slider.ThumbColor);
+
+ ///
+ /// Map through the shared
+ /// . When the loader resolves a
+ /// painter or drawable id, assigns a
+ /// to ;
+ /// while the load is in flight (or the source is null) the
+ /// default thumb circle keeps drawing.
+ ///
+ ///
+ /// Declared async void deliberately — mirrors
+ /// ; see that mapper's remarks
+ /// for the fire-and-forget rationale.
+ ///
+ public static async void MapThumbImageSource(SliderHandler handler, ISlider slider) =>
+ await handler.ThumbLoader.LoadAsync(slider.ThumbImageSource).ConfigureAwait(false);
+
+ ///
+ /// Adapter exposing the slider's
+ /// to as an
+ /// . itself
+ /// doesn't implement (only the
+ /// stand-alone /
+ /// virtual views do), so we wrap it.
+ ///
+ sealed class SliderThumbImageSourcePart : IImageSourcePart
+ {
+ readonly ISlider _slider;
+ public SliderThumbImageSourcePart(ISlider slider) => _slider = slider;
+
+ public IImageSource? Source => _slider.ThumbImageSource;
+ public bool IsAnimationPlaying => false;
+ public bool IsLoading => false;
+
+ public void UpdateIsLoading(bool isLoading) { /* no-op */ }
+ }
}
diff --git a/src/Microsoft.AndroidX.Compose/ComposeBridges.cs b/src/Microsoft.AndroidX.Compose/ComposeBridges.cs
index 576ba13..e5f3eae 100644
--- a/src/Microsoft.AndroidX.Compose/ComposeBridges.cs
+++ b/src/Microsoft.AndroidX.Compose/ComposeBridges.cs
@@ -1446,7 +1446,7 @@ public static IntPtr RememberSheetState(
"Landroidx/compose/ui/Alignment;Lkotlin/jvm/functions/Function3;" +
"Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V",
Defaults = typeof(PullToRefreshBoxDefault))]
- [ComposeFacade]
+ [ComposeFacade(Scope = "Box")]
public static partial void PullToRefreshBox(
bool isRefreshing,
IFunction0 onRefresh,
@@ -1454,6 +1454,7 @@ public static partial void PullToRefreshBox(
StateType = typeof(PullToRefreshState))]
IntPtr state,
IModifier? modifier,
+ IFunction3? indicator,
IFunction3 content,
int defaults,
IComposer composer);
@@ -3866,6 +3867,8 @@ public static partial void Slider(
[Callback(typeof(float))]
IFunction1 onValueChange,
IModifier? modifier,
+ [Slot("Thumb")]
+ IFunction3? thumb,
IClosedFloatingPointRange? valueRange,
SliderColors? colors,
bool enabled = true,
@@ -3873,25 +3876,34 @@ public static partial void Slider(
int defaults = 0,
IComposer composer = null!);
- // Slot rename pattern: C# `p5` is the real Kotlin `steps` Int; C# named
- // `steps:` is the JVM `$changed` recomposition int; `_changed` is `$default`.
- // Cross-check with RangeSlider above, which uses the same SliderKt
- // overload (both pass `steps: 0` for $changed and route the user-facing
- // steps through `p5:`).
- public static partial void Slider(float value, IFunction1 onValueChange, IModifier? modifier, IClosedFloatingPointRange? valueRange, SliderColors? colors, bool enabled, int steps, int defaults, IComposer composer)
+ // Wrapper-passthrough that calls the rich (Float, ..., thumb, track,
+ // valueRange) overload of SliderKt.Slider directly. The 11-user-param
+ // shape lets the facade expose a `Thumb` slot. We never supply
+ // `track:` — Kotlin's default is a stock track lambda — so bit 9
+ // (`track`) is force-OR'd into the $default mask before forwarding.
+ //
+ // Slot rename pattern (matches RangeSlider above):
+ // C# `p7` = real Kotlin `steps` Int
+ // C# `steps:` (after `_composer`) = JVM `$changed` int
+ // C# `_changed` = JVM `$changed1`
+ // C# `_changed1` = JVM `$default` ← the bitmask we forward
+ public static partial void Slider(float value, IFunction1 onValueChange, IModifier? modifier, IFunction3? thumb, IClosedFloatingPointRange? valueRange, SliderColors? colors, bool enabled, int steps, int defaults, IComposer composer)
=> SliderKt.Slider(
value: value,
onValueChange: onValueChange,
modifier: modifier,
enabled: enabled,
- valueRange: valueRange,
- p5: steps,
onValueChangeFinished: null,
colors: colors,
interactionSource: null,
+ p7: steps,
+ thumb: thumb,
+ track: null,
+ valueRange: valueRange,
_composer: composer,
steps: 0,
- _changed: defaults);
+ _changed: 0,
+ _changed1: defaults | (int)SliderDefault.Track);
// FlowRow / FlowColumn — Phase 8 wrapper-passthrough facades. The
// simpler 7-Kotlin-param overloads (no FlowRowOverflow / FlowColumnOverflow
diff --git a/src/Microsoft.AndroidX.Compose/ComposeDefaults.cs b/src/Microsoft.AndroidX.Compose/ComposeDefaults.cs
index c78932d..1730f70 100644
--- a/src/Microsoft.AndroidX.Compose/ComposeDefaults.cs
+++ b/src/Microsoft.AndroidX.Compose/ComposeDefaults.cs
@@ -881,14 +881,16 @@
"disabledActiveTickColor", "disabledInactiveTrackColor",
"disabledInactiveTickColor")]
-// androidx.compose.material3.SliderKt.Slider (simple float overload):
-// 9 user params; bits 0 (value) and 1 (onValueChange) always provided.
-// The longer overload with Function3 thumb/track slots has non-null
-// Kotlin defaults that can't be safely substituted, so we lock in this
-// simpler shape via the declarative form.
+// androidx.compose.material3.SliderKt.Slider (rich float overload with
+// thumb / track slots): 11 user params. Bits 0 (value) and 1
+// (onValueChange) always provided; bit 9 (track) is forced set
+// because Kotlin's default for that slot is a non-null lambda
+// (`SliderDefaults.Track(...)`) we cannot substitute with `null`
+// without an NPE. The simpler 9-param overload is no longer used —
+// this rich shape is required so the facade can expose a `Thumb` slot.
[assembly: ComposeDefaults("SliderDefault",
- "!value", "!onValueChange", "modifier", "enabled", "valueRange",
- "steps", "onValueChangeFinished", "colors", "interactionSource")]
+ "!value", "!onValueChange", "modifier", "enabled", "onValueChangeFinished",
+ "colors", "interactionSource", "steps", "thumb", "track", "valueRange")]
// androidx.compose.material3.SliderKt.RangeSlider (simple
// ClosedFloatingPointRange overload): 8 user params; bits 0 (value)
diff --git a/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt b/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt
index 644811d..70b35ed 100644
--- a/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt
@@ -174,8 +174,8 @@ AndroidX.Compose.Color.WithAlpha(byte alpha) -> AndroidX.Compose.Color
AndroidX.Compose.Color.WithOpacity(float opacity) -> AndroidX.Compose.Color
AndroidX.Compose.Column
AndroidX.Compose.Column.Column() -> void
-AndroidX.Compose.Column.Column(AndroidX.Compose.Arrangement? verticalArrangement) -> void
AndroidX.Compose.Column.Column(AndroidX.Compose.Arrangement? verticalArrangement, AndroidX.Compose.Alignment.Horizontal? horizontalAlignment) -> void
+AndroidX.Compose.Column.Column(AndroidX.Compose.Arrangement? verticalArrangement) -> void
AndroidX.Compose.ComposableContainer
AndroidX.Compose.ComposableContainer.Add(AndroidX.Compose.ComposableNode? child) -> void
AndroidX.Compose.ComposableContainer.Add(AndroidX.Compose.Modifier! modifier) -> void
@@ -220,10 +220,10 @@ AndroidX.Compose.DatePickerState.DatePickerState() -> void
AndroidX.Compose.DatePickerState.DatePickerState(long? initialSelectedDateMillis = null, Kotlin.Ranges.IntRange? initialYearRange = null, AndroidX.Compose.Material3.ISelectableDates? initialSelectableDates = null) -> void
AndroidX.Compose.DatePickerState.DisplayedMonthMillis.get -> long
AndroidX.Compose.DatePickerState.DisplayedMonthMillis.set -> void
-AndroidX.Compose.DatePickerState.InitialDisplayMode.get -> int?
-AndroidX.Compose.DatePickerState.InitialDisplayMode.set -> void
AndroidX.Compose.DatePickerState.InitialDisplayedMonthMillis.get -> Java.Lang.Long?
AndroidX.Compose.DatePickerState.InitialDisplayedMonthMillis.set -> void
+AndroidX.Compose.DatePickerState.InitialDisplayMode.get -> int?
+AndroidX.Compose.DatePickerState.InitialDisplayMode.set -> void
AndroidX.Compose.DatePickerState.InitialSelectableDates.get -> AndroidX.Compose.Material3.ISelectableDates?
AndroidX.Compose.DatePickerState.InitialSelectableDates.set -> void
AndroidX.Compose.DatePickerState.InitialSelectedDateMillis.get -> Java.Lang.Long?
@@ -232,18 +232,6 @@ AndroidX.Compose.DatePickerState.InitialYearRange.get -> Kotlin.Ranges.IntRange?
AndroidX.Compose.DatePickerState.InitialYearRange.set -> void
AndroidX.Compose.DatePickerState.SelectedDateMillis.get -> long?
AndroidX.Compose.DatePickerState.SelectedDateMillis.set -> void
-AndroidX.Compose.DateRangeSelectableDates
-AndroidX.Compose.DateRangeSelectableDates.DateRangeSelectableDates() -> void
-AndroidX.Compose.DateRangeSelectableDates.IsSelectableDate(long utcTimeMillis) -> bool
-AndroidX.Compose.DateRangeSelectableDates.IsSelectableYear(int year) -> bool
-AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.get -> long?
-AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.set -> void
-AndroidX.Compose.DateRangeSelectableDates.MaxYear.get -> int?
-AndroidX.Compose.DateRangeSelectableDates.MaxYear.set -> void
-AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.get -> long?
-AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.set -> void
-AndroidX.Compose.DateRangeSelectableDates.MinYear.get -> int?
-AndroidX.Compose.DateRangeSelectableDates.MinYear.set -> void
AndroidX.Compose.DateRangePicker
AndroidX.Compose.DateRangePicker.DateRangePicker(AndroidX.Compose.DateRangePickerState? state = null) -> void
AndroidX.Compose.DateRangePickerDialog
@@ -263,6 +251,18 @@ AndroidX.Compose.DateRangePickerState.SelectedEndDateMillis.set -> void
AndroidX.Compose.DateRangePickerState.SelectedStartDateMillis.get -> long?
AndroidX.Compose.DateRangePickerState.SelectedStartDateMillis.set -> void
AndroidX.Compose.DateRangePickerState.SetSelection(long? startDateMillis, long? endDateMillis) -> void
+AndroidX.Compose.DateRangeSelectableDates
+AndroidX.Compose.DateRangeSelectableDates.DateRangeSelectableDates() -> void
+AndroidX.Compose.DateRangeSelectableDates.IsSelectableDate(long utcTimeMillis) -> bool
+AndroidX.Compose.DateRangeSelectableDates.IsSelectableYear(int year) -> bool
+AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.get -> long?
+AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.set -> void
+AndroidX.Compose.DateRangeSelectableDates.MaxYear.get -> int?
+AndroidX.Compose.DateRangeSelectableDates.MaxYear.set -> void
+AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.get -> long?
+AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.set -> void
+AndroidX.Compose.DateRangeSelectableDates.MinYear.get -> int?
+AndroidX.Compose.DateRangeSelectableDates.MinYear.set -> void
AndroidX.Compose.DerivedState
AndroidX.Compose.DerivedState.Value.get -> T
AndroidX.Compose.DisableSelection
@@ -544,6 +544,7 @@ AndroidX.Compose.Image.Image(AndroidX.Compose.UI.Graphics.Painter.Painter! paint
AndroidX.Compose.Image.Image(AndroidX.Compose.UI.Graphics.Painter.Painter! painter) -> void
AndroidX.Compose.Image.Image(int drawableResourceId, string? contentDescription) -> void
AndroidX.Compose.Image.Image(int drawableResourceId) -> void
+AndroidX.Compose.ImeAction
AndroidX.Compose.InputChip
AndroidX.Compose.InputChip.Avatar.get -> AndroidX.Compose.ComposableNode?
AndroidX.Compose.InputChip.Avatar.set -> void
@@ -559,9 +560,8 @@ AndroidX.Compose.InputChip.TrailingIcon.set -> void
AndroidX.Compose.IState
AndroidX.Compose.IState.Value.get -> T
AndroidX.Compose.IStateFlow
-AndroidX.Compose.ImeAction
-AndroidX.Compose.KeyboardOptionsCompanion
AndroidX.Compose.KeyboardActionsHelper
+AndroidX.Compose.KeyboardOptionsCompanion
AndroidX.Compose.KeyboardType
AndroidX.Compose.LargeFlexibleTopAppBar
AndroidX.Compose.LargeFlexibleTopAppBar.Actions.get -> AndroidX.Compose.ComposableNode?
@@ -977,7 +977,17 @@ AndroidX.Compose.PrimaryTabRow
AndroidX.Compose.PrimaryTabRow.PrimaryTabRow(int selectedTabIndex) -> void
AndroidX.Compose.ProvidedValue
AndroidX.Compose.PullToRefreshBox
+AndroidX.Compose.PullToRefreshBox.Indicator.get -> AndroidX.Compose.ComposableNode?
+AndroidX.Compose.PullToRefreshBox.Indicator.set -> void
AndroidX.Compose.PullToRefreshBox.PullToRefreshBox(bool isRefreshing, System.Action! onRefresh, AndroidX.Compose.PullToRefreshState? state = null) -> void
+AndroidX.Compose.PullToRefreshIndicator
+AndroidX.Compose.PullToRefreshIndicator.Color.get -> long
+AndroidX.Compose.PullToRefreshIndicator.Color.set -> void
+AndroidX.Compose.PullToRefreshIndicator.ContainerColor.get -> long
+AndroidX.Compose.PullToRefreshIndicator.ContainerColor.set -> void
+AndroidX.Compose.PullToRefreshIndicator.MaxDistance.get -> AndroidX.Compose.Dp?
+AndroidX.Compose.PullToRefreshIndicator.MaxDistance.set -> void
+AndroidX.Compose.PullToRefreshIndicator.PullToRefreshIndicator(AndroidX.Compose.PullToRefreshState! state, bool isRefreshing) -> void
AndroidX.Compose.PullToRefreshState
AndroidX.Compose.PullToRefreshState.DistanceFraction.get -> float
AndroidX.Compose.PullToRefreshState.IsAnimating.get -> bool
@@ -1123,6 +1133,8 @@ AndroidX.Compose.Slider
AndroidX.Compose.Slider.Colors.get -> AndroidX.Compose.Material3.SliderColors?
AndroidX.Compose.Slider.Colors.set -> void
AndroidX.Compose.Slider.Slider(float value, System.Action! onValueChange, bool enabled = true, int steps = 0) -> void
+AndroidX.Compose.Slider.Thumb.get -> AndroidX.Compose.ComposableNode?
+AndroidX.Compose.Slider.Thumb.set -> void
AndroidX.Compose.Slider.ValueRange.get -> Kotlin.Ranges.IClosedFloatingPointRange?
AndroidX.Compose.Slider.ValueRange.set -> void
AndroidX.Compose.SmallFloatingActionButton
@@ -1497,6 +1509,7 @@ override AndroidX.Compose.PermanentNavigationDrawer.Render(AndroidX.Compose.Runt
override AndroidX.Compose.PrimaryScrollableTabRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void
override AndroidX.Compose.PrimaryTabRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void
override AndroidX.Compose.PullToRefreshBox.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void
+override AndroidX.Compose.PullToRefreshIndicator.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void
override AndroidX.Compose.RadioButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void
override AndroidX.Compose.RangeSlider.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void
override AndroidX.Compose.Row.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void
@@ -2018,9 +2031,9 @@ static AndroidX.Compose.ImeAction.Previous.get -> int
static AndroidX.Compose.ImeAction.Search.get -> int
static AndroidX.Compose.ImeAction.Send.get -> int
static AndroidX.Compose.ImeAction.Unspecified.get -> int
+static AndroidX.Compose.KeyboardActionsHelper.Create(System.Action? onDone = null, System.Action? onGo = null, System.Action? onNext = null, System.Action? onPrevious = null, System.Action? onSearch = null, System.Action? onSend = null) -> AndroidX.Compose.Foundation.Text.KeyboardActions!
static AndroidX.Compose.KeyboardOptionsCompanion.Default.get -> AndroidX.Compose.Foundation.Text.KeyboardOptions!
static AndroidX.Compose.KeyboardOptionsCompanion.Get() -> AndroidX.Compose.Foundation.Text.KeyboardOptions.Companion!
-static AndroidX.Compose.KeyboardActionsHelper.Create(System.Action? onDone = null, System.Action? onGo = null, System.Action? onNext = null, System.Action? onPrevious = null, System.Action? onSearch = null, System.Action? onSend = null) -> AndroidX.Compose.Foundation.Text.KeyboardActions!
static AndroidX.Compose.KeyboardType.Ascii.get -> int
static AndroidX.Compose.KeyboardType.Decimal.get -> int
static AndroidX.Compose.KeyboardType.Email.get -> int
diff --git a/src/Microsoft.AndroidX.Compose/PullToRefreshBox.cs b/src/Microsoft.AndroidX.Compose/PullToRefreshBox.cs
index 62c6c04..9eba17c 100644
--- a/src/Microsoft.AndroidX.Compose/PullToRefreshBox.cs
+++ b/src/Microsoft.AndroidX.Compose/PullToRefreshBox.cs
@@ -12,11 +12,15 @@ namespace AndroidX.Compose;
/// observe pull progress ( /
/// ) for a custom indicator
/// or analytics; omit it and Compose creates one internally via
-/// rememberPullToRefreshState(). The stock Material 3 spinner is
-/// always used — indicator customization is not yet exposed (the
-/// underlying container + optional Function3 slot is the same
-/// hybrid shape the facade generator can't model for BottomAppBar;
-/// see .github/copilot-instructions.md).
+/// rememberPullToRefreshState(). Override the indicator by
+/// assigning — typically a
+/// bound to the same
+/// — to recolor the stock spinner
+/// or supply a fully custom one. left
+/// null uses Material 3's default.
+///
+/// The body lambda runs inside Material's Box scope, so children
+/// can use Modifier.Align(...) / Modifier.MatchParentSize().
///
///
/// var refreshing = Remember(() => new MutableState<bool>(false));
@@ -61,4 +65,4 @@ namespace AndroidX.Compose;
/// }
///
///
-public sealed partial class PullToRefreshBox;
+public sealed partial class PullToRefreshBox { }
diff --git a/src/Microsoft.AndroidX.Compose/PullToRefreshIndicator.cs b/src/Microsoft.AndroidX.Compose/PullToRefreshIndicator.cs
new file mode 100644
index 0000000..8dcd418
--- /dev/null
+++ b/src/Microsoft.AndroidX.Compose/PullToRefreshIndicator.cs
@@ -0,0 +1,103 @@
+using AndroidX.Compose.Material3.PullToRefresh;
+using AndroidX.Compose.Runtime;
+
+namespace AndroidX.Compose;
+
+///
+/// Material 3 pull-to-refresh indicator (the spinning arrow / progress
+/// glyph that appears as the user drags), exposed as a
+/// so it can be plugged into
+/// .
+///
+///
+/// Wraps the bound
+/// .Instance.Indicator(...)
+/// helper. The indicator must share its
+/// with the parent — pass the same
+/// wrapper instance to both, like:
+///
+/// var state = new PullToRefreshState();
+/// new PullToRefreshBox(isRefreshing, onRefresh, state: state)
+/// {
+/// Indicator = new PullToRefreshIndicator(state, isRefreshing) { Color = 0xFFFF0000L },
+/// };
+///
+/// Material 3's stock indicator picks up colors from the active
+/// ColorScheme; supply /
+/// only to override one (or both) of those slots.
+/// Any color left at 0L falls through to the theme default.
+///
+public sealed class PullToRefreshIndicator : ComposableNode
+{
+ // PullToRefreshDefaults.Indicator $default mask bits — order
+ // matches the bound JNI signature
+ // (state, isRefreshing, modifier, containerColor, color, maxDistance).
+ // Bits 0/1 are always supplied (state, isRefreshing).
+ const int BitModifier = 1 << 2;
+ const int BitContainerColor = 1 << 3;
+ const int BitColor = 1 << 4;
+ const int BitMaxDistance = 1 << 5;
+
+ readonly PullToRefreshState _state;
+ readonly bool _isRefreshing;
+
+ ///
+ /// Construct an indicator bound to (which
+ /// must be the same instance handed to the parent
+ /// ) and tracking
+ /// .
+ ///
+ public PullToRefreshIndicator(PullToRefreshState state, bool isRefreshing)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+ _state = state;
+ _isRefreshing = isRefreshing;
+ }
+
+ ///
+ /// ARGB-packed background color (Compose Color long packing).
+ /// 0L falls through to ColorScheme.surfaceContainerHighest.
+ ///
+ public long ContainerColor { get; set; }
+
+ ///
+ /// ARGB-packed glyph color (Compose Color long packing).
+ /// 0L falls through to ColorScheme.onSurfaceVariant.
+ ///
+ public long Color { get; set; }
+
+ ///
+ /// Optional drag-distance threshold in dp. null falls through
+ /// to Material's default (80.dp). Increase for sliders /
+ /// dense lists where the gesture region competes with content.
+ ///
+ public Dp? MaxDistance { get; set; }
+
+ ///
+ public override void Render(IComposer composer)
+ {
+ var jvm = _state.Jvm
+ ?? throw new InvalidOperationException(
+ "PullToRefreshIndicator's state is not bound. " +
+ "Pass the same PullToRefreshState wrapper to PullToRefreshBox " +
+ "(it populates state.Jvm during the box's first render).");
+
+ int mask = 0;
+ var modifier = BuildModifier();
+ if (modifier is null) mask |= BitModifier;
+ if (ContainerColor == 0L) mask |= BitContainerColor;
+ if (Color == 0L) mask |= BitColor;
+ if (MaxDistance is null) mask |= BitMaxDistance;
+
+ PullToRefreshDefaults.Instance.Indicator(
+ state: jvm,
+ isRefreshing: _isRefreshing,
+ modifier: modifier,
+ containerColor: ContainerColor,
+ color: Color,
+ maxDistance: MaxDistance?.Value ?? 0f,
+ _composer: composer,
+ p7: 0,
+ _changed: mask);
+ }
+}
diff --git a/src/Microsoft.AndroidX.Compose/Slider.cs b/src/Microsoft.AndroidX.Compose/Slider.cs
index cec3a3a..00b32e3 100644
--- a/src/Microsoft.AndroidX.Compose/Slider.cs
+++ b/src/Microsoft.AndroidX.Compose/Slider.cs
@@ -5,9 +5,9 @@ namespace AndroidX.Compose;
///
/// new Slider(value: pos.Value, onValueChange: v => pos.Value = v)
///
-/// Calls the simple (Float, (Float) -> Unit) overload — the
-/// richer overloads (with custom thumb / track slots, or
-/// a SliderState first param) aren't exposed in this facade.
+/// Calls the rich (Float, (Float) -> Unit, …, thumb) overload —
+/// custom track slot and the SliderState-first overload
+/// aren't exposed.
///
/// Optional ValueRange property surfaces Kotlin's
/// ClosedFloatingPointRange<Float> — build with
@@ -16,5 +16,19 @@ namespace AndroidX.Compose;
/// ; build via
/// composer.SliderColors(...) to override individual color slots
/// without restating the full theme.
+///
+/// Optional Thumb property replaces the stock 20-dp filled
+/// circle with any :
+///
+/// new Slider(value: pos.Value, onValueChange: v => pos.Value = v)
+/// {
+/// Thumb = new Image(Resource.Drawable.dotnet_bot) { Modifier = Modifier.Size(40) },
+/// }
+///
+/// The slot's Kotlin signature is @Composable (SliderState) -> Unit;
+/// the C# property ignores the SliderState arg, so a thumb that
+/// reacts to drag / focus state isn't expressible from this facade — drop
+/// to the binding directly with a hand-written
+/// when needed.
///
public sealed partial class Slider;