diff --git a/docs/maui-coverage.md b/docs/maui-coverage.md
index a1b3a919..24d3799e 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-12 22:25 UTC.
+Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:58 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**: 24 (**55.8%**)
-- **Property-mapper keys covered**: 830 / 1224 (**67.8%**)
+- **Property-mapper keys covered**: 883 / 1224 (**72.1%**)
### Per-category coverage
| Category | Handlers | Keys |
| --- | --- | --- |
-| **Pages / Navigation** | 1/4 (25%) | 32/130 (25%) |
-| **Containers** | 5/5 (100%) | 162/174 (93%) |
-| **Leaves** | 18/18 (100%) | 636/695 (92%) |
+| **Pages / Navigation** | 1/4 (25%) | 33/130 (25%) |
+| **Containers** | 5/5 (100%) | 166/174 (95%) |
+| **Leaves** | 18/18 (100%) | 684/695 (98%) |
| **Menus / Toolbar** | 0/7 (0%) | 0/1 (0%) |
| **Shapes** | 0/1 (0%) | 0/41 (0%) |
| **App / Window** | 0/2 (0%) | 0/8 (0%) |
@@ -37,31 +37,31 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem
| --- | --- | --- | --- | --- | --- |
| ❌ | `FlyoutViewHandler` | Pages / Navigation | `FlyoutPage` | — | 0 / 35 (0%) |
| ❌ | `NavigationViewHandler` | Pages / Navigation | `NavigationPage` | — | 0 / 31 (0%) |
-| 🟡 | `PageHandler` | Pages / Navigation | `Page` | `PageHandler` | 32 / 33 (97%) |
+| ✅ | `PageHandler` | Pages / Navigation | `Page` | `PageHandler` | 33 / 33 (100%) |
| ❌ | `TabbedViewHandler` | Pages / Navigation | `TabbedPage` | — | 0 / 31 (0%) |
-| 🟡 | `BorderHandler` | Containers | `Border` | `BorderHandler` | 34 / 40 (85%) |
+| 🟡 | `BorderHandler` | Containers | `Border` | `BorderHandler` | 35 / 40 (88%) |
| ✅ | `ContentViewHandler` | Containers | `ContentView`, `IContentView` | `ContentViewHandler` | 32 / 32 (100%) |
-| 🟡 | `LayoutHandler` | Containers | `Layout` | `LayoutHandler` | 31 / 32 (97%) |
-| 🟡 | `RefreshViewHandler` | Containers | `RefreshView` | `RefreshViewHandler` | 33 / 35 (94%) |
-| 🟡 | `ScrollViewHandler` | Containers | `ScrollView` | `ScrollViewHandler` | 32 / 35 (91%) |
+| ✅ | `LayoutHandler` | Containers | `Layout` | `LayoutHandler` | 32 / 32 (100%) |
+| 🟡 | `RefreshViewHandler` | Containers | `RefreshView` | `RefreshViewHandler` | 34 / 35 (97%) |
+| 🟡 | `ScrollViewHandler` | Containers | `ScrollView` | `ScrollViewHandler` | 33 / 35 (94%) |
| ✅ | `ActivityIndicatorHandler` | Leaves | `ActivityIndicator` | `ActivityIndicatorHandler` | 33 / 33 (100%) |
-| 🟡 | `ButtonHandler` | Leaves | `Button` | `ButtonHandler` | 33 / 40 (82%) |
+| ✅ | `ButtonHandler` | Leaves | `Button` | `ButtonHandler` | 40 / 40 (100%) |
| ✅ | `CheckBoxHandler` | Leaves | `CheckBox` | `CheckBoxHandler` | 33 / 33 (100%) |
-| 🟡 | `DatePickerHandler` | Leaves | `DatePicker` | `DatePickerHandler` | 37 / 39 (95%) |
-| 🟡 | `EditorHandler` | Leaves | `Editor` | `EditorHandler` | 38 / 46 (83%) |
-| 🟡 | `EntryHandler` | Leaves | `Entry` | `EntryHandler` | 38 / 49 (78%) |
+| ✅ | `DatePickerHandler` | Leaves | `DatePicker` | `DatePickerHandler` | 39 / 39 (100%) |
+| 🟡 | `EditorHandler` | Leaves | `Editor` | `EditorHandler` | 45 / 46 (98%) |
+| 🟡 | `EntryHandler` | Leaves | `Entry` | `EntryHandler` | 48 / 49 (98%) |
| 🟡 | `ImageButtonHandler` | Leaves | `ImageButton` | `ImageButtonHandler` | 37 / 38 (97%) |
| 🟡 | `ImageHandler` | Leaves | `Image` | `ImageHandler` | 33 / 34 (97%) |
-| 🟡 | `IndicatorViewHandler` | Leaves | `IndicatorView` | `IndicatorViewHandler` | 37 / 39 (95%) |
-| 🟡 | `LabelHandler` | Leaves | `Label` | `LabelHandler` | 35 / 40 (88%) |
-| 🟡 | `PickerHandler` | Leaves | `Picker` | `PickerHandler` | 36 / 41 (88%) |
+| ✅ | `IndicatorViewHandler` | Leaves | `IndicatorView` | `IndicatorViewHandler` | 39 / 39 (100%) |
+| 🟡 | `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` | 35 / 39 (90%) |
-| 🟡 | `SearchBarHandler` | Leaves | `SearchBar` | `SearchBarHandler` | 37 / 47 (79%) |
+| 🟡 | `RadioButtonHandler` | Leaves | `RadioButton` | `RadioButtonHandler` | 36 / 39 (92%) |
+| 🟡 | `SearchBarHandler` | Leaves | `SearchBar` | `SearchBarHandler` | 46 / 47 (98%) |
| 🟡 | `SliderHandler` | Leaves | `Slider` | `SliderHandler` | 37 / 38 (97%) |
| ✅ | `StepperHandler` | Leaves | `Stepper` | `StepperHandler` | 35 / 35 (100%) |
| ✅ | `SwitchHandler` | Leaves | `Switch` | `SwitchHandler` | 34 / 34 (100%) |
-| 🟡 | `TimePickerHandler` | Leaves | `TimePicker` | `TimePickerHandler` | 35 / 37 (95%) |
+| ✅ | `TimePickerHandler` | Leaves | `TimePicker` | `TimePickerHandler` | 37 / 37 (100%) |
| ❌ | `MenuBarHandler` | Menus / Toolbar | `MenuBar` | — | n/a |
| ❌ | `MenuBarItemHandler` | Menus / Toolbar | `MenuBarItem` | — | n/a |
| ❌ | `MenuFlyoutHandler` | Menus / Toolbar | `MenuFlyout` | — | n/a |
@@ -112,24 +112,18 @@ 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`).
-- **`EntryHandler`** (78%) — missing: `CharacterSpacing`, `ClearButtonVisibility`, `CursorPosition`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `MaxLength`, `PlaceholderColor`, `ReturnType`, `SelectionLength`, `VerticalTextAlignment`
-- **`SearchBarHandler`** (79%) — missing: `CancelButtonColor`, `CharacterSpacing`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `MaxLength`, `PlaceholderColor`, `ReturnType`, `SearchIconColor`, `VerticalTextAlignment`
-- **`ButtonHandler`** (82%) — missing: `CharacterSpacing`, `CornerRadius`, `Font`, `Padding`, `Source`, `StrokeColor`, `StrokeThickness`
-- **`EditorHandler`** (83%) — missing: `CharacterSpacing`, `CursorPosition`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `PlaceholderColor`, `SelectionLength`, `VerticalTextAlignment`
-- **`BorderHandler`** (85%) — missing: `Shape`, `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit`
-- **`LabelHandler`** (88%) — missing: `CharacterSpacing`, `LineHeight`, `Padding`, `TextDecorations`, `VerticalTextAlignment`
-- **`PickerHandler`** (88%) — missing: `CharacterSpacing`, `HorizontalTextAlignment`, `IsOpen`, `Items`, `VerticalTextAlignment`
-- **`RadioButtonHandler`** (90%) — missing: `CharacterSpacing`, `CornerRadius`, `StrokeColor`, `StrokeThickness`
-- **`ScrollViewHandler`** (91%) — missing: `Content`, `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility`
-- **`RefreshViewHandler`** (94%) — missing: `IsRefreshEnabled`, `RefreshColor`
-- **`TimePickerHandler`** (95%) — missing: `CharacterSpacing`, `IsOpen`
-- **`DatePickerHandler`** (95%) — missing: `CharacterSpacing`, `IsOpen`
-- **`IndicatorViewHandler`** (95%) — missing: `HideSingle`, `MaximumVisible`
-- **`LayoutHandler`** (97%) — missing: `ClipsToBounds`
-- **`PageHandler`** (97%) — missing: `Title`
+- **`BorderHandler`** (88%) — missing: `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit`
+- **`RadioButtonHandler`** (92%) — missing: `CornerRadius`, `StrokeColor`, `StrokeThickness`
+- **`ScrollViewHandler`** (94%) — missing: `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility`
- **`ImageHandler`** (97%) — missing: `IsAnimationPlaying`
+- **`RefreshViewHandler`** (97%) — missing: `RefreshColor`
- **`ImageButtonHandler`** (97%) — missing: `IsAnimationPlaying`
- **`SliderHandler`** (97%) — missing: `ThumbImageSource`
+- **`LabelHandler`** (98%) — missing: `VerticalTextAlignment`
+- **`PickerHandler`** (98%) — missing: `VerticalTextAlignment`
+- **`EditorHandler`** (98%) — missing: `VerticalTextAlignment`
+- **`SearchBarHandler`** (98%) — missing: `VerticalTextAlignment`
+- **`EntryHandler`** (98%) — missing: `VerticalTextAlignment`
## Per-handler property detail
@@ -223,13 +217,9 @@ _No Compose backend handler. Stock MAUI handler keeps the AppCompat backend._
-### 🟡 `PageHandler` — `Page`
+### ✅ `PageHandler` — `Page`
-Backed by `PageHandler`. **32 / 33 keys (97%)**.
-
-Missing keys:
-
-- [ ] `Title`
+Backed by `PageHandler`. **33 / 33 keys (100%)**.
All stock keys
@@ -259,7 +249,7 @@ Missing keys:
- [x] `ScaleY`
- [x] `Semantics`
- [x] `Shadow`
-- [ ] `Title`
+- [x] `Title`
- [x] `ToolTip`
- [x] `Toolbar`
- [x] `TranslationX`
@@ -311,11 +301,10 @@ _No Compose backend handler. Stock MAUI handler keeps the AppCompat backend._
### 🟡 `BorderHandler` — `Border`
-Backed by `BorderHandler`. **34 / 40 keys (85%)**.
+Backed by `BorderHandler`. **35 / 40 keys (88%)**.
Missing keys:
-- [ ] `Shape`
- [ ] `StrokeDashOffset`
- [ ] `StrokeDashPattern`
- [ ] `StrokeLineCap`
@@ -355,7 +344,7 @@ Extra keys we map (no stock counterpart):
- [x] `ScaleY`
- [x] `Semantics`
- [x] `Shadow`
-- [ ] `Shape`
+- [x] `Shape`
- [x] `Stroke`
- [ ] `StrokeDashOffset`
- [ ] `StrokeDashPattern`
@@ -418,13 +407,9 @@ Extra keys we map (no stock counterpart):
-### 🟡 `LayoutHandler` — `Layout`
+### ✅ `LayoutHandler` — `Layout`
-Backed by `LayoutHandler`. **31 / 32 keys (97%)**.
-
-Missing keys:
-
-- [ ] `ClipsToBounds`
+Backed by `LayoutHandler`. **32 / 32 keys (100%)**.
Extra keys we map (no stock counterpart):
@@ -440,7 +425,7 @@ Extra keys we map (no stock counterpart):
- [x] `Background`
- [x] `Border`
- [x] `Clip`
-- [ ] `ClipsToBounds`
+- [x] `ClipsToBounds`
- [x] `ContainerView`
- [x] `FlowDirection`
- [x] `Height`
@@ -471,11 +456,10 @@ Extra keys we map (no stock counterpart):
### 🟡 `RefreshViewHandler` — `RefreshView`
-Backed by `RefreshViewHandler`. **33 / 35 keys (94%)**.
+Backed by `RefreshViewHandler`. **34 / 35 keys (97%)**.
Missing keys:
-- [ ] `IsRefreshEnabled`
- [ ] `RefreshColor`
All stock keys
@@ -492,7 +476,7 @@ Missing keys:
- [x] `Height`
- [x] `InputTransparent`
- [x] `IsEnabled`
-- [ ] `IsRefreshEnabled`
+- [x] `IsRefreshEnabled`
- [x] `IsRefreshing`
- [x] `MaximumHeight`
- [x] `MaximumWidth`
@@ -520,11 +504,10 @@ Missing keys:
### 🟡 `ScrollViewHandler` — `ScrollView`
-Backed by `ScrollViewHandler`. **32 / 35 keys (91%)**.
+Backed by `ScrollViewHandler`. **33 / 35 keys (94%)**.
Missing keys:
-- [ ] `Content`
- [ ] `HorizontalScrollBarVisibility`
- [ ] `VerticalScrollBarVisibility`
@@ -537,7 +520,7 @@ Missing keys:
- [x] `Border`
- [x] `Clip`
- [x] `ContainerView`
-- [ ] `Content`
+- [x] `Content`
- [x] `FlowDirection`
- [x] `Height`
- [ ] `HorizontalScrollBarVisibility`
@@ -610,19 +593,9 @@ Backed by `ActivityIndicatorHandler`. **33 / 33 keys (100%)**.
-### 🟡 `ButtonHandler` — `Button`
-
-Backed by `ButtonHandler`. **33 / 40 keys (82%)**.
-
-Missing keys:
+### ✅ `ButtonHandler` — `Button`
-- [ ] `CharacterSpacing`
-- [ ] `CornerRadius`
-- [ ] `Font`
-- [ ] `Padding`
-- [ ] `Source`
-- [ ] `StrokeColor`
-- [ ] `StrokeThickness`
+Backed by `ButtonHandler`. **40 / 40 keys (100%)**.
Extra keys we map (no stock counterpart):
@@ -635,12 +608,12 @@ Extra keys we map (no stock counterpart):
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CharacterSpacing`
+- [x] `CharacterSpacing`
- [x] `Clip`
- [x] `ContainerView`
-- [ ] `CornerRadius`
+- [x] `CornerRadius`
- [x] `FlowDirection`
-- [ ] `Font`
+- [x] `Font`
- [x] `Height`
- [x] `InputTransparent`
- [x] `IsEnabled`
@@ -649,7 +622,7 @@ Extra keys we map (no stock counterpart):
- [x] `MinimumHeight`
- [x] `MinimumWidth`
- [x] `Opacity`
-- [ ] `Padding`
+- [x] `Padding`
- [x] `Rotation`
- [x] `RotationX`
- [x] `RotationY`
@@ -659,9 +632,9 @@ Extra keys we map (no stock counterpart):
- [x] `ScaleY`
- [x] `Semantics`
- [x] `Shadow`
-- [ ] `Source`
-- [ ] `StrokeColor`
-- [ ] `StrokeThickness`
+- [x] `Source`
+- [x] `StrokeColor`
+- [x] `StrokeThickness`
- [x] `Text`
- [x] `TextColor`
- [x] `ToolTip`
@@ -715,14 +688,9 @@ Backed by `CheckBoxHandler`. **33 / 33 keys (100%)**.
-### 🟡 `DatePickerHandler` — `DatePicker`
-
-Backed by `DatePickerHandler`. **37 / 39 keys (95%)**.
+### ✅ `DatePickerHandler` — `DatePicker`
-Missing keys:
-
-- [ ] `CharacterSpacing`
-- [ ] `IsOpen`
+Backed by `DatePickerHandler`. **39 / 39 keys (100%)**.
Extra keys we map (no stock counterpart):
@@ -735,7 +703,7 @@ Extra keys we map (no stock counterpart):
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CharacterSpacing`
+- [x] `CharacterSpacing`
- [x] `Clip`
- [x] `ContainerView`
- [x] `Date`
@@ -745,7 +713,7 @@ Extra keys we map (no stock counterpart):
- [x] `Height`
- [x] `InputTransparent`
- [x] `IsEnabled`
-- [ ] `IsOpen`
+- [x] `IsOpen`
- [x] `MaximumDate`
- [x] `MaximumHeight`
- [x] `MaximumWidth`
@@ -774,17 +742,10 @@ Extra keys we map (no stock counterpart):
### 🟡 `EditorHandler` — `Editor`
-Backed by `EditorHandler`. **38 / 46 keys (83%)**.
+Backed by `EditorHandler`. **45 / 46 keys (98%)**.
Missing keys:
-- [ ] `CharacterSpacing`
-- [ ] `CursorPosition`
-- [ ] `HorizontalTextAlignment`
-- [ ] `IsSpellCheckEnabled`
-- [ ] `IsTextPredictionEnabled`
-- [ ] `PlaceholderColor`
-- [ ] `SelectionLength`
- [ ] `VerticalTextAlignment`
Extra keys we map (no stock counterpart):
@@ -798,19 +759,19 @@ Extra keys we map (no stock counterpart):
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CharacterSpacing`
+- [x] `CharacterSpacing`
- [x] `Clip`
- [x] `ContainerView`
-- [ ] `CursorPosition`
+- [x] `CursorPosition`
- [x] `FlowDirection`
- [x] `Font`
- [x] `Height`
-- [ ] `HorizontalTextAlignment`
+- [x] `HorizontalTextAlignment`
- [x] `InputTransparent`
- [x] `IsEnabled`
- [x] `IsReadOnly`
-- [ ] `IsSpellCheckEnabled`
-- [ ] `IsTextPredictionEnabled`
+- [x] `IsSpellCheckEnabled`
+- [x] `IsTextPredictionEnabled`
- [x] `Keyboard`
- [x] `MaxLength`
- [x] `MaximumHeight`
@@ -819,7 +780,7 @@ Extra keys we map (no stock counterpart):
- [x] `MinimumWidth`
- [x] `Opacity`
- [x] `Placeholder`
-- [ ] `PlaceholderColor`
+- [x] `PlaceholderColor`
- [x] `Rotation`
- [x] `RotationX`
- [x] `RotationY`
@@ -827,7 +788,7 @@ Extra keys we map (no stock counterpart):
- [x] `Scale`
- [x] `ScaleX`
- [x] `ScaleY`
-- [ ] `SelectionLength`
+- [x] `SelectionLength`
- [x] `Semantics`
- [x] `Shadow`
- [x] `Text`
@@ -844,20 +805,10 @@ Extra keys we map (no stock counterpart):
### 🟡 `EntryHandler` — `Entry`
-Backed by `EntryHandler`. **38 / 49 keys (78%)**.
+Backed by `EntryHandler`. **48 / 49 keys (98%)**.
Missing keys:
-- [ ] `CharacterSpacing`
-- [ ] `ClearButtonVisibility`
-- [ ] `CursorPosition`
-- [ ] `HorizontalTextAlignment`
-- [ ] `IsSpellCheckEnabled`
-- [ ] `IsTextPredictionEnabled`
-- [ ] `MaxLength`
-- [ ] `PlaceholderColor`
-- [ ] `ReturnType`
-- [ ] `SelectionLength`
- [ ] `VerticalTextAlignment`
Extra keys we map (no stock counterpart):
@@ -871,31 +822,31 @@ Extra keys we map (no stock counterpart):
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CharacterSpacing`
-- [ ] `ClearButtonVisibility`
+- [x] `CharacterSpacing`
+- [x] `ClearButtonVisibility`
- [x] `Clip`
- [x] `ContainerView`
-- [ ] `CursorPosition`
+- [x] `CursorPosition`
- [x] `FlowDirection`
- [x] `Font`
- [x] `Height`
-- [ ] `HorizontalTextAlignment`
+- [x] `HorizontalTextAlignment`
- [x] `InputTransparent`
- [x] `IsEnabled`
- [x] `IsPassword`
- [x] `IsReadOnly`
-- [ ] `IsSpellCheckEnabled`
-- [ ] `IsTextPredictionEnabled`
+- [x] `IsSpellCheckEnabled`
+- [x] `IsTextPredictionEnabled`
- [x] `Keyboard`
-- [ ] `MaxLength`
+- [x] `MaxLength`
- [x] `MaximumHeight`
- [x] `MaximumWidth`
- [x] `MinimumHeight`
- [x] `MinimumWidth`
- [x] `Opacity`
- [x] `Placeholder`
-- [ ] `PlaceholderColor`
-- [ ] `ReturnType`
+- [x] `PlaceholderColor`
+- [x] `ReturnType`
- [x] `Rotation`
- [x] `RotationX`
- [x] `RotationY`
@@ -903,7 +854,7 @@ Extra keys we map (no stock counterpart):
- [x] `Scale`
- [x] `ScaleX`
- [x] `ScaleY`
-- [ ] `SelectionLength`
+- [x] `SelectionLength`
- [x] `Semantics`
- [x] `Shadow`
- [x] `Text`
@@ -1016,14 +967,9 @@ Missing keys:
-### 🟡 `IndicatorViewHandler` — `IndicatorView`
+### ✅ `IndicatorViewHandler` — `IndicatorView`
-Backed by `IndicatorViewHandler`. **37 / 39 keys (95%)**.
-
-Missing keys:
-
-- [ ] `HideSingle`
-- [ ] `MaximumVisible`
+Backed by `IndicatorViewHandler`. **39 / 39 keys (100%)**.
All stock keys
@@ -1037,14 +983,14 @@ Missing keys:
- [x] `Count`
- [x] `FlowDirection`
- [x] `Height`
-- [ ] `HideSingle`
+- [x] `HideSingle`
- [x] `IndicatorColor`
- [x] `IndicatorSize`
- [x] `IndicatorsShape`
- [x] `InputTransparent`
- [x] `IsEnabled`
- [x] `MaximumHeight`
-- [ ] `MaximumVisible`
+- [x] `MaximumVisible`
- [x] `MaximumWidth`
- [x] `MinimumHeight`
- [x] `MinimumWidth`
@@ -1071,14 +1017,10 @@ Missing keys:
### 🟡 `LabelHandler` — `Label`
-Backed by `LabelHandler`. **35 / 40 keys (88%)**.
+Backed by `LabelHandler`. **39 / 40 keys (98%)**.
Missing keys:
-- [ ] `CharacterSpacing`
-- [ ] `LineHeight`
-- [ ] `Padding`
-- [ ] `TextDecorations`
- [ ] `VerticalTextAlignment`
Extra keys we map (no stock counterpart):
@@ -1092,7 +1034,7 @@ Extra keys we map (no stock counterpart):
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CharacterSpacing`
+- [x] `CharacterSpacing`
- [x] `Clip`
- [x] `ContainerView`
- [x] `FlowDirection`
@@ -1101,13 +1043,13 @@ Extra keys we map (no stock counterpart):
- [x] `HorizontalTextAlignment`
- [x] `InputTransparent`
- [x] `IsEnabled`
-- [ ] `LineHeight`
+- [x] `LineHeight`
- [x] `MaximumHeight`
- [x] `MaximumWidth`
- [x] `MinimumHeight`
- [x] `MinimumWidth`
- [x] `Opacity`
-- [ ] `Padding`
+- [x] `Padding`
- [x] `Rotation`
- [x] `RotationX`
- [x] `RotationY`
@@ -1119,7 +1061,7 @@ Extra keys we map (no stock counterpart):
- [x] `Shadow`
- [x] `Text`
- [x] `TextColor`
-- [ ] `TextDecorations`
+- [x] `TextDecorations`
- [x] `ToolTip`
- [x] `Toolbar`
- [x] `TranslationX`
@@ -1132,14 +1074,10 @@ Extra keys we map (no stock counterpart):
### 🟡 `PickerHandler` — `Picker`
-Backed by `PickerHandler`. **36 / 41 keys (88%)**.
+Backed by `PickerHandler`. **40 / 41 keys (98%)**.
Missing keys:
-- [ ] `CharacterSpacing`
-- [ ] `HorizontalTextAlignment`
-- [ ] `IsOpen`
-- [ ] `Items`
- [ ] `VerticalTextAlignment`
Extra keys we map (no stock counterpart):
@@ -1154,17 +1092,17 @@ Extra keys we map (no stock counterpart):
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CharacterSpacing`
+- [x] `CharacterSpacing`
- [x] `Clip`
- [x] `ContainerView`
- [x] `FlowDirection`
- [x] `Font`
- [x] `Height`
-- [ ] `HorizontalTextAlignment`
+- [x] `HorizontalTextAlignment`
- [x] `InputTransparent`
- [x] `IsEnabled`
-- [ ] `IsOpen`
-- [ ] `Items`
+- [x] `IsOpen`
+- [x] `Items`
- [x] `MaximumHeight`
- [x] `MaximumWidth`
- [x] `MinimumHeight`
@@ -1237,11 +1175,10 @@ Backed by `ProgressBarHandler`. **33 / 33 keys (100%)**.
### 🟡 `RadioButtonHandler` — `RadioButton`
-Backed by `RadioButtonHandler`. **35 / 39 keys (90%)**.
+Backed by `RadioButtonHandler`. **36 / 39 keys (92%)**.
Missing keys:
-- [ ] `CharacterSpacing`
- [ ] `CornerRadius`
- [ ] `StrokeColor`
- [ ] `StrokeThickness`
@@ -1253,7 +1190,7 @@ Missing keys:
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CharacterSpacing`
+- [x] `CharacterSpacing`
- [x] `Clip`
- [x] `ContainerView`
- [x] `Content`
@@ -1292,24 +1229,17 @@ Missing keys:
### 🟡 `SearchBarHandler` — `SearchBar`
-Backed by `SearchBarHandler`. **37 / 47 keys (79%)**.
+Backed by `SearchBarHandler`. **46 / 47 keys (98%)**.
Missing keys:
-- [ ] `CancelButtonColor`
-- [ ] `CharacterSpacing`
-- [ ] `HorizontalTextAlignment`
-- [ ] `IsSpellCheckEnabled`
-- [ ] `IsTextPredictionEnabled`
-- [ ] `MaxLength`
-- [ ] `PlaceholderColor`
-- [ ] `ReturnType`
-- [ ] `SearchIconColor`
- [ ] `VerticalTextAlignment`
Extra keys we map (no stock counterpart):
+- `CursorPosition`
- `HorizontalLayoutAlignment`
+- `SelectionLength`
All stock keys
@@ -1318,29 +1248,29 @@ Extra keys we map (no stock counterpart):
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CancelButtonColor`
-- [ ] `CharacterSpacing`
+- [x] `CancelButtonColor`
+- [x] `CharacterSpacing`
- [x] `Clip`
- [x] `ContainerView`
- [x] `FlowDirection`
- [x] `Font`
- [x] `Height`
-- [ ] `HorizontalTextAlignment`
+- [x] `HorizontalTextAlignment`
- [x] `InputTransparent`
- [x] `IsEnabled`
- [x] `IsReadOnly`
-- [ ] `IsSpellCheckEnabled`
-- [ ] `IsTextPredictionEnabled`
+- [x] `IsSpellCheckEnabled`
+- [x] `IsTextPredictionEnabled`
- [x] `Keyboard`
-- [ ] `MaxLength`
+- [x] `MaxLength`
- [x] `MaximumHeight`
- [x] `MaximumWidth`
- [x] `MinimumHeight`
- [x] `MinimumWidth`
- [x] `Opacity`
- [x] `Placeholder`
-- [ ] `PlaceholderColor`
-- [ ] `ReturnType`
+- [x] `PlaceholderColor`
+- [x] `ReturnType`
- [x] `Rotation`
- [x] `RotationX`
- [x] `RotationY`
@@ -1348,7 +1278,7 @@ Extra keys we map (no stock counterpart):
- [x] `Scale`
- [x] `ScaleX`
- [x] `ScaleY`
-- [ ] `SearchIconColor`
+- [x] `SearchIconColor`
- [x] `Semantics`
- [x] `Shadow`
- [x] `Text`
@@ -1501,14 +1431,9 @@ Backed by `SwitchHandler`. **34 / 34 keys (100%)**.
-### 🟡 `TimePickerHandler` — `TimePicker`
-
-Backed by `TimePickerHandler`. **35 / 37 keys (95%)**.
+### ✅ `TimePickerHandler` — `TimePicker`
-Missing keys:
-
-- [ ] `CharacterSpacing`
-- [ ] `IsOpen`
+Backed by `TimePickerHandler`. **37 / 37 keys (100%)**.
Extra keys we map (no stock counterpart):
@@ -1521,7 +1446,7 @@ Extra keys we map (no stock counterpart):
- [x] `AutomationId`
- [x] `Background`
- [x] `Border`
-- [ ] `CharacterSpacing`
+- [x] `CharacterSpacing`
- [x] `Clip`
- [x] `ContainerView`
- [x] `FlowDirection`
@@ -1530,7 +1455,7 @@ Extra keys we map (no stock counterpart):
- [x] `Height`
- [x] `InputTransparent`
- [x] `IsEnabled`
-- [ ] `IsOpen`
+- [x] `IsOpen`
- [x] `MaximumHeight`
- [x] `MaximumWidth`
- [x] `MinimumHeight`
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ButtonsPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ButtonsPage.xaml
index 8c0811ae..eca77173 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ButtonsPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ButtonsPage.xaml
@@ -64,6 +64,75 @@
x:Name="ClickCountLabel"
Text="No clicks yet" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EditorPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EditorPage.xaml
index f08b878f..f11c4fd8 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EditorPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EditorPage.xaml
@@ -47,6 +47,49 @@
MaxLength="30"
HeightRequest="100" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EditorPage.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EditorPage.xaml.cs
index 9249158c..06c43e9d 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EditorPage.xaml.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EditorPage.xaml.cs
@@ -22,4 +22,33 @@ void OnNoteTextChanged(object? sender, TextChangedEventArgs e)
StringSplitOptions.RemoveEmptyEntries).Length;
WordCountLabel.Text = $"Words: {words} Characters: {text.Length}";
}
+
+ void OnEditorCaretStart(object? sender, EventArgs e)
+ {
+ CursorEditor.CursorPosition = 0;
+ CursorEditor.SelectionLength = 0;
+ UpdateEditorCursorEcho();
+ }
+
+ void OnEditorCaretEnd(object? sender, EventArgs e)
+ {
+ var len = CursorEditor.Text?.Length ?? 0;
+ CursorEditor.CursorPosition = len;
+ CursorEditor.SelectionLength = 0;
+ UpdateEditorCursorEcho();
+ }
+
+ void OnEditorSelectQuick(object? sender, EventArgs e)
+ {
+ var text = CursorEditor.Text ?? string.Empty;
+ var idx = text.IndexOf("quick", StringComparison.Ordinal);
+ if (idx < 0) return;
+ CursorEditor.CursorPosition = idx;
+ CursorEditor.SelectionLength = "quick".Length;
+ UpdateEditorCursorEcho();
+ }
+
+ void UpdateEditorCursorEcho() =>
+ EditorCursorEcho.Text =
+ $"Caret: {CursorEditor.CursorPosition} Selection length: {CursorEditor.SelectionLength}";
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EntriesPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EntriesPage.xaml
index 972c7e80..8dbbb262 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EntriesPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EntriesPage.xaml
@@ -68,6 +68,77 @@
IsReadOnly="True"
HorizontalOptions="Fill" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EntriesPage.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EntriesPage.xaml.cs
index 1387ae9a..f1e2ca77 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EntriesPage.xaml.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/EntriesPage.xaml.cs
@@ -21,4 +21,44 @@ void OnNameTextChanged(object? sender, TextChangedEventArgs e)
? "Greeting will appear here."
: $"Hello, {e.NewTextValue}!";
}
+
+ void OnMaxLengthChanged(object? sender, TextChangedEventArgs e)
+ {
+ var text = e.NewTextValue ?? string.Empty;
+ MaxLengthLabel.Text = text.Length >= 8
+ ? $"At cap ({text.Length}/8) — extra typing rejected."
+ : $"Length: {text.Length}/8";
+ }
+
+ void OnCaretToStart(object? sender, EventArgs e)
+ {
+ CursorEntry.CursorPosition = 0;
+ CursorEntry.SelectionLength = 0;
+ UpdateCursorEcho();
+ }
+
+ void OnCaretToEnd(object? sender, EventArgs e)
+ {
+ var len = CursorEntry.Text?.Length ?? 0;
+ CursorEntry.CursorPosition = len;
+ CursorEntry.SelectionLength = 0;
+ UpdateCursorEcho();
+ }
+
+ void OnSelectQuick(object? sender, EventArgs e)
+ {
+ var text = CursorEntry.Text ?? string.Empty;
+ var idx = text.IndexOf("quick", StringComparison.Ordinal);
+ if (idx < 0) return;
+ CursorEntry.CursorPosition = idx;
+ CursorEntry.SelectionLength = "quick".Length;
+ UpdateCursorEcho();
+ }
+
+ void UpdateCursorEcho()
+ {
+ var len = CursorEntry.Text?.Length ?? 0;
+ CursorEcho.Text =
+ $"Caret: {CursorEntry.CursorPosition} / {len} Selection length: {CursorEntry.SelectionLength}";
+ }
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/IndicatorPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/IndicatorPage.xaml
index bb16fb0a..0707275e 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/IndicatorPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/IndicatorPage.xaml
@@ -50,6 +50,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/LabelsPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/LabelsPage.xaml
index b98add0f..70b29d4c 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/LabelsPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/LabelsPage.xaml
@@ -54,6 +54,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/PickersPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/PickersPage.xaml
index 379e6158..a9e0aa47 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/PickersPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/PickersPage.xaml
@@ -84,6 +84,38 @@
Clicked="OnResetClicked"
HorizontalOptions="Fill" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml
index 8728d96e..1d033fca 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml
@@ -43,6 +43,19 @@
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml.cs
index 1d0b9f02..ea8ee76a 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/RefreshPage.xaml.cs
@@ -39,4 +39,10 @@ async void OnRefreshing(object? sender, EventArgs e)
// consumer flips IsRefreshing back to false).
Refresh.IsRefreshing = false;
}
+
+ void OnRefreshEnabledToggled(object? sender, ToggledEventArgs e)
+ {
+ Refresh.IsRefreshEnabled = e.Value;
+ EnabledLabel.Text = $"IsRefreshEnabled = {e.Value}";
+ }
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SearchPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SearchPage.xaml
index bc5b4133..8fb5cd3e 100644
--- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SearchPage.xaml
+++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/SearchPage.xaml
@@ -37,6 +37,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs
index 4e2bc966..0c2dccd6 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs
@@ -45,9 +45,20 @@ public partial class BorderHandler : ComposeElementHandler
[nameof(MauiBorder.Stroke)] = MapStroke,
[nameof(MauiBorder.StrokeThickness)] = MapStrokeThickness,
[nameof(MauiBorder.StrokeShape)] = MapShape,
+ // Stock MAUI exposes the abstract IBorderStroke.Shape key as
+ // a separate string. Aliased here so MAUI's PropertyMapper
+ // looks up "Shape" and routes to the same handler.
+ ["Shape"] = MapShape,
["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.
};
/// Command mapper (inherits view-level commands; no extras).
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ButtonHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ButtonHandler.cs
index 0ecfe158..3303d11d 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ButtonHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ButtonHandler.cs
@@ -1,9 +1,15 @@
using AndroidX.Compose;
using AndroidX.Compose.Material3;
using AndroidX.Compose.Runtime;
+using AndroidX.Compose.UI.Platform;
+using Microsoft.AndroidX.Compose.Maui.Loaders;
using Microsoft.AndroidX.Compose.Maui.Platform;
using Microsoft.Maui.Handlers;
-using ComposeButton = AndroidX.Compose.Button;
+using ComposeButton = AndroidX.Compose.Button;
+using ComposeColor = AndroidX.Compose.Color;
+using ComposeImage = AndroidX.Compose.Image;
+using ComposeFontWeight = AndroidX.Compose.FontWeight;
+using ComposeText = AndroidX.Compose.Text;
namespace Microsoft.AndroidX.Compose.Maui.Handlers;
@@ -47,6 +53,13 @@ public partial class ButtonHandler : ComposeElementHandler
{
[nameof(IText.Text)] = MapText,
[nameof(ITextStyle.TextColor)] = MapTextColor,
+ [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
+ [nameof(ITextStyle.Font)] = MapFont,
+ [nameof(IButtonStroke.CornerRadius)] = MapCornerRadius,
+ [nameof(IButtonStroke.StrokeColor)] = MapStrokeColor,
+ [nameof(IButtonStroke.StrokeThickness)] = MapStrokeThickness,
+ [nameof(IImageSourcePart.Source)] = MapImageSource,
+ [nameof(IPadding.Padding)] = MapPadding,
[nameof(IView.Background)] = MapBackground,
[nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
};
@@ -59,6 +72,16 @@ public partial class ButtonHandler : ComposeElementHandler
readonly MutableState _containerColor = new((long?)null);
readonly MutableState _contentColor = new((long?)null);
readonly MutableState _fillWidth = new(false);
+ readonly MutableState _letterSpacing = new((float?)null);
+ readonly MutableState _fontSize = new((int?)null);
+ readonly MutableState _bold = new(false);
+ // CornerRadius in MAUI IButtonStroke is an int (DIPs).
+ readonly MutableState _cornerRadius = new(-1);
+ readonly MutableState _strokeColor = new((long?)null);
+ readonly MutableState _strokeThickness = new(0f);
+ // Padding lives as a struct (Thickness); bump on change and live-read.
+ readonly MutableState _paddingVersion = new(0);
+ ImageSourceLoader? _loader;
/// Construct a handler with the default mappers.
public ButtonHandler() : base(Mapper, CommandMapper) { }
@@ -67,6 +90,12 @@ public ButtonHandler() : base(Mapper, CommandMapper) { }
public ButtonHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null)
: base(mapper ?? Mapper, commandMapper ?? CommandMapper) { }
+ // Lazy — buttons without an ImageSource never allocate the loader.
+ ImageSourceLoader Loader =>
+ _loader ??= new ImageSourceLoader(
+ this,
+ () => (VirtualView as Microsoft.Maui.IImage) as IImageSourcePart);
+
///
public override ComposableNode BuildNode(IComposer composer)
{
@@ -75,20 +104,58 @@ public override ComposableNode BuildNode(IComposer composer)
SubscribeToViewProperties();
- var container = _containerColor.Value;
- var content = _contentColor.Value;
- var button = new ComposeButton(onClick: OnClicked)
+ // Subscribe so padding map bumps re-run BuildNode.
+ _ = _paddingVersion.Value;
+
+ var container = _containerColor.Value;
+ var content = _contentColor.Value;
+ var letterSpacing = _letterSpacing.Value;
+ var fontSize = _fontSize.Value;
+ var bold = _bold.Value;
+ var cornerRadius = _cornerRadius.Value;
+ var strokeColor = _strokeColor.Value;
+ var strokeThickness = _strokeThickness.Value;
+ var padding = (virtualView as IPadding)?.Padding ?? Thickness.Zero;
+ var hasCustomText = letterSpacing.HasValue || fontSize.HasValue || bold;
+
+ var button = new ComposeButton(onClick: OnClicked);
+ // Optional leading image — only added when ImageSource resolved.
+ if (_loader is { } loader)
{
- new Text(_text.Value),
+ if (loader.Painter.Value is { } painter)
+ button.Add(new ComposeImage(painter));
+ else if (loader.DrawableResourceId.Value is int id)
+ button.Add(new ComposeImage(id));
+ }
+ var textNode = new ComposeText(_text.Value)
+ {
+ LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null,
+ FontSize = fontSize.HasValue ? new Sp(fontSize.Value) : null,
+ FontWeight = bold ? ComposeFontWeight.Bold : null,
};
+ button.Add(textNode);
+
if (container is not null || content is not null)
button.Colors = composer.ButtonColors(
containerColor: container,
contentColor: content);
- // Single chained PrependModifier — combines the layout-fill
- // (when set) with the cross-cutting view properties (Opacity,
- // Translation, Scale, Rotation, IsVisible, Clip, Shadow).
- var outer = (_fillWidth.Value ? Modifier.FillMaxWidth() : Modifier.Companion)
+ if (cornerRadius >= 0)
+ button.Shape = new RoundedCornerShape(new Dp(cornerRadius));
+ if (padding != Thickness.Zero)
+ button.ContentPadding = new PaddingValues(
+ start: new Dp((float)padding.Left),
+ top: new Dp((float)padding.Top),
+ end: new Dp((float)padding.Right),
+ bottom: new Dp((float)padding.Bottom));
+ // Optional stroke chain — Compose Button has no built-in border slot.
+ // Wrap the outer Modifier with Modifier.Border when a stroke is set.
+ var outer = (_fillWidth.Value ? Modifier.FillMaxWidth() : Modifier.Companion);
+ if (strokeColor.HasValue && strokeThickness > 0f)
+ outer = outer.Border(
+ new Dp(strokeThickness),
+ new ComposeColor(strokeColor.Value),
+ button.Shape);
+ outer = outer
.ApplyViewProperties(virtualView)
.ApplyGestures(virtualView, MauiContext)
.ApplySemantics(virtualView);
@@ -96,6 +163,13 @@ public override ComposableNode BuildNode(IComposer composer)
return button;
}
+ ///
+ protected override void DisconnectHandler(ComposeView platformView)
+ {
+ _loader?.Reset();
+ base.DisconnectHandler(platformView);
+ }
+
void OnClicked()
{
// Stock MAUI ButtonHandler raises Pressed → Clicked → Released in
@@ -173,4 +247,76 @@ public static void MapHorizontalLayoutAlignment(ButtonHandler handler, IButton b
}
handler._fillWidth.Value = fill;
}
+
+ ///
+ /// Map to the Compose
+ /// Text.LetterSpacing slot on the button's inner label.
+ ///
+ public static void MapCharacterSpacing(ButtonHandler handler, IButton button)
+ {
+ if (button is ITextStyle ts)
+ handler._letterSpacing.Value = ts.CharacterSpacing != 0
+ ? (float)ts.CharacterSpacing
+ : null;
+ }
+
+ ///
+ /// Map (size + bold) to the Compose
+ /// Text.FontSize and Text.FontWeight slots.
+ /// Custom font families and italic land in a later phase.
+ ///
+ public static void MapFont(ButtonHandler handler, IButton button)
+ {
+ if (button is not ITextStyle ts) return;
+ handler._fontSize.Value = ts.Font.Size > 0 ? (int)ts.Font.Size : null;
+ handler._bold.Value = (ts.Font.Weight & Microsoft.Maui.FontWeight.Bold)
+ == Microsoft.Maui.FontWeight.Bold;
+ }
+
+ ///
+ /// Map to the Compose
+ /// Button.Shape slot via .
+ ///
+ public static void MapCornerRadius(ButtonHandler handler, IButton button)
+ {
+ if (button is IButtonStroke stroke)
+ handler._cornerRadius.Value = stroke.CornerRadius;
+ }
+
+ ///
+ /// Map to the outer
+ /// Modifier.Border chain. Compose's Material 3 Button
+ /// has no built-in border slot, so we draw the stroke around the
+ /// button frame instead.
+ ///
+ public static void MapStrokeColor(ButtonHandler handler, IButton button)
+ {
+ if (button is IButtonStroke stroke)
+ handler._strokeColor.Value = ColorMapping.ToPackedLong(stroke.StrokeColor);
+ }
+
+ ///
+ /// Map to the outer
+ /// Modifier.Border chain. See .
+ ///
+ public static void MapStrokeThickness(ButtonHandler handler, IButton button)
+ {
+ if (button is IButtonStroke stroke)
+ handler._strokeThickness.Value = (float)stroke.StrokeThickness;
+ }
+
+ ///
+ /// Map to the Compose
+ /// Button.ContentPadding slot. Live-read in .
+ ///
+ public static void MapPadding(ButtonHandler handler, IButton button) =>
+ handler._paddingVersion.Value = handler._paddingVersion.Value + 1;
+
+ ///
+ /// Map through the shared
+ /// ; the resolved drawable or painter
+ /// is rendered inline as a leading icon inside the button row.
+ ///
+ public static async void MapImageSource(ButtonHandler handler, IButton button) =>
+ await handler.Loader.LoadAsync((button as Microsoft.Maui.IImage)?.Source).ConfigureAwait(false);
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs
index 89b7b06f..baff02ac 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs
@@ -72,8 +72,10 @@ public partial class DatePickerHandler : ComposeElementHandler
[nameof(IDatePicker.MinimumDate)] = MapMinimumDate,
[nameof(IDatePicker.MaximumDate)] = MapMaximumDate,
[nameof(IDatePicker.Format)] = MapFormat,
+ [nameof(IDatePicker.IsOpen)] = MapIsOpen,
[nameof(ITextStyle.TextColor)] = MapTextColor,
[nameof(ITextStyle.Font)] = MapFont,
+ [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
[nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
};
@@ -88,6 +90,7 @@ public partial class DatePickerHandler : ComposeElementHandler
readonly MutableState _textColor = new((long?)null);
readonly MutableState _fontSize = new((int?)null);
readonly MutableState _bold = new(false);
+ readonly MutableState _letterSpacing = new((float?)null);
readonly MutableState _open = new(false);
readonly MutableState _fillWidth = new(false);
@@ -123,6 +126,7 @@ public override ComposableNode BuildNode(IComposer composer)
var packed = _textColor.Value;
var size = _fontSize.Value;
var bold = _bold.Value;
+ var spacing = _letterSpacing.Value;
var fill = _fillWidth.Value;
var isOpen = _open.Value;
@@ -150,9 +154,9 @@ public override ComposableNode BuildNode(IComposer composer)
? new DateTime(ticksValue).ToString(format)
: "Pick a date";
- var trigger = new ComposeOutlinedButton(onClick: () => _open.Value = true)
+ var trigger = new ComposeOutlinedButton(onClick: () => SetOpen(virtualView, true))
{
- BuildLabel(label, packed, size, bold),
+ BuildLabel(label, packed, size, bold, spacing),
};
// Combines the layout-fill (when set) with the cross-cutting view
// properties (Opacity, Translation, Scale, Rotation, IsVisible,
@@ -165,7 +169,7 @@ public override ComposableNode BuildNode(IComposer composer)
trigger.PrependModifier(outer);
var dialog = isOpen
- ? new ComposeDatePickerDialog(onDismissRequest: () => _open.Value = false)
+ ? new ComposeDatePickerDialog(onDismissRequest: () => SetOpen(virtualView, false))
{
ConfirmButton = new TextButton(onClick: () =>
{
@@ -186,10 +190,10 @@ public override ComposableNode BuildNode(IComposer composer)
if (VirtualView is { } dp)
dp.Date = picked;
}
- _open.Value = false;
+ SetOpen(virtualView, false);
})
{ new ComposeText("OK") },
- DismissButton = new TextButton(onClick: () => _open.Value = false)
+ DismissButton = new TextButton(onClick: () => SetOpen(virtualView, false))
{
new ComposeText("Cancel"),
},
@@ -228,7 +232,19 @@ public override ComposableNode BuildNode(IComposer composer)
});
}
- static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold)
+ ///
+ /// Toggle the dialog and propagate the new state back to MAUI's
+ /// two-way binding. The mapper
+ /// re-fires with the same value and the
+ /// equality short-circuit breaks the loop.
+ ///
+ void SetOpen(IDatePicker dp, bool isOpen)
+ {
+ _open.Value = isOpen;
+ dp.IsOpen = isOpen;
+ }
+
+ static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold, float? letterSpacing)
{
var node = new ComposeText(text);
if (packed.HasValue)
@@ -237,6 +253,8 @@ static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold
node.FontSize = new Sp(size.Value);
if (bold)
node.FontWeight = ComposeFontWeight.Bold;
+ if (letterSpacing.HasValue)
+ node.LetterSpacing = new Sp(1) * letterSpacing.Value;
return node;
}
@@ -310,6 +328,25 @@ public static void MapFont(DatePickerHandler handler, IDatePicker dp)
== Microsoft.Maui.FontWeight.Bold;
}
+ ///
+ /// Map (em units in MAUI)
+ /// to the Compose letterSpacing slot in .
+ /// MAUI emits double; 0 clears the slot (Compose falls back
+ /// to the Material default).
+ ///
+ public static void MapCharacterSpacing(DatePickerHandler handler, IDatePicker dp) =>
+ handler._letterSpacing.Value = dp.CharacterSpacing != 0 ? (float)dp.CharacterSpacing : null;
+
+ ///
+ /// Map to the Compose dialog's
+ /// open slot. Setting this property externally pops up (or
+ /// dismisses) the dialog without a tap on the trigger button —
+ /// matches stock MAUI's ShowPickerDialog /
+ /// HidePickerDialog command bridge.
+ ///
+ public static void MapIsOpen(DatePickerHandler handler, IDatePicker dp) =>
+ handler._open.Value = dp.IsOpen;
+
///
/// Map to
/// Modifier.fillMaxWidth() when the picker asks to fill its
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs
index 48f86d97..069ecbaf 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs
@@ -1,5 +1,7 @@
using AndroidX.Compose;
using AndroidX.Compose.Runtime;
+using AndroidX.Compose.UI.Text;
+using AndroidX.Compose.UI.Text.Input;
using Microsoft.AndroidX.Compose.Maui.Platform;
using Microsoft.Maui.Handlers;
using ComposeColor = AndroidX.Compose.Color;
@@ -7,6 +9,7 @@
using ComposeKeyboardType = AndroidX.Compose.KeyboardType;
using ComposeOutlinedTextField = AndroidX.Compose.OutlinedTextField;
using ComposeText = AndroidX.Compose.Text;
+using ComposeTextAlign = AndroidX.Compose.TextAlign;
using ComposeTextStyle = AndroidX.Compose.TextStyle;
namespace Microsoft.AndroidX.Compose.Maui.Handlers;
@@ -29,12 +32,12 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers;
/// so the field starts tall enough to look like a
/// TextArea.
///
-/// is enforced inside
-/// by truncating the new text
-/// — Compose's OutlinedTextField doesn't expose a
-/// maxLength slot directly.
-/// is a no-op: Compose's multi-line OutlinedTextField
-/// already grows with content when no maxLines is set.
+/// Like , value/cursor/selection state
+/// is bound through Compose's
+/// OutlinedTextField(MutableState<TextFieldValue>)
+/// overload via a custom subclass that
+/// enforces and mirrors text +
+/// caret back to MAUI's .
///
public partial class EditorHandler : ComposeElementHandler
{
@@ -46,36 +49,57 @@ public partial class EditorHandler : ComposeElementHandler
public static IPropertyMapper Mapper =
new PropertyMapper(ViewHandler.ViewMapper)
{
- [nameof(IText.Text)] = MapText,
- [nameof(ITextStyle.TextColor)] = MapTextColor,
- [nameof(ITextStyle.Font)] = MapFont,
- [nameof(IPlaceholder.Placeholder)] = MapPlaceholder,
- [nameof(ITextInput.Keyboard)] = MapKeyboard,
- [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly,
- [nameof(ITextInput.MaxLength)] = MapMaxLength,
- [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
+ [nameof(IText.Text)] = MapText,
+ [nameof(ITextStyle.TextColor)] = MapTextColor,
+ [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
+ [nameof(ITextStyle.Font)] = MapFont,
+ [nameof(IPlaceholder.Placeholder)] = MapPlaceholder,
+ [nameof(IPlaceholder.PlaceholderColor)] = MapPlaceholderColor,
+ [nameof(ITextInput.Keyboard)] = MapKeyboard,
+ [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly,
+ [nameof(ITextInput.IsSpellCheckEnabled)] = MapAutoCorrect,
+ [nameof(ITextInput.IsTextPredictionEnabled)] = MapAutoCorrect,
+ [nameof(ITextInput.MaxLength)] = MapMaxLength,
+ [nameof(ITextInput.CursorPosition)] = MapCursorPosition,
+ [nameof(ITextInput.SelectionLength)] = MapSelectionLength,
+ [nameof(ITextAlignment.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
+ // TODO: VerticalTextAlignment — Compose's Box facade doesn't
+ // expose the `contentAlignment` slot (always passes null), so
+ // we can't wrap the OutlinedTextField and align it. Leaving
+ // unmapped instead of wiring a no-op mapper.
+ [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
};
/// Command mapper (inherits view-level commands; no extras).
public static CommandMapper CommandMapper =
new(ViewCommandMapper);
- readonly MutableState _text = new(string.Empty);
- readonly MutableState _color = new((long?)null);
- readonly MutableState _fontSize = new((int?)null);
- readonly MutableState _bold = new(false);
- readonly MutableState _placeholder = new(string.Empty);
- readonly MutableState _keyboardType = new(ComposeKeyboardType.Text);
- readonly MutableState _readOnly = new(false);
- readonly MutableState _maxLength = new(-1);
- readonly MutableState _fillWidth = new(false);
+ readonly TextFieldValueState _tfv;
+ readonly MutableState _color = new((long?)null);
+ readonly MutableState _placeholderColor = new((long?)null);
+ readonly MutableState _fontSize = new((int?)null);
+ readonly MutableState _bold = new(false);
+ readonly MutableState _placeholder = new(string.Empty);
+ readonly MutableState _keyboardType = new(ComposeKeyboardType.Text);
+ readonly MutableState _readOnly = new(false);
+ readonly MutableState _autoCorrect = new(true);
+ readonly MutableState _maxLength = new(-1);
+ readonly MutableState _letterSpacing = new((float?)null);
+ readonly MutableState _hTextAlign = new((int)TextAlignment.Start);
+ readonly MutableState _fillWidth = new(false);
/// Construct a handler with the default mappers.
- public EditorHandler() : base(Mapper, CommandMapper) { }
+ public EditorHandler() : base(Mapper, CommandMapper)
+ {
+ _tfv = new TextFieldValueState(this);
+ }
/// Construct a handler with custom mappers.
public EditorHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null)
- : base(mapper ?? Mapper, commandMapper ?? CommandMapper) { }
+ : base(mapper ?? Mapper, commandMapper ?? CommandMapper)
+ {
+ _tfv = new TextFieldValueState(this);
+ }
///
public override ComposableNode BuildNode(IComposer composer)
@@ -83,37 +107,51 @@ public override ComposableNode BuildNode(IComposer composer)
var virtualView = VirtualView
?? throw new InvalidOperationException("VirtualView not set on EditorHandler.");
- var packed = _color.Value;
- var size = _fontSize.Value;
- var bold = _bold.Value;
- var placeholder = _placeholder.Value;
- var keyboardType = _keyboardType.Value;
- var readOnly = _readOnly.Value;
- var fill = _fillWidth.Value;
+ var packed = _color.Value;
+ var placeholderInk = _placeholderColor.Value;
+ var size = _fontSize.Value;
+ var bold = _bold.Value;
+ var placeholder = _placeholder.Value;
+ var keyboardType = _keyboardType.Value;
+ var readOnly = _readOnly.Value;
+ var autoCorrect = _autoCorrect.Value;
+ var letterSpacing = _letterSpacing.Value;
+ var hTextAlign = (TextAlignment)_hTextAlign.Value;
+ var fill = _fillWidth.Value;
- var field = new ComposeOutlinedTextField(_text.Value, OnValueChanged)
+ var field = new ComposeOutlinedTextField(_tfv)
{
ReadOnly = readOnly,
// Multi-line — the whole point of Editor.
SingleLine = false,
};
if (!string.IsNullOrEmpty(placeholder))
- field.Placeholder = new ComposeText(placeholder);
- if (packed.HasValue || size.HasValue || bold)
+ field.Placeholder = new ComposeText(placeholder)
+ {
+ Color = placeholderInk.HasValue ? new ComposeColor(placeholderInk.Value) : null,
+ };
+ if (packed.HasValue || size.HasValue || bold || letterSpacing.HasValue
+ || hTextAlign != TextAlignment.Start)
field.TextStyle = new ComposeTextStyle
{
- Color = packed.HasValue ? new ComposeColor(packed.Value) : null,
- FontSize = size.HasValue ? new Sp(size.Value) : null,
- FontWeight = bold ? ComposeFontWeight.Bold : null,
+ Color = packed.HasValue ? new ComposeColor(packed.Value) : null,
+ FontSize = size.HasValue ? new Sp(size.Value) : null,
+ FontWeight = bold ? ComposeFontWeight.Bold : null,
+ LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null,
+ TextAlign = hTextAlign switch
+ {
+ TextAlignment.Center => ComposeTextAlign.Center,
+ TextAlignment.End => ComposeTextAlign.End,
+ _ => null,
+ },
};
- if (keyboardType != ComposeKeyboardType.Text)
- {
- var d = KeyboardOptionsCompanion.Default;
- field.KeyboardOptions = d.Copy(
- d.Capitalization, d.AutoCorrectEnabled,
- keyboardType, d.ImeAction,
- d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales);
- }
+ // KeyboardOptions: always set so autocorrect + keyboardType
+ // overrides take effect — same pattern as EntryHandler.
+ var d = KeyboardOptionsCompanion.Default;
+ field.KeyboardOptions = d.Copy(
+ d.Capitalization, (Java.Lang.Boolean)autoCorrect,
+ keyboardType, d.ImeAction,
+ d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales);
// Tall default — feels like an editor, not a one-line entry.
// Stack on top of any caller-supplied modifier (Layout chains).
@@ -125,30 +163,28 @@ public override ComposableNode BuildNode(IComposer composer)
return field;
}
- void OnValueChanged(string newValue)
+ /// Map to the Compose value slot.
+ public static void MapText(EditorHandler handler, IEditor editor)
{
- // MAUI Editor.MaxLength: truncate before push-back so the user
- // can't sneak past the cap. Compose's OutlinedTextField has no
- // built-in maxLength slot.
- var max = _maxLength.Value;
- if (max >= 0 && newValue.Length > max)
- newValue = newValue.Substring(0, max);
-
- // Update Compose state synchronously so the rendered value stays
- // pinned to what the user just typed (mirrors EntryHandler).
- _text.Value = newValue;
- if (VirtualView is { } editor)
- editor.Text = newValue;
+ var newText = editor.Text ?? string.Empty;
+ var current = handler._tfv.Value;
+ if (current?.Text == newText) return;
+ var cursor = Math.Min(editor.CursorPosition, newText.Length);
+ if (cursor < 0) cursor = newText.Length;
+ handler._tfv.SetWithoutMirror(ComposeExtensions.NewTextFieldValue(
+ newText, TextRangeKt.TextRange(cursor), composition: null));
}
- /// Map to the Compose value slot.
- public static void MapText(EditorHandler handler, IEditor editor) =>
- handler._text.Value = editor.Text ?? string.Empty;
-
/// Map to the Compose TextStyle.Color slot.
public static void MapTextColor(EditorHandler handler, IEditor editor) =>
handler._color.Value = ColorMapping.ToPackedLong(editor.TextColor);
+ /// Map to Compose letterSpacing.
+ public static void MapCharacterSpacing(EditorHandler handler, IEditor editor) =>
+ handler._letterSpacing.Value = editor.CharacterSpacing != 0
+ ? (float)editor.CharacterSpacing
+ : null;
+
/// Map (size + bold) to Compose TextStyle slots.
public static void MapFont(EditorHandler handler, IEditor editor)
{
@@ -162,6 +198,10 @@ public static void MapFont(EditorHandler handler, IEditor editor)
public static void MapPlaceholder(EditorHandler handler, IEditor editor) =>
handler._placeholder.Value = editor.Placeholder ?? string.Empty;
+ /// Map to the placeholder Text's color.
+ public static void MapPlaceholderColor(EditorHandler handler, IEditor editor) =>
+ handler._placeholderColor.Value = ColorMapping.ToPackedLong(editor.PlaceholderColor);
+
/// Map to a Compose KeyboardType int.
public static void MapKeyboard(EditorHandler handler, IEditor editor) =>
handler._keyboardType.Value = KeyboardMapping.Resolve(editor.Keyboard, nameof(EditorHandler));
@@ -170,9 +210,53 @@ public static void MapKeyboard(EditorHandler handler, IEditor editor) =>
public static void MapIsReadOnly(EditorHandler handler, IEditor editor) =>
handler._readOnly.Value = editor.IsReadOnly;
+ ///
+ /// Combined map for +
+ /// . Compose has a
+ /// single autoCorrectEnabled slot driving both; we AND the
+ /// MAUI flags (autocorrect is only on if both checks pass).
+ ///
+ public static void MapAutoCorrect(EditorHandler handler, IEditor editor) =>
+ handler._autoCorrect.Value = editor.IsSpellCheckEnabled && editor.IsTextPredictionEnabled;
+
/// Map to the truncation cap (negative = unlimited).
- public static void MapMaxLength(EditorHandler handler, IEditor editor) =>
+ public static void MapMaxLength(EditorHandler handler, IEditor editor)
+ {
handler._maxLength.Value = editor.MaxLength;
+ // Re-truncate the current buffer if it's now over the cap.
+ if (editor.MaxLength >= 0
+ && handler._tfv.Value is { } current
+ && (current.Text?.Length ?? 0) > editor.MaxLength)
+ {
+ handler._tfv.Value = current;
+ }
+ }
+
+ /// Map to the TFV selection start.
+ public static void MapCursorPosition(EditorHandler handler, IEditor editor) =>
+ ApplySelection(handler, editor);
+
+ /// Map to the TFV selection end.
+ public static void MapSelectionLength(EditorHandler handler, IEditor editor) =>
+ ApplySelection(handler, editor);
+
+ static void ApplySelection(EditorHandler handler, IEditor editor)
+ {
+ var current = handler._tfv.Value;
+ if (current is null) return;
+ var text = current.Text ?? string.Empty;
+ var start = Math.Clamp(editor.CursorPosition, 0, text.Length);
+ var length = Math.Max(0, Math.Min(editor.SelectionLength, text.Length - start));
+ var end = start + length;
+ var existing = current.Selection;
+ if ((int)existing == start && (int)(existing >> 32) == end)
+ return;
+ handler._tfv.SetWithoutMirror(current.Copy(text, TextRangeKt.TextRange(start, end), composition: null));
+ }
+
+ /// Map to Compose textAlign.
+ public static void MapHorizontalTextAlignment(EditorHandler handler, IEditor editor) =>
+ handler._hTextAlign.Value = (int)editor.HorizontalTextAlignment;
///
/// Map to
@@ -182,4 +266,64 @@ public static void MapMaxLength(EditorHandler handler, IEditor editor) =>
public static void MapHorizontalLayoutAlignment(EditorHandler handler, IEditor editor) =>
handler._fillWidth.Value = editor.HorizontalLayoutAlignment
== Microsoft.Maui.Primitives.LayoutAlignment.Fill;
+
+ ///
+ /// of
+ /// that intercepts every write so we can enforce
+ /// (Compose has no built-in
+ /// slot) and mirror text + caret back to MAUI's
+ /// . Mirrors the
+ /// pattern.
+ ///
+ sealed class TextFieldValueState : MutableState
+ {
+ readonly EditorHandler _owner;
+ bool _suppressMirror;
+
+ public TextFieldValueState(EditorHandler owner)
+ : base(ComposeExtensions.NewTextFieldValue())
+ {
+ _owner = owner;
+ }
+
+ public void SetWithoutMirror(TextFieldValue value)
+ {
+ _suppressMirror = true;
+ try { Value = value; }
+ finally { _suppressMirror = false; }
+ }
+
+ public override TextFieldValue Value
+ {
+ get => base.Value;
+ set
+ {
+ var max = _owner._maxLength.Value;
+ if (max >= 0 && value?.Text is { } text && text.Length > max)
+ {
+ var trunc = text[..max];
+ var sel = value.Selection;
+ var start = Math.Min((int)sel, max);
+ var end = Math.Min((int)(sel >> 32), max);
+ value = ComposeExtensions.NewTextFieldValue(
+ trunc, TextRangeKt.TextRange(start, end), composition: null);
+ }
+ base.Value = value
+ ?? throw new InvalidOperationException(
+ "TextFieldValue cannot be null on EditorHandler.TextFieldValueState.");
+ if (!_suppressMirror && _owner.VirtualView is { } editor && value is not null)
+ {
+ var newText = value.Text ?? string.Empty;
+ if (editor.Text != newText)
+ editor.Text = newText;
+ var caret = (int)value.Selection;
+ var selLen = (int)(value.Selection >> 32) - caret;
+ if (editor.CursorPosition != caret)
+ editor.CursorPosition = caret;
+ if (editor.SelectionLength != selLen)
+ editor.SelectionLength = selLen;
+ }
+ }
+ }
+ }
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs
index 9a3d018a..12dcef1e 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs
@@ -1,13 +1,16 @@
using AndroidX.Compose;
using AndroidX.Compose.Runtime;
+using AndroidX.Compose.UI.Text;
using AndroidX.Compose.UI.Text.Input;
using Microsoft.AndroidX.Compose.Maui.Platform;
using Microsoft.Maui.Handlers;
using ComposeColor = AndroidX.Compose.Color;
using ComposeFontWeight = AndroidX.Compose.FontWeight;
+using ComposeImeAction = AndroidX.Compose.ImeAction;
using ComposeKeyboardType = AndroidX.Compose.KeyboardType;
using ComposeOutlinedTextField = AndroidX.Compose.OutlinedTextField;
using ComposeText = AndroidX.Compose.Text;
+using ComposeTextAlign = AndroidX.Compose.TextAlign;
using ComposeTextStyle = AndroidX.Compose.TextStyle;
namespace Microsoft.AndroidX.Compose.Maui.Handlers;
@@ -20,13 +23,20 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers;
/// .
///
///
-/// Folds into the page's single composition via
+/// Folds into the page's single composition via
/// /
/// . Uses OutlinedTextField
/// rather than the filled TextField variant because it
/// matches MAUI's stock Entry chrome more closely (clear bordered
/// outline, no shaded background fill, label that floats above the
-/// border when the field gains focus).
+/// border when the field gains focus).
+///
+/// The value/cursor/selection state is bound through Compose's
+/// OutlinedTextField(MutableState<TextFieldValue>) overload
+/// — a custom subclass intercepts every
+/// write so we can enforce
+/// (Compose has no built-in slot) and mirror text + caret back to
+/// MAUI's .
///
public partial class EntryHandler : ComposeElementHandler
{
@@ -37,36 +47,64 @@ public partial class EntryHandler : ComposeElementHandler
public static IPropertyMapper Mapper =
new PropertyMapper(ViewHandler.ViewMapper)
{
- [nameof(IText.Text)] = MapText,
- [nameof(ITextStyle.TextColor)] = MapTextColor,
- [nameof(ITextStyle.Font)] = MapFont,
- [nameof(IPlaceholder.Placeholder)] = MapPlaceholder,
- [nameof(IEntry.IsPassword)] = MapIsPassword,
- [nameof(ITextInput.Keyboard)] = MapKeyboard,
- [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly,
- [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
+ [nameof(IText.Text)] = MapText,
+ [nameof(ITextStyle.TextColor)] = MapTextColor,
+ [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
+ [nameof(ITextStyle.Font)] = MapFont,
+ [nameof(IPlaceholder.Placeholder)] = MapPlaceholder,
+ [nameof(IPlaceholder.PlaceholderColor)] = MapPlaceholderColor,
+ [nameof(IEntry.IsPassword)] = MapIsPassword,
+ [nameof(IEntry.ReturnType)] = MapReturnType,
+ [nameof(IEntry.ClearButtonVisibility)] = MapClearButtonVisibility,
+ [nameof(ITextInput.Keyboard)] = MapKeyboard,
+ [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly,
+ [nameof(ITextInput.IsSpellCheckEnabled)] = MapAutoCorrect,
+ [nameof(ITextInput.IsTextPredictionEnabled)] = MapAutoCorrect,
+ [nameof(ITextInput.MaxLength)] = MapMaxLength,
+ [nameof(ITextInput.CursorPosition)] = MapCursorPosition,
+ [nameof(ITextInput.SelectionLength)] = MapSelectionLength,
+ [nameof(ITextAlignment.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
+ [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
+ // TODO: VerticalTextAlignment — single-line OutlinedTextField has
+ // no meaningful vertical alignment slot; Compose centres the
+ // baseline by default. Stock MAUI Entry only honours this
+ // alignment when the Entry's Height exceeds line height, which
+ // is uncommon. Skip until a multi-line Entry use-case appears.
};
/// Command mapper (inherits view-level commands; no extras).
public static CommandMapper CommandMapper =
new(ViewCommandMapper);
- readonly MutableState _text = new(string.Empty);
- readonly MutableState _color = new((long?)null);
- readonly MutableState _fontSize = new((int?)null);
- readonly MutableState _bold = new(false);
- readonly MutableState _placeholder = new(string.Empty);
- readonly MutableState _isPassword = new(false);
- readonly MutableState _keyboardType = new(ComposeKeyboardType.Text);
- readonly MutableState _readOnly = new(false);
- readonly MutableState _fillWidth = new(false);
+ readonly TextFieldValueState _tfv;
+ readonly MutableState _color = new((long?)null);
+ readonly MutableState _placeholderColor = new((long?)null);
+ readonly MutableState _fontSize = new((int?)null);
+ readonly MutableState _bold = new(false);
+ readonly MutableState _placeholder = new(string.Empty);
+ readonly MutableState _isPassword = new(false);
+ readonly MutableState _keyboardType = new(ComposeKeyboardType.Text);
+ readonly MutableState _readOnly = new(false);
+ readonly MutableState _autoCorrect = new(true);
+ readonly MutableState _imeAction = new(ComposeImeAction.Default);
+ readonly MutableState _letterSpacing = new((float?)null);
+ readonly MutableState _hTextAlign = new((int)TextAlignment.Start);
+ readonly MutableState _clearButtonMode = new((int)ClearButtonVisibility.Never);
+ readonly MutableState _maxLength = new(-1);
+ readonly MutableState _fillWidth = new(false);
/// Construct a handler with the default mappers.
- public EntryHandler() : base(Mapper, CommandMapper) { }
+ public EntryHandler() : base(Mapper, CommandMapper)
+ {
+ _tfv = new TextFieldValueState(this);
+ }
/// Construct a handler with custom mappers.
public EntryHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null)
- : base(mapper ?? Mapper, commandMapper ?? CommandMapper) { }
+ : base(mapper ?? Mapper, commandMapper ?? CommandMapper)
+ {
+ _tfv = new TextFieldValueState(this);
+ }
///
public override ComposableNode BuildNode(IComposer composer)
@@ -76,43 +114,85 @@ public override ComposableNode BuildNode(IComposer composer)
SubscribeToViewProperties();
- var packed = _color.Value;
- var size = _fontSize.Value;
- var bold = _bold.Value;
- var placeholder = _placeholder.Value;
- var isPassword = _isPassword.Value;
- var keyboardType = _keyboardType.Value;
- var readOnly = _readOnly.Value;
- var fill = _fillWidth.Value;
+ var packed = _color.Value;
+ var placeholderInk = _placeholderColor.Value;
+ var size = _fontSize.Value;
+ var bold = _bold.Value;
+ var placeholder = _placeholder.Value;
+ var isPassword = _isPassword.Value;
+ var keyboardType = _keyboardType.Value;
+ var readOnly = _readOnly.Value;
+ var autoCorrect = _autoCorrect.Value;
+ var imeAction = _imeAction.Value;
+ var letterSpacing = _letterSpacing.Value;
+ var hTextAlign = (TextAlignment)_hTextAlign.Value;
+ var clearButtonMode = (ClearButtonVisibility)_clearButtonMode.Value;
+ var fill = _fillWidth.Value;
- var field = new ComposeOutlinedTextField(_text.Value, OnValueChanged)
+ var field = new ComposeOutlinedTextField(_tfv)
{
ReadOnly = readOnly,
SingleLine = true,
};
if (!string.IsNullOrEmpty(placeholder))
- field.Placeholder = new ComposeText(placeholder);
- if (packed.HasValue || size.HasValue || bold)
+ field.Placeholder = new ComposeText(placeholder)
+ {
+ Color = placeholderInk.HasValue ? new ComposeColor(placeholderInk.Value) : null,
+ };
+ if (packed.HasValue || size.HasValue || bold || letterSpacing.HasValue
+ || hTextAlign != TextAlignment.Start)
field.TextStyle = new ComposeTextStyle
{
- Color = packed.HasValue ? new ComposeColor(packed.Value) : null,
- FontSize = size.HasValue ? new Sp(size.Value) : null,
- FontWeight = bold ? ComposeFontWeight.Bold : null,
+ Color = packed.HasValue ? new ComposeColor(packed.Value) : null,
+ FontSize = size.HasValue ? new Sp(size.Value) : null,
+ FontWeight = bold ? ComposeFontWeight.Bold : null,
+ LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null,
+ TextAlign = hTextAlign switch
+ {
+ TextAlignment.Center => ComposeTextAlign.Center,
+ TextAlignment.End => ComposeTextAlign.End,
+ _ => null,
+ },
};
if (isPassword)
field.VisualTransformation = new PasswordVisualTransformation('•');
- // Always set KeyboardOptions when the resolved type isn't the
- // default `Text`, so the OS surfaces the right IME (digits,
- // email, etc.). Password buffers + numeric flag both lower
- // through `keyboardType`.
+ // Always set KeyboardOptions so OS surfaces the right IME action +
+ // honours autocorrect / keyboard-type overrides. Password buffers +
+ // numeric flag both lower through `keyboardType`.
var resolvedType = isPassword ? ComposeKeyboardType.Password : keyboardType;
- if (resolvedType != ComposeKeyboardType.Text)
+ var d = KeyboardOptionsCompanion.Default;
+ field.KeyboardOptions = d.Copy(
+ d.Capitalization, (Java.Lang.Boolean)autoCorrect,
+ resolvedType, imeAction,
+ d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales);
+
+ // Wire the IME action callback so MAUI Entry.Completed (and
+ // SearchButtonPressed-style return-key handling) fires when the
+ // user taps the IME's action key.
+ field.KeyboardActions = KeyboardActionsHelper.Create(
+ onDone: OnCompleted,
+ onGo: OnCompleted,
+ onNext: OnCompleted,
+ onSearch: OnCompleted,
+ onSend: OnCompleted,
+ onPrevious: OnCompleted);
+
+ // Clear-X trailing icon when MAUI's ClearButtonVisibility ==
+ // WhileEditing and the text is non-empty. We always render it
+ // when WhileEditing because Compose's OutlinedTextField doesn't
+ // expose a "focused" flag without subscribing to FocusInteraction
+ // state — close enough for parity with stock MAUI on Android.
+ if (clearButtonMode == ClearButtonVisibility.WhileEditing
+ && !string.IsNullOrEmpty(_tfv.Value?.Text))
{
- var d = KeyboardOptionsCompanion.Default;
- field.KeyboardOptions = d.Copy(
- d.Capitalization, d.AutoCorrectEnabled,
- resolvedType, d.ImeAction,
- d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales);
+ field.TrailingIcon = new IconButton(onClick: () =>
+ {
+ if (VirtualView is { } entry)
+ entry.Text = string.Empty;
+ })
+ {
+ new ComposeText("\u2715"), // ✕
+ };
}
// Single chained PrependModifier — combines layout-fill with
@@ -125,28 +205,36 @@ public override ComposableNode BuildNode(IComposer composer)
return field;
}
- void OnValueChanged(string newValue)
+ void OnCompleted()
{
- // Update Compose state synchronously so the rendered value stays
- // pinned to what the user just typed (Compose snaps `value`
- // back on the next recompose; lagging here drops keystrokes).
- // Updating VirtualView.Text after triggers MAUI's standard
- // property pipeline (data binding, behaviors, validation) which
- // re-enters MapText with the same string — that's a no-op on
- // MutableState, so no feedback loop.
- _text.Value = newValue;
- if (VirtualView is { } entry)
- entry.Text = newValue;
+ if (VirtualView is Microsoft.Maui.Controls.Entry e)
+ e.SendCompleted();
}
/// Map to the Compose value slot.
- public static void MapText(EntryHandler handler, IEntry entry) =>
- handler._text.Value = entry.Text ?? string.Empty;
+ public static void MapText(EntryHandler handler, IEntry entry)
+ {
+ var newText = entry.Text ?? string.Empty;
+ var current = handler._tfv.Value;
+ if (current?.Text == newText) return;
+ // Text changed externally — snap cursor to end so the IME
+ // doesn't try to re-render at a stale offset.
+ var cursor = Math.Min(entry.CursorPosition, newText.Length);
+ if (cursor < 0) cursor = newText.Length;
+ handler._tfv.SetWithoutMirror(ComposeExtensions.NewTextFieldValue(
+ newText, TextRangeKt.TextRange(cursor), composition: null));
+ }
/// Map to the Compose TextStyle.Color slot.
public static void MapTextColor(EntryHandler handler, IEntry entry) =>
handler._color.Value = ColorMapping.ToPackedLong(entry.TextColor);
+ /// Map to Compose letterSpacing.
+ public static void MapCharacterSpacing(EntryHandler handler, IEntry entry) =>
+ handler._letterSpacing.Value = entry.CharacterSpacing != 0
+ ? (float)entry.CharacterSpacing
+ : null;
+
/// Map (size + bold) to Compose TextStyle slots.
public static void MapFont(EntryHandler handler, IEntry entry)
{
@@ -160,10 +248,30 @@ public static void MapFont(EntryHandler handler, IEntry entry)
public static void MapPlaceholder(EntryHandler handler, IEntry entry) =>
handler._placeholder.Value = entry.Placeholder ?? string.Empty;
+ /// Map to the placeholder Text's color.
+ public static void MapPlaceholderColor(EntryHandler handler, IEntry entry) =>
+ handler._placeholderColor.Value = ColorMapping.ToPackedLong(entry.PlaceholderColor);
+
/// Map to the visualTransformation + keyboardType slots.
public static void MapIsPassword(EntryHandler handler, IEntry entry) =>
handler._isPassword.Value = entry.IsPassword;
+ /// Map to KeyboardOptions.imeAction.
+ public static void MapReturnType(EntryHandler handler, IEntry entry) =>
+ handler._imeAction.Value = entry.ReturnType switch
+ {
+ ReturnType.Done => ComposeImeAction.Done,
+ ReturnType.Go => ComposeImeAction.Go,
+ ReturnType.Next => ComposeImeAction.Next,
+ ReturnType.Search => ComposeImeAction.Search,
+ ReturnType.Send => ComposeImeAction.Send,
+ _ => ComposeImeAction.Default,
+ };
+
+ /// Map to the trailing X icon toggle.
+ public static void MapClearButtonVisibility(EntryHandler handler, IEntry entry) =>
+ handler._clearButtonMode.Value = (int)entry.ClearButtonVisibility;
+
/// Map to a Compose KeyboardType int.
public static void MapKeyboard(EntryHandler handler, IEntry entry) =>
handler._keyboardType.Value = KeyboardMapping.Resolve(entry.Keyboard, nameof(EntryHandler));
@@ -172,6 +280,56 @@ public static void MapKeyboard(EntryHandler handler, IEntry entry) =>
public static void MapIsReadOnly(EntryHandler handler, IEntry entry) =>
handler._readOnly.Value = entry.IsReadOnly;
+ ///
+ /// Combined map for +
+ /// . Compose has a single
+ /// autoCorrectEnabled slot driving both; we AND the MAUI flags
+ /// (autocorrect is only on if both checks pass).
+ ///
+ public static void MapAutoCorrect(EntryHandler handler, IEntry entry) =>
+ handler._autoCorrect.Value = entry.IsSpellCheckEnabled && entry.IsTextPredictionEnabled;
+
+ /// Map to the truncation cap (negative = unlimited).
+ public static void MapMaxLength(EntryHandler handler, IEntry entry)
+ {
+ handler._maxLength.Value = entry.MaxLength;
+ // Re-truncate the current buffer if it's now over the cap.
+ if (entry.MaxLength >= 0
+ && handler._tfv.Value is { } current
+ && (current.Text?.Length ?? 0) > entry.MaxLength)
+ {
+ handler._tfv.Value = current; // override re-applies truncation
+ }
+ }
+
+ /// Map to the TFV selection start.
+ public static void MapCursorPosition(EntryHandler handler, IEntry entry) =>
+ ApplySelection(handler, entry);
+
+ /// Map to the TFV selection end.
+ public static void MapSelectionLength(EntryHandler handler, IEntry entry) =>
+ ApplySelection(handler, entry);
+
+ static void ApplySelection(EntryHandler handler, IEntry entry)
+ {
+ var current = handler._tfv.Value;
+ if (current is null) return;
+ var text = current.Text ?? string.Empty;
+ var start = Math.Clamp(entry.CursorPosition, 0, text.Length);
+ var length = Math.Max(0, Math.Min(entry.SelectionLength, text.Length - start));
+ var end = start + length;
+ // Skip writes that wouldn't actually change Compose's selection —
+ // re-entrance from OnSelectionChanged hits this constantly.
+ var existing = current.Selection;
+ if ((int)existing == start && (int)(existing >> 32) == end)
+ return;
+ handler._tfv.SetWithoutMirror(current.Copy(text, TextRangeKt.TextRange(start, end), composition: null));
+ }
+
+ /// Map to Compose textAlign.
+ public static void MapHorizontalTextAlignment(EntryHandler handler, IEntry entry) =>
+ handler._hTextAlign.Value = (int)entry.HorizontalTextAlignment;
+
///
/// Map to
/// Modifier.fillMaxWidth() when the entry asks to fill its
@@ -182,4 +340,74 @@ public static void MapIsReadOnly(EntryHandler handler, IEntry entry) =>
public static void MapHorizontalLayoutAlignment(EntryHandler handler, IEntry entry) =>
handler._fillWidth.Value = entry.HorizontalLayoutAlignment
== Microsoft.Maui.Primitives.LayoutAlignment.Fill;
+
+ ///
+ /// of
+ /// that intercepts every write so we can enforce
+ /// (Compose has no built-in
+ /// slot) and mirror the resulting text + caret position back to
+ /// MAUI's . Compose's
+ /// OutlinedTextField(MutableState<TextFieldValue>)
+ /// ctor writes state.Value = newTfv from its
+ /// onValueChange lambda, so overriding the
+ /// setter intercepts user edits as well as
+ /// programmatic writes.
+ ///
+ sealed class TextFieldValueState : MutableState
+ {
+ readonly EntryHandler _owner;
+ bool _suppressMirror;
+
+ public TextFieldValueState(EntryHandler owner)
+ : base(ComposeExtensions.NewTextFieldValue())
+ {
+ _owner = owner;
+ }
+
+ // Programmatic write that updates the Compose state without
+ // bouncing the new value back through MAUI's IEntry.Text setter
+ // (which would feed-back into MapText). Used by MapText and
+ // ApplySelection where the value is already authoritative on
+ // the MAUI side.
+ public void SetWithoutMirror(TextFieldValue value)
+ {
+ _suppressMirror = true;
+ try { Value = value; }
+ finally { _suppressMirror = false; }
+ }
+
+ public override TextFieldValue Value
+ {
+ get => base.Value;
+ set
+ {
+ var max = _owner._maxLength.Value;
+ if (max >= 0 && value?.Text is { } text && text.Length > max)
+ {
+ var trunc = text[..max];
+ // Clamp existing selection within the new bounds.
+ var sel = value.Selection;
+ var start = Math.Min((int)sel, max);
+ var end = Math.Min((int)(sel >> 32), max);
+ value = ComposeExtensions.NewTextFieldValue(
+ trunc, TextRangeKt.TextRange(start, end), composition: null);
+ }
+ base.Value = value
+ ?? throw new InvalidOperationException(
+ "TextFieldValue cannot be null on EntryHandler.TextFieldValueState.");
+ if (!_suppressMirror && _owner.VirtualView is { } entry && value is not null)
+ {
+ var newText = value.Text ?? string.Empty;
+ if (entry.Text != newText)
+ entry.Text = newText;
+ var caret = (int)value.Selection;
+ var selLen = (int)(value.Selection >> 32) - caret;
+ if (entry.CursorPosition != caret)
+ entry.CursorPosition = caret;
+ if (entry.SelectionLength != selLen)
+ entry.SelectionLength = selLen;
+ }
+ }
+ }
+ }
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs
index 42907e2b..8c3849f6 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs
@@ -52,6 +52,10 @@ 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 f7a6b16d..e3e61871 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs
@@ -53,6 +53,14 @@ 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.
};
/// Command mapper (inherits view-level commands; no extras).
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/IndicatorViewHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/IndicatorViewHandler.cs
index cc042b34..8da9890a 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/IndicatorViewHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/IndicatorViewHandler.cs
@@ -69,6 +69,8 @@ public partial class IndicatorViewHandler : ComposeElementHandlerCommand mapper (inherits view-level commands; no extras).
@@ -84,6 +86,8 @@ public partial class IndicatorViewHandler : ComposeElementHandler _position = new(0);
readonly MutableState _indicatorColor = new((long?)null);
readonly MutableState _selectedColor = new((long?)null);
+ readonly MutableState _hideSingle = new(true);
+ readonly MutableState _maximumVisible = new(int.MaxValue);
/// Construct a handler with the default mappers.
public IndicatorViewHandler() : base(Mapper, CommandMapper) { }
@@ -106,11 +110,26 @@ public override ComposableNode BuildNode(IComposer composer)
int count = view.Count;
int position = _position.Value;
+ bool hideSingle = _hideSingle.Value;
+ int maxVisible = _maximumVisible.Value;
double sizeDp = view.IndicatorSize > 0 ? view.IndicatorSize : 6.0;
var shape = view.IndicatorsShape == IndicatorShape.Square
? new RoundedCornerShape(0)
: new RoundedCornerShape(50);
+ // HideSingle (MAUI default = true): collapse the strip when
+ // there's only one carousel page — there's nothing to indicate.
+ // Returning an empty Row keeps the slot alive so the next
+ // recompose with count >= 2 patches in dots without
+ // re-establishing the parent layout.
+ int visibleCount = hideSingle && count <= 1 ? 0 : count;
+ // MaximumVisible caps how many dots are rendered (stock MAUI
+ // truncates from the tail; we mirror that — a 10-page carousel
+ // with MaximumVisible = 5 shows dots [0..4], the page indicator
+ // for pages 5-9 disappears).
+ if (maxVisible > 0)
+ visibleCount = Math.Min(visibleCount, maxVisible);
+
long inactiveColor = _indicatorColor.Value
?? ColorMapping.ToPackedLong(Microsoft.Maui.Graphics.Colors.LightGrey)
?? throw new InvalidOperationException("Failed to pack LightGrey color.");
@@ -140,7 +159,7 @@ public override ComposableNode BuildNode(IComposer composer)
Modifier = Modifier.Companion.ApplyViewProperties(view).ApplySemantics(view),
};
- for (int i = 0; i < count; i++)
+ for (int i = 0; i < visibleCount; i++)
{
long color = i == position ? activeColor : inactiveColor;
row.Add(new Box
@@ -178,4 +197,19 @@ public static void MapIndicatorSize(IndicatorViewHandler handler, MauiIndicatorV
/// Bump the indicator-shape version slot.
public static void MapIndicatorsShape(IndicatorViewHandler handler, MauiIndicatorView _) =>
handler._shapeVersion.Value++;
+
+ ///
+ /// Map : when
+ /// , the dot strip is suppressed while
+ /// is 0 or 1.
+ ///
+ public static void MapHideSingle(IndicatorViewHandler handler, MauiIndicatorView view) =>
+ handler._hideSingle.Value = view.HideSingle;
+
+ ///
+ /// Map to a hard cap on
+ /// the number of dots rendered; tail dots beyond the cap are hidden.
+ ///
+ public static void MapMaximumVisible(IndicatorViewHandler handler, MauiIndicatorView view) =>
+ handler._maximumVisible.Value = view.MaximumVisible;
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs
index 68811c71..43baf791 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs
@@ -40,11 +40,20 @@ public partial class LabelHandler : ComposeElementHandler
public static IPropertyMapper Mapper =
new PropertyMapper(ViewHandler.ViewMapper)
{
- [nameof(IText.Text)] = MapText,
- [nameof(ITextStyle.TextColor)] = MapTextColor,
- [nameof(ITextStyle.Font)] = MapFont,
- [nameof(ILabel.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
+ [nameof(IText.Text)] = MapText,
+ [nameof(ITextStyle.TextColor)] = MapTextColor,
+ [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
+ [nameof(ITextStyle.Font)] = MapFont,
+ [nameof(ILabel.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
+ [nameof(ILabel.LineHeight)] = MapLineHeight,
+ [nameof(ILabel.TextDecorations)] = MapTextDecorations,
+ [nameof(IPadding.Padding)] = MapPadding,
[nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
+ // TODO: VerticalTextAlignment — requires wrapping the Text in a
+ // Box whose `contentAlignment` slot is set; our Box facade
+ // currently doesn't expose `contentAlignment` so we can't drive
+ // it without a generator change. In stock MAUI this only has a
+ // visible effect when the Label has an explicit Height anyway.
};
/// Command mapper (inherits view-level commands; no extras).
@@ -60,6 +69,19 @@ public partial class LabelHandler : ComposeElementHandler
// user-defined enums and would throw NotSupportedException at ctor time.
readonly MutableState _hTextAlign = new((int)TextAlignment.Start);
readonly MutableState _fillWidth = new(false);
+ // CharacterSpacing in MAUI is "em"-ish (0..1 typically). Packed via the
+ // Sp(1) * float overload because Sp has no (float) ctor.
+ readonly MutableState _letterSpacing = new((float?)null);
+ // LineHeight in MAUI is a multiplier on the default line height; we
+ // expose it as an int sp here for simplicity — null when not set.
+ readonly MutableState _lineHeight = new((int?)null);
+ // TextDecorations enum stored as int (Flags: None=0, Underline=1,
+ // Strikethrough=2). MutableState's generic boxed path doesn't recognise
+ // [Flags] enums.
+ readonly MutableState _decorations = new(0);
+ // Padding live-read in BuildNode; this version slot just bumps to force
+ // a recomposition when MAUI invokes the mapper.
+ readonly MutableState _paddingVersion = new(0);
/// Construct a handler with the default mappers.
public LabelHandler() : base(Mapper, CommandMapper) { }
@@ -85,6 +107,12 @@ public override ComposableNode BuildNode(IComposer composer)
var bold = _bold.Value;
var fill = _fillWidth.Value;
var align = (TextAlignment)_hTextAlign.Value;
+ var letterSpacing = _letterSpacing.Value;
+ var lineHeight = _lineHeight.Value;
+ var decorations = (Microsoft.Maui.TextDecorations)_decorations.Value;
+ // Subscribe so padding mapper bumps re-run BuildNode.
+ _ = _paddingVersion.Value;
+ var padding = virtualView.Padding;
var text = new ComposeText(_text.Value)
{
Color = packed.HasValue ? new ComposeColor(packed.Value) : null,
@@ -96,15 +124,40 @@ public override ComposableNode BuildNode(IComposer composer)
TextAlignment.End => ComposeTextAlign.End,
_ => null,
},
+ LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null,
+ LineHeight = lineHeight.HasValue ? new Sp(lineHeight.Value) : null,
+ // Strikethrough takes precedence over Underline when both bits
+ // are set; combining the two would need TextDecoration.Combine
+ // which isn't bound yet.
+ // TODO: expose TextDecoration.Combine to handle the
+ // Underline | Strikethrough combination faithfully.
+ Decoration = decorations switch
+ {
+ Microsoft.Maui.TextDecorations.Strikethrough => TextDecoration.LineThrough,
+ Microsoft.Maui.TextDecorations.Underline => TextDecoration.Underline,
+ _ when (decorations & Microsoft.Maui.TextDecorations.Strikethrough) != 0
+ => TextDecoration.LineThrough,
+ _ when (decorations & Microsoft.Maui.TextDecorations.Underline) != 0
+ => TextDecoration.Underline,
+ _ => null,
+ },
};
// Single PrependModifier call combining the layout-fill (if
// applicable) with the cross-cutting view properties — calling
// PrependModifier twice would replace, not merge, so this
- // builds the chain once.
+ // builds the chain once. View properties are applied on the
+ // outermost modifier (per ModifierBridge convention) so
+ // background/shadow/opacity cover the entire label rectangle;
+ // padding is innermost so it only shrinks the content area.
var outer = (fill ? Modifier.FillMaxWidth() : Modifier.Companion)
.ApplyViewProperties(virtualView)
.ApplyGestures(virtualView, MauiContext)
- .ApplySemantics(virtualView);
+ .ApplySemantics(virtualView)
+ .Padding(
+ new Dp((float)padding.Left),
+ new Dp((float)padding.Top),
+ new Dp((float)padding.Right),
+ new Dp((float)padding.Bottom));
text.PrependModifier(outer);
return text;
}
@@ -149,4 +202,47 @@ public static void MapHorizontalLayoutAlignment(LabelHandler handler, ILabel lab
is Microsoft.Maui.Primitives.LayoutAlignment.Fill
or Microsoft.Maui.Primitives.LayoutAlignment.Center;
+ ///
+ /// Map to Compose
+ /// letterSpacing. MAUI's value is in "em-ish" units; we
+ /// pack it via the * float overload because
+ /// has no (float) constructor.
+ ///
+ public static void MapCharacterSpacing(LabelHandler handler, ILabel label) =>
+ handler._letterSpacing.Value = label.CharacterSpacing != 0
+ ? (float)label.CharacterSpacing
+ : null;
+
+ /// Map to Compose lineHeight.
+ ///
+ /// MAUI's LineHeight is a multiplier on the platform default
+ /// line height (-1 = use default). Compose expects an absolute sp.
+ /// We approximate by multiplying against the current font size when
+ /// known; otherwise leave the slot unset.
+ ///
+ public static void MapLineHeight(LabelHandler handler, ILabel label)
+ {
+ var lh = label.LineHeight;
+ if (lh <= 0)
+ {
+ handler._lineHeight.Value = null;
+ return;
+ }
+ var size = label.Font.Size > 0 ? label.Font.Size : 14d;
+ handler._lineHeight.Value = (int)Math.Round(size * lh);
+ }
+
+ ///
+ /// Map to Compose
+ /// TextDecoration. Only single-flag values render correctly;
+ /// the combined Underline | Strikethrough case falls back to
+ /// Strikethrough (see ).
+ ///
+ public static void MapTextDecorations(LabelHandler handler, ILabel label) =>
+ handler._decorations.Value = (int)label.TextDecorations;
+
+ /// Map . Live-read in .
+ public static void MapPadding(LabelHandler handler, ILabel label) =>
+ handler._paddingVersion.Value = handler._paddingVersion.Value + 1;
+
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LayoutHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LayoutHandler.cs
index 462133fa..2d6f124d 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LayoutHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LayoutHandler.cs
@@ -53,6 +53,7 @@ public partial class LayoutHandler : ComposeElementHandler
// invalidates the layout's container subtree (Compose
// smart-skips siblings whose state is unchanged).
["Children"] = MapChildren,
+ ["ClipsToBounds"] = MapClipsToBounds,
[nameof(IStackLayout.Spacing)] = MapSpacing,
[nameof(IPadding.Padding)] = MapPadding,
};
@@ -63,6 +64,7 @@ public partial class LayoutHandler : ComposeElementHandler
readonly MutableState _childrenVersion = new(0);
readonly MutableState _spacing = new(0f);
+ readonly MutableState _clipsToBounds = new(false);
// Thickness is a MAUI struct; not a Java type, primitive, or
// Nullable, so MutableState throws
// NotSupportedException at construction. Use a version counter
@@ -131,10 +133,15 @@ ComposableNode BuildStack(ILayout layout, IMauiContext context, bool vertical)
// 1. ApplyViewProperties wraps the OUTER box (Opacity / Scale /
// Rotation / Clip / Shadow / IsVisible affect the entire
// padded layout including its own background-painting slot).
- // 2. Padding goes innermost so the inner content gets the
+ // 2. ClipsToBounds = true clips children to the layout's
+ // rectangle (Compose draws children outside their parent's
+ // bounds by default — this matches MAUI's Layout.IsClippedToBounds).
+ // 3. Padding goes innermost so the inner content gets the
// inset, while alpha / clip / shadow trace the outer box.
// PrependModifier replaces, so emit a single chained call.
var outer = Modifier.Companion.ApplyViewProperties(layout).ApplyGestures(layout, context).ApplySemantics(layout);
+ if (_clipsToBounds.Value)
+ outer = outer.Clip(Shape.Rectangle);
if (padding != Thickness.Zero)
{
outer = outer.Padding(
@@ -167,6 +174,16 @@ public static void MapChildren(LayoutHandler handler, ILayout layout)
handler._childrenVersion.Value++;
}
+ ///
+ /// Map to a
+ /// Modifier.Clip(RectangleShape) on the outer container.
+ /// When , children that overflow the
+ /// layout's measured bounds are clipped — matches MAUI's
+ /// Layout.IsClippedToBounds contract.
+ ///
+ public static void MapClipsToBounds(LayoutHandler handler, ILayout layout) =>
+ handler._clipsToBounds.Value = layout.ClipsToBounds;
+
/// Map to the spacing slot (dp).
public static void MapSpacing(LayoutHandler handler, ILayout layout)
{
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/PageHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/PageHandler.cs
index 83608fcb..bc5d875f 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/PageHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/PageHandler.cs
@@ -50,6 +50,7 @@ public partial class PageHandler : ViewHandler
new PropertyMapper(ViewHandler.ViewMapper)
{
["Content"] = MapContent,
+ ["Title"] = MapTitle,
};
/// Command mapper (inherits the base view commands; no extras).
@@ -155,4 +156,18 @@ protected override void DisconnectHandler(ComposeView platformView)
platformView.DisposeComposition();
base.DisconnectHandler(platformView);
}
+
+ ///
+ /// Map . The Compose
+ /// handler is registered against (which has
+ /// no Title); the Activity/Shell action-bar title is owned by
+ /// the navigation host, not the page view. Stock MAUI's
+ /// PageHandler.MapTitle is itself a deliberate no-op for the same
+ /// reason — Shell / NavigationPage push titles down through their own
+ /// handlers. We register the key purely for parity so MAUI's batched
+ /// property pipeline doesn't log "missing mapper" warnings.
+ ///
+ public static void MapTitle(PageHandler handler, IContentView page)
+ {
+ }
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/PickerHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/PickerHandler.cs
index 86207a83..a8693951 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/PickerHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/PickerHandler.cs
@@ -8,6 +8,7 @@
using ComposeFontWeight = AndroidX.Compose.FontWeight;
using ComposeOutlinedTextField = AndroidX.Compose.OutlinedTextField;
using ComposeText = AndroidX.Compose.Text;
+using ComposeTextAlign = AndroidX.Compose.TextAlign;
using ComposeTextStyle = AndroidX.Compose.TextStyle;
using MauiPicker = Microsoft.Maui.Controls.Picker;
@@ -64,12 +65,22 @@ public partial class PickerHandler : ComposeElementHandler
// the dropdown menu's child list (Compose smart-skips
// siblings whose state is unchanged).
["ItemsSource"] = MapItemsSource,
+ // Stock MAUI mapper also exposes an "Items" key that fires
+ // when the IPicker.Items collection is reset; we forward
+ // to the same handler.
+ ["Items"] = MapItemsSource,
[nameof(IPicker.SelectedIndex)] = MapSelectedIndex,
[nameof(IPicker.Title)] = MapTitle,
[nameof(IPicker.TitleColor)] = MapTitleColor,
[nameof(ITextStyle.TextColor)] = MapTextColor,
+ [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
[nameof(ITextStyle.Font)] = MapFont,
+ [nameof(IPicker.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
+ [nameof(IPicker.IsOpen)] = MapIsOpen,
[nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
+ // TODO: VerticalTextAlignment — see LabelHandler. Requires a
+ // Box wrapper with `contentAlignment` to take visible effect
+ // (and only matters when the picker has an explicit Height).
};
/// Command mapper (inherits view-level commands; no extras).
@@ -85,6 +96,9 @@ public partial class PickerHandler : ComposeElementHandler
readonly MutableState _bold = new(false);
readonly MutableState _open = new(false);
readonly MutableState _fillWidth = new(false);
+ readonly MutableState _letterSpacing = new((float?)null);
+ // Stored as the underlying int (see LabelHandler note on enum-backed slots).
+ readonly MutableState _hTextAlign = new((int)TextAlignment.Start);
INotifyCollectionChanged? _subscribedItems;
@@ -143,6 +157,8 @@ public override ComposableNode BuildNode(IComposer composer)
var bold = _bold.Value;
var fill = _fillWidth.Value;
var isOpen = _open.Value;
+ var letterSpacing = _letterSpacing.Value;
+ var hAlign = (TextAlignment)_hTextAlign.Value;
var displayValue = selectedIndex >= 0 && selectedIndex < items.Count
? items[selectedIndex] ?? string.Empty
@@ -152,7 +168,7 @@ public override ComposableNode BuildNode(IComposer composer)
{
ReadOnly = true,
SingleLine = true,
- TrailingIcon = new IconButton(onClick: () => _open.Value = !_open.Value)
+ TrailingIcon = new IconButton(onClick: () => SetOpen(virtualView, !_open.Value))
{
new ComposeText(isOpen ? "▲" : "▼"),
},
@@ -163,13 +179,20 @@ public override ComposableNode BuildNode(IComposer composer)
? new ComposeText(title) { Color = new ComposeColor(packedTitleColor.Value) }
: new ComposeText(title);
}
- if (packedTextColor.HasValue || size.HasValue || bold)
+ if (packedTextColor.HasValue || size.HasValue || bold || letterSpacing.HasValue || hAlign != TextAlignment.Start)
{
trigger.TextStyle = new ComposeTextStyle
{
- Color = packedTextColor.HasValue ? new ComposeColor(packedTextColor.Value) : null,
- FontSize = size.HasValue ? new Sp(size.Value) : null,
- FontWeight = bold ? ComposeFontWeight.Bold : null,
+ Color = packedTextColor.HasValue ? new ComposeColor(packedTextColor.Value) : null,
+ FontSize = size.HasValue ? new Sp(size.Value) : null,
+ FontWeight = bold ? ComposeFontWeight.Bold : null,
+ LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null,
+ TextAlign = hAlign switch
+ {
+ TextAlignment.Center => ComposeTextAlign.Center,
+ TextAlignment.End => ComposeTextAlign.End,
+ _ => null,
+ },
};
}
// Combines the layout-fill (when set) with the cross-cutting view
@@ -185,7 +208,7 @@ public override ComposableNode BuildNode(IComposer composer)
var menu = new ExposedDropdownMenu(
expanded: isOpen,
- onDismissRequest: () => _open.Value = false);
+ onDismissRequest: () => SetOpen(virtualView, false));
for (int i = 0; i < items.Count; i++)
{
// Capture i so the click closure points at this row's index.
@@ -198,13 +221,28 @@ public override ComposableNode BuildNode(IComposer composer)
return new ExposedDropdownMenuBox(
expanded: isOpen,
- onExpandedChange: v => _open.Value = v)
+ onExpandedChange: v => SetOpen(virtualView, v))
{
trigger,
menu,
};
}
+ // Two-way IsOpen helper: writes both the Compose state slot and the
+ // MAUI virtual view in lockstep. MutableState's equality short-circuit
+ // breaks the resulting MapIsOpen feedback loop just like
+ // EntryHandler.OnValueChanged.
+ static void SetOpen(IPicker picker, bool isOpen, PickerHandler? handler = null)
+ {
+ // Best-effort lookup of the handler when caller didn't pass it.
+ // Avoids holding a strong field reference inside lambdas.
+ handler ??= picker.Handler as PickerHandler;
+ if (handler is not null)
+ handler._open.Value = isOpen;
+ if (picker.IsOpen != isOpen)
+ picker.IsOpen = isOpen;
+ }
+
void OnItemSelected(int index)
{
// Update Compose state synchronously so the trigger label
@@ -295,4 +333,31 @@ public static void MapFont(PickerHandler handler, IPicker picker)
public static void MapHorizontalLayoutAlignment(PickerHandler handler, IPicker picker) =>
handler._fillWidth.Value = picker.HorizontalLayoutAlignment
== Microsoft.Maui.Primitives.LayoutAlignment.Fill;
+
+ ///
+ /// Map to the Compose
+ /// TextStyle.LetterSpacing slot on the trigger.
+ ///
+ public static void MapCharacterSpacing(PickerHandler handler, IPicker picker) =>
+ handler._letterSpacing.Value = picker.CharacterSpacing != 0
+ ? (float)picker.CharacterSpacing
+ : null;
+
+ ///
+ /// Map to the Compose
+ /// TextStyle.TextAlign slot on the trigger.
+ ///
+ public static void MapHorizontalTextAlignment(PickerHandler handler, IPicker picker) =>
+ handler._hTextAlign.Value = (int)picker.HorizontalTextAlignment;
+
+ ///
+ /// Map to the Compose dropdown
+ /// expansion state. Two-way: tap/dismiss both write the virtual
+ /// view's IsOpen back through .
+ ///
+ public static void MapIsOpen(PickerHandler handler, IPicker picker)
+ {
+ if (handler._open.Value != picker.IsOpen)
+ handler._open.Value = picker.IsOpen;
+ }
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs
index 11f89b73..1c85d998 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs
@@ -60,6 +60,14 @@ public partial class RadioButtonHandler : ComposeElementHandler
[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.
};
/// Command mapper (inherits view-level commands; no extras).
@@ -71,6 +79,7 @@ public partial class RadioButtonHandler : ComposeElementHandler
readonly MutableState _color = new((long?)null);
readonly MutableState _fontSize = new((int?)null);
readonly MutableState _bold = new(false);
+ readonly MutableState _letterSpacing = new((float?)null);
/// Construct a handler with the default mappers.
public RadioButtonHandler() : base(Mapper, CommandMapper) { }
@@ -88,6 +97,7 @@ public override ComposableNode BuildNode(IComposer composer)
var packed = _color.Value;
var size = _fontSize.Value;
var bold = _bold.Value;
+ var spacing = _letterSpacing.Value;
var label = _label.Value;
var radio = new ComposeRadioButton(selected: _checked.Value, onClick: OnSelected);
@@ -105,6 +115,8 @@ public override ComposableNode BuildNode(IComposer composer)
text.FontSize = new Sp(size.Value);
if (bold)
text.FontWeight = ComposeFontWeight.Bold;
+ if (spacing.HasValue)
+ text.LetterSpacing = new Sp(1) * spacing.Value;
var row = new Row(horizontalArrangement: null,
verticalAlignment: Alignment.Vertical.CenterVertically)
@@ -165,4 +177,14 @@ public static void MapFont(RadioButtonHandler handler, IRadioButton rb)
handler._bold.Value = (font.Weight & Microsoft.Maui.FontWeight.Bold)
== Microsoft.Maui.FontWeight.Bold;
}
+
+ ///
+ /// Map to the Compose
+ /// label's letterSpacing slot (sp). MAUI's value is em-ish;
+ /// the multiplication operator on packs a
+ /// fractional Sp without losing the signal that an
+ /// int-only constructor would round to zero.
+ ///
+ public static void MapCharacterSpacing(RadioButtonHandler handler, IRadioButton rb) =>
+ handler._letterSpacing.Value = rb.CharacterSpacing != 0 ? (float)rb.CharacterSpacing : null;
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs
index 73865f96..d828aefe 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs
@@ -54,8 +54,15 @@ public partial class RefreshViewHandler : ComposeElementHandler
public static IPropertyMapper Mapper =
new PropertyMapper(ViewHandler.ViewMapper)
{
- [nameof(IRefreshView.IsRefreshing)] = MapIsRefreshing,
- ["Content"] = MapContent,
+ [nameof(IRefreshView.IsRefreshing)] = MapIsRefreshing,
+ [nameof(IRefreshView.IsRefreshEnabled)] = MapIsRefreshEnabled,
+ ["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).
@@ -63,6 +70,7 @@ public partial class RefreshViewHandler : ComposeElementHandler
new(ViewCommandMapper);
readonly MutableState _isRefreshing = new(false);
+ readonly MutableState _isEnabled = new(true);
// Bumped whenever Content swaps so BuildNode reads the live
// PresentedContent reference (IView itself doesn't fit in
// MutableState; same trick as ContentViewHandler).
@@ -87,6 +95,7 @@ public override ComposableNode BuildNode(IComposer composer)
var context = MauiContext
?? throw new InvalidOperationException("MauiContext not set on RefreshViewHandler.");
+ bool isEnabled = _isEnabled.Value;
var box = new ComposePullToRefreshBox(
isRefreshing: _isRefreshing.Value,
onRefresh: () =>
@@ -100,6 +109,13 @@ public override ComposableNode BuildNode(IComposer composer)
// The mapper re-fires with the same value and the
// MutableState equality short-circuit breaks the
// loop.
+ //
+ // When IsRefreshEnabled = false we swallow the pull-down
+ // gesture entirely — stock MAUI sets SwipeRefreshLayout.Enabled
+ // = false which suppresses both the spinner and the
+ // event, matching that behaviour here without needing
+ // the lower-level enabled-flag API on PullToRefreshBox.
+ if (!isEnabled) return;
_isRefreshing.Value = true;
view.IsRefreshing = true;
});
@@ -127,4 +143,14 @@ public static void MapIsRefreshing(RefreshViewHandler handler, IRefreshView view
/// Bump the content version slot so re-walks.
public static void MapContent(RefreshViewHandler handler, IRefreshView _) =>
handler._contentVersion.Value++;
+
+ ///
+ /// Map . When
+ /// the pull-to-refresh gesture is swallowed
+ /// — the spinner never appears and IsRefreshing = true is
+ /// not written. Matches MAUI's stock Android behaviour of disabling
+ /// the underlying SwipeRefreshLayout.
+ ///
+ public static void MapIsRefreshEnabled(RefreshViewHandler handler, IRefreshView view) =>
+ handler._isEnabled.Value = view.IsRefreshEnabled;
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs
index bb031cf8..59445f61 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs
@@ -33,6 +33,14 @@ public partial class ScrollViewHandler : ComposeElementHandler
new PropertyMapper(ViewHandler.ViewMapper)
{
[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.
};
/// Command mapper (inherits view-level commands; no extras).
@@ -40,6 +48,10 @@ public partial class ScrollViewHandler : ComposeElementHandler
new(ViewCommandMapper);
readonly MutableState _orientation = new((int)ScrollOrientation.Vertical);
+ // Content swaps don't trigger a property changed on Orientation —
+ // bumping a version slot recomposes BuildNode which re-walks the
+ // new PresentedContent.
+ readonly MutableState _contentVersion = new(0);
/// Construct a handler with the default mappers.
public ScrollViewHandler() : base(Mapper, CommandMapper) { }
@@ -56,13 +68,23 @@ public override ComposableNode BuildNode(IComposer composer)
var context = MauiContext
?? throw new InvalidOperationException("MauiContext not set on ScrollViewHandler.");
- return new ScrollContainer(this, scroll, _orientation, context);
+ return new ScrollContainer(this, scroll, _orientation, _contentVersion, context);
}
/// Map to the cached enum slot.
public static void MapOrientation(ScrollViewHandler handler, IScrollView view) =>
handler._orientation.Value = (int)view.Orientation;
+ ///
+ /// Bump the content version slot when
+ /// swaps so the child walker
+ /// re-renders against the new tree. The new
+ /// is read live inside
+ /// ScrollContainer.Render.
+ ///
+ public static void MapContent(ScrollViewHandler handler, IScrollView _) =>
+ handler._contentVersion.Value++;
+
///
/// implementing the
/// scroll-wrapped . Pulled out so the
@@ -76,17 +98,20 @@ sealed class ScrollContainer : ComposableNode
readonly ScrollViewHandler _owner;
readonly IScrollView _scroll;
readonly MutableState _orientation;
+ readonly MutableState _contentVersion;
readonly IMauiContext _context;
public ScrollContainer(
ScrollViewHandler owner,
IScrollView scroll,
MutableState orientation,
+ MutableState contentVersion,
IMauiContext context)
{
_owner = owner;
_scroll = scroll;
_orientation = orientation;
+ _contentVersion = contentVersion;
_context = context;
}
@@ -99,6 +124,9 @@ public override void Render(IComposer composer)
_owner.SubscribeToViewProperties();
var orientation = (ScrollOrientation)_orientation.Value;
+ // Read the content version so swapping ScrollView.Content
+ // re-runs the walker. Live PresentedContent is read below.
+ _ = _contentVersion.Value;
var state = composer.Remember(() => new ScrollState());
var fillMain = orientation switch
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs
index 0ef53e50..a6edc254 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs
@@ -1,5 +1,7 @@
using AndroidX.Compose;
using AndroidX.Compose.Runtime;
+using AndroidX.Compose.UI.Text;
+using AndroidX.Compose.UI.Text.Input;
using Microsoft.AndroidX.Compose.Maui.Platform;
using Microsoft.Maui.Handlers;
using ComposeColor = AndroidX.Compose.Color;
@@ -8,6 +10,7 @@
using ComposeKeyboardType = AndroidX.Compose.KeyboardType;
using ComposeOutlinedTextField = AndroidX.Compose.OutlinedTextField;
using ComposeText = AndroidX.Compose.Text;
+using ComposeTextAlign = AndroidX.Compose.TextAlign;
using ComposeTextStyle = AndroidX.Compose.TextStyle;
namespace Microsoft.AndroidX.Compose.Maui.Handlers;
@@ -31,6 +34,13 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers;
/// styled text input with a search-icon leading slot, so we render
/// the simpler OutlinedTextField shape and wire the search
/// action through keyboardActions.onSearch.
+///
+/// Like , value/cursor/selection state
+/// is bound through Compose's
+/// OutlinedTextField(MutableState<TextFieldValue>)
+/// overload via a custom subclass that
+/// enforces and mirrors text +
+/// caret back to MAUI's .
///
public partial class SearchBarHandler : ComposeElementHandler
{
@@ -42,34 +52,61 @@ public partial class SearchBarHandler : ComposeElementHandler
public static IPropertyMapper Mapper =
new PropertyMapper(ViewHandler.ViewMapper)
{
- [nameof(IText.Text)] = MapText,
- [nameof(ITextStyle.TextColor)] = MapTextColor,
- [nameof(ITextStyle.Font)] = MapFont,
- [nameof(IPlaceholder.Placeholder)] = MapPlaceholder,
- [nameof(ITextInput.Keyboard)] = MapKeyboard,
- [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly,
- [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
+ [nameof(IText.Text)] = MapText,
+ [nameof(ITextStyle.TextColor)] = MapTextColor,
+ [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
+ [nameof(ITextStyle.Font)] = MapFont,
+ [nameof(IPlaceholder.Placeholder)] = MapPlaceholder,
+ [nameof(IPlaceholder.PlaceholderColor)] = MapPlaceholderColor,
+ [nameof(ISearchBar.SearchIconColor)] = MapSearchIconColor,
+ [nameof(ISearchBar.CancelButtonColor)] = MapCancelButtonColor,
+ [nameof(ISearchBar.ReturnType)] = MapReturnType,
+ [nameof(ITextInput.Keyboard)] = MapKeyboard,
+ [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly,
+ [nameof(ITextInput.IsSpellCheckEnabled)] = MapAutoCorrect,
+ [nameof(ITextInput.IsTextPredictionEnabled)] = MapAutoCorrect,
+ [nameof(ITextInput.MaxLength)] = MapMaxLength,
+ [nameof(ITextInput.CursorPosition)] = MapCursorPosition,
+ [nameof(ITextInput.SelectionLength)] = MapSelectionLength,
+ [nameof(ITextAlignment.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
+ [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
+ // TODO: VerticalTextAlignment — see EditorHandler; Box facade
+ // doesn't expose contentAlignment.
};
/// Command mapper (inherits view-level commands; no extras).
public static CommandMapper CommandMapper =
new(ViewCommandMapper);
- readonly MutableState _text = new(string.Empty);
- readonly MutableState _color = new((long?)null);
- readonly MutableState _fontSize = new((int?)null);
- readonly MutableState _bold = new(false);
- readonly MutableState _placeholder = new(string.Empty);
- readonly MutableState _keyboardType = new(ComposeKeyboardType.Text);
- readonly MutableState _readOnly = new(false);
- readonly MutableState _fillWidth = new(false);
+ readonly TextFieldValueState _tfv;
+ readonly MutableState _color = new((long?)null);
+ readonly MutableState _placeholderColor = new((long?)null);
+ readonly MutableState _searchIconColor = new((long?)null);
+ readonly MutableState _cancelButtonColor = new((long?)null);
+ readonly MutableState _fontSize = new((int?)null);
+ readonly MutableState _bold = new(false);
+ readonly MutableState _placeholder = new(string.Empty);
+ readonly MutableState _keyboardType = new(ComposeKeyboardType.Text);
+ readonly MutableState _readOnly = new(false);
+ readonly MutableState _autoCorrect = new(true);
+ readonly MutableState _imeAction = new(ComposeImeAction.Search);
+ readonly MutableState _maxLength = new(-1);
+ readonly MutableState _letterSpacing = new((float?)null);
+ readonly MutableState _hTextAlign = new((int)TextAlignment.Start);
+ readonly MutableState _fillWidth = new(false);
/// Construct a handler with the default mappers.
- public SearchBarHandler() : base(Mapper, CommandMapper) { }
+ public SearchBarHandler() : base(Mapper, CommandMapper)
+ {
+ _tfv = new TextFieldValueState(this);
+ }
/// Construct a handler with custom mappers.
public SearchBarHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null)
- : base(mapper ?? Mapper, commandMapper ?? CommandMapper) { }
+ : base(mapper ?? Mapper, commandMapper ?? CommandMapper)
+ {
+ _tfv = new TextFieldValueState(this);
+ }
///
public override ComposableNode BuildNode(IComposer composer)
@@ -77,44 +114,88 @@ public override ComposableNode BuildNode(IComposer composer)
var virtualView = VirtualView
?? throw new InvalidOperationException("VirtualView not set on SearchBarHandler.");
- var packed = _color.Value;
- var size = _fontSize.Value;
- var bold = _bold.Value;
- var placeholder = _placeholder.Value;
- var keyboardType = _keyboardType.Value;
- var readOnly = _readOnly.Value;
- var fill = _fillWidth.Value;
+ var packed = _color.Value;
+ var placeholderInk = _placeholderColor.Value;
+ var searchIconInk = _searchIconColor.Value;
+ var cancelButtonInk = _cancelButtonColor.Value;
+ var size = _fontSize.Value;
+ var bold = _bold.Value;
+ var placeholder = _placeholder.Value;
+ var keyboardType = _keyboardType.Value;
+ var readOnly = _readOnly.Value;
+ var autoCorrect = _autoCorrect.Value;
+ var imeAction = _imeAction.Value;
+ var letterSpacing = _letterSpacing.Value;
+ var hTextAlign = (TextAlignment)_hTextAlign.Value;
+ var fill = _fillWidth.Value;
- var field = new ComposeOutlinedTextField(_text.Value, OnValueChanged)
+ var field = new ComposeOutlinedTextField(_tfv)
{
ReadOnly = readOnly,
SingleLine = true,
// Magnifier glass — pure Unicode keeps us off the
- // material-icons NuGet for the MAUI base layer.
- LeadingIcon = new ComposeText("\U0001F50D"),
+ // material-icons NuGet for the MAUI base layer. Coloured
+ // via SearchIconColor when set.
+ LeadingIcon = new ComposeText("\U0001F50D")
+ {
+ Color = searchIconInk.HasValue ? new ComposeColor(searchIconInk.Value) : null,
+ },
};
if (!string.IsNullOrEmpty(placeholder))
- field.Placeholder = new ComposeText(placeholder);
- if (packed.HasValue || size.HasValue || bold)
+ field.Placeholder = new ComposeText(placeholder)
+ {
+ Color = placeholderInk.HasValue ? new ComposeColor(placeholderInk.Value) : null,
+ };
+ if (packed.HasValue || size.HasValue || bold || letterSpacing.HasValue
+ || hTextAlign != TextAlignment.Start)
field.TextStyle = new ComposeTextStyle
{
- Color = packed.HasValue ? new ComposeColor(packed.Value) : null,
- FontSize = size.HasValue ? new Sp(size.Value) : null,
- FontWeight = bold ? ComposeFontWeight.Bold : null,
+ Color = packed.HasValue ? new ComposeColor(packed.Value) : null,
+ FontSize = size.HasValue ? new Sp(size.Value) : null,
+ FontWeight = bold ? ComposeFontWeight.Bold : null,
+ LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null,
+ TextAlign = hTextAlign switch
+ {
+ TextAlignment.Center => ComposeTextAlign.Center,
+ TextAlignment.End => ComposeTextAlign.End,
+ _ => null,
+ },
};
+ // Cancel-X trailing IconButton, visible only when the field has
+ // content — tapping clears the buffer. CancelButtonColor tints
+ // the X glyph when set.
+ if (!string.IsNullOrEmpty(_tfv.Value?.Text))
+ {
+ field.TrailingIcon = new IconButton(onClick: () =>
+ {
+ if (VirtualView is { } searchBar)
+ searchBar.Text = string.Empty;
+ })
+ {
+ new ComposeText("\u2715")
+ {
+ Color = cancelButtonInk.HasValue ? new ComposeColor(cancelButtonInk.Value) : null,
+ },
+ };
+ }
+
// KeyboardOptions: route through KeyboardOptionsCompanion.Default.Copy
- // so we override imeAction without disturbing other slots.
+ // so we override imeAction (typically Search) without disturbing
+ // other slots — same pattern as EntryHandler.
var d = KeyboardOptionsCompanion.Default;
field.KeyboardOptions = d.Copy(
- d.Capitalization, d.AutoCorrectEnabled,
- keyboardType, ComposeImeAction.Search,
+ d.Capitalization, (Java.Lang.Boolean)autoCorrect,
+ keyboardType, imeAction,
d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales);
// Wire the search-key callback. SearchButtonPressed() is
// MAUI's standard trampoline — it fires SearchCommand and the
// SearchButtonPressed event the way the stock handler does.
field.KeyboardActions = KeyboardActionsHelper.Create(
- onSearch: OnSearchInvoked);
+ onSearch: OnSearchInvoked,
+ onDone: OnSearchInvoked,
+ onGo: OnSearchInvoked,
+ onSend: OnSearchInvoked);
var modifier = (fill ? Modifier.FillMaxWidth() : Modifier.Companion)
.ApplyGestures(virtualView, MauiContext)
@@ -123,15 +204,6 @@ public override ComposableNode BuildNode(IComposer composer)
return field;
}
- void OnValueChanged(string newValue)
- {
- // Mirror EntryHandler's two-way pattern (issue: feedback-loop
- // guard via MutableState equality).
- _text.Value = newValue;
- if (VirtualView is { } searchBar)
- searchBar.Text = newValue;
- }
-
void OnSearchInvoked()
{
// SearchButtonPressed is the public ISearchBar trampoline:
@@ -141,13 +213,27 @@ void OnSearchInvoked()
}
/// Map to the Compose value slot.
- public static void MapText(SearchBarHandler handler, ISearchBar searchBar) =>
- handler._text.Value = searchBar.Text ?? string.Empty;
+ public static void MapText(SearchBarHandler handler, ISearchBar searchBar)
+ {
+ var newText = searchBar.Text ?? string.Empty;
+ var current = handler._tfv.Value;
+ if (current?.Text == newText) return;
+ var cursor = Math.Min(searchBar.CursorPosition, newText.Length);
+ if (cursor < 0) cursor = newText.Length;
+ handler._tfv.SetWithoutMirror(ComposeExtensions.NewTextFieldValue(
+ newText, TextRangeKt.TextRange(cursor), composition: null));
+ }
/// Map to the Compose TextStyle.Color slot.
public static void MapTextColor(SearchBarHandler handler, ISearchBar searchBar) =>
handler._color.Value = ColorMapping.ToPackedLong(searchBar.TextColor);
+ /// Map to Compose letterSpacing.
+ public static void MapCharacterSpacing(SearchBarHandler handler, ISearchBar searchBar) =>
+ handler._letterSpacing.Value = searchBar.CharacterSpacing != 0
+ ? (float)searchBar.CharacterSpacing
+ : null;
+
/// Map (size + bold) to Compose TextStyle slots.
public static void MapFont(SearchBarHandler handler, ISearchBar searchBar)
{
@@ -161,6 +247,32 @@ public static void MapFont(SearchBarHandler handler, ISearchBar searchBar)
public static void MapPlaceholder(SearchBarHandler handler, ISearchBar searchBar) =>
handler._placeholder.Value = searchBar.Placeholder ?? string.Empty;
+ /// Map to the placeholder Text's color.
+ public static void MapPlaceholderColor(SearchBarHandler handler, ISearchBar searchBar) =>
+ handler._placeholderColor.Value = ColorMapping.ToPackedLong(searchBar.PlaceholderColor);
+
+ /// Map to the leading magnifier's color.
+ public static void MapSearchIconColor(SearchBarHandler handler, ISearchBar searchBar) =>
+ handler._searchIconColor.Value = ColorMapping.ToPackedLong(searchBar.SearchIconColor);
+
+ /// Map to the trailing X's color.
+ public static void MapCancelButtonColor(SearchBarHandler handler, ISearchBar searchBar) =>
+ handler._cancelButtonColor.Value = ColorMapping.ToPackedLong(searchBar.CancelButtonColor);
+
+ /// Map to KeyboardOptions.imeAction.
+ public static void MapReturnType(SearchBarHandler handler, ISearchBar searchBar) =>
+ handler._imeAction.Value = searchBar.ReturnType switch
+ {
+ ReturnType.Done => ComposeImeAction.Done,
+ ReturnType.Go => ComposeImeAction.Go,
+ ReturnType.Next => ComposeImeAction.Next,
+ ReturnType.Send => ComposeImeAction.Send,
+ // Default for SearchBar is the magnifier (Search) — even
+ // for ReturnType.Default — so the IME button matches the
+ // leading icon.
+ _ => ComposeImeAction.Search,
+ };
+
/// Map to a Compose KeyboardType int.
public static void MapKeyboard(SearchBarHandler handler, ISearchBar searchBar) =>
handler._keyboardType.Value = KeyboardMapping.Resolve(searchBar.Keyboard, nameof(SearchBarHandler));
@@ -169,6 +281,52 @@ public static void MapKeyboard(SearchBarHandler handler, ISearchBar searchBar) =
public static void MapIsReadOnly(SearchBarHandler handler, ISearchBar searchBar) =>
handler._readOnly.Value = searchBar.IsReadOnly;
+ ///
+ /// Combined map for +
+ /// . Compose has a
+ /// single autoCorrectEnabled slot; we AND the MAUI flags.
+ ///
+ public static void MapAutoCorrect(SearchBarHandler handler, ISearchBar searchBar) =>
+ handler._autoCorrect.Value = searchBar.IsSpellCheckEnabled && searchBar.IsTextPredictionEnabled;
+
+ /// Map to the truncation cap (negative = unlimited).
+ public static void MapMaxLength(SearchBarHandler handler, ISearchBar searchBar)
+ {
+ handler._maxLength.Value = searchBar.MaxLength;
+ if (searchBar.MaxLength >= 0
+ && handler._tfv.Value is { } current
+ && (current.Text?.Length ?? 0) > searchBar.MaxLength)
+ {
+ handler._tfv.Value = current;
+ }
+ }
+
+ /// Map to the TFV selection start.
+ public static void MapCursorPosition(SearchBarHandler handler, ISearchBar searchBar) =>
+ ApplySelection(handler, searchBar);
+
+ /// Map to the TFV selection end.
+ public static void MapSelectionLength(SearchBarHandler handler, ISearchBar searchBar) =>
+ ApplySelection(handler, searchBar);
+
+ static void ApplySelection(SearchBarHandler handler, ISearchBar searchBar)
+ {
+ var current = handler._tfv.Value;
+ if (current is null) return;
+ var text = current.Text ?? string.Empty;
+ var start = Math.Clamp(searchBar.CursorPosition, 0, text.Length);
+ var length = Math.Max(0, Math.Min(searchBar.SelectionLength, text.Length - start));
+ var end = start + length;
+ var existing = current.Selection;
+ if ((int)existing == start && (int)(existing >> 32) == end)
+ return;
+ handler._tfv.SetWithoutMirror(current.Copy(text, TextRangeKt.TextRange(start, end), composition: null));
+ }
+
+ /// Map to Compose textAlign.
+ public static void MapHorizontalTextAlignment(SearchBarHandler handler, ISearchBar searchBar) =>
+ handler._hTextAlign.Value = (int)searchBar.HorizontalTextAlignment;
+
///
/// Map to
/// Modifier.fillMaxWidth() when the search bar asks to
@@ -177,4 +335,63 @@ public static void MapIsReadOnly(SearchBarHandler handler, ISearchBar searchBar)
public static void MapHorizontalLayoutAlignment(SearchBarHandler handler, ISearchBar searchBar) =>
handler._fillWidth.Value = searchBar.HorizontalLayoutAlignment
== Microsoft.Maui.Primitives.LayoutAlignment.Fill;
+
+ ///
+ /// of
+ /// that intercepts every write so we can enforce
+ /// and mirror text + caret back
+ /// to MAUI's . Mirrors the
+ /// pattern.
+ ///
+ sealed class TextFieldValueState : MutableState
+ {
+ readonly SearchBarHandler _owner;
+ bool _suppressMirror;
+
+ public TextFieldValueState(SearchBarHandler owner)
+ : base(ComposeExtensions.NewTextFieldValue())
+ {
+ _owner = owner;
+ }
+
+ public void SetWithoutMirror(TextFieldValue value)
+ {
+ _suppressMirror = true;
+ try { Value = value; }
+ finally { _suppressMirror = false; }
+ }
+
+ public override TextFieldValue Value
+ {
+ get => base.Value;
+ set
+ {
+ var max = _owner._maxLength.Value;
+ if (max >= 0 && value?.Text is { } text && text.Length > max)
+ {
+ var trunc = text[..max];
+ var sel = value.Selection;
+ var start = Math.Min((int)sel, max);
+ var end = Math.Min((int)(sel >> 32), max);
+ value = ComposeExtensions.NewTextFieldValue(
+ trunc, TextRangeKt.TextRange(start, end), composition: null);
+ }
+ base.Value = value
+ ?? throw new InvalidOperationException(
+ "TextFieldValue cannot be null on SearchBarHandler.TextFieldValueState.");
+ if (!_suppressMirror && _owner.VirtualView is { } searchBar && value is not null)
+ {
+ var newText = value.Text ?? string.Empty;
+ if (searchBar.Text != newText)
+ searchBar.Text = newText;
+ var caret = (int)value.Selection;
+ var selLen = (int)(value.Selection >> 32) - caret;
+ if (searchBar.CursorPosition != caret)
+ searchBar.CursorPosition = caret;
+ if (searchBar.SelectionLength != selLen)
+ searchBar.SelectionLength = selLen;
+ }
+ }
+ }
+ }
}
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs
index 9386ca0d..ae24de4d 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs
@@ -54,6 +54,11 @@ 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.
};
/// Command mapper (inherits view-level commands; no extras).
diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs
index 62b74bc9..0fe29539 100644
--- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs
+++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs
@@ -53,8 +53,10 @@ public partial class TimePickerHandler : ComposeElementHandler
{
[nameof(ITimePicker.Time)] = MapTime,
[nameof(ITimePicker.Format)] = MapFormat,
+ [nameof(ITimePicker.IsOpen)] = MapIsOpen,
[nameof(ITextStyle.TextColor)] = MapTextColor,
[nameof(ITextStyle.Font)] = MapFont,
+ [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing,
[nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
};
@@ -67,6 +69,7 @@ public partial class TimePickerHandler : ComposeElementHandler
readonly MutableState _textColor = new((long?)null);
readonly MutableState _fontSize = new((int?)null);
readonly MutableState _bold = new(false);
+ readonly MutableState _letterSpacing = new((float?)null);
readonly MutableState _open = new(false);
readonly MutableState _fillWidth = new(false);
@@ -90,6 +93,7 @@ public override ComposableNode BuildNode(IComposer composer)
var packed = _textColor.Value;
var size = _fontSize.Value;
var bold = _bold.Value;
+ var spacing = _letterSpacing.Value;
var fill = _fillWidth.Value;
var isOpen = _open.Value;
@@ -116,9 +120,9 @@ public override ComposableNode BuildNode(IComposer composer)
ticks,
is24Hour);
- var trigger = new ComposeOutlinedButton(onClick: () => _open.Value = true)
+ var trigger = new ComposeOutlinedButton(onClick: () => SetOpen(virtualView, true))
{
- BuildLabel(label, packed, size, bold),
+ BuildLabel(label, packed, size, bold, spacing),
};
// Combines the layout-fill (when set) with the cross-cutting view
// properties (Opacity, Translation, Scale, Rotation, IsVisible,
@@ -131,7 +135,7 @@ public override ComposableNode BuildNode(IComposer composer)
trigger.PrependModifier(outer);
var dialog = isOpen
- ? new ComposeTimePickerDialog(onDismissRequest: () => _open.Value = false)
+ ? new ComposeTimePickerDialog(onDismissRequest: () => SetOpen(virtualView, false))
{
Title = new ComposeText("Pick a time"),
ConfirmButton = new TextButton(onClick: () =>
@@ -140,10 +144,10 @@ public override ComposableNode BuildNode(IComposer composer)
_ticks.Value = picked.Ticks;
if (VirtualView is { } tp)
tp.Time = picked;
- _open.Value = false;
+ SetOpen(virtualView, false);
})
{ new ComposeText("OK") },
- DismissButton = new TextButton(onClick: () => _open.Value = false)
+ DismissButton = new TextButton(onClick: () => SetOpen(virtualView, false))
{
new ComposeText("Cancel"),
},
@@ -159,7 +163,17 @@ public override ComposableNode BuildNode(IComposer composer)
});
}
- static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold)
+ ///
+ /// Toggle the dialog and propagate the new state back to MAUI's
+ /// two-way binding.
+ ///
+ void SetOpen(ITimePicker tp, bool isOpen)
+ {
+ _open.Value = isOpen;
+ tp.IsOpen = isOpen;
+ }
+
+ static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold, float? letterSpacing)
{
var node = new ComposeText(text);
if (packed.HasValue)
@@ -168,6 +182,8 @@ static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold
node.FontSize = new Sp(size.Value);
if (bold)
node.FontWeight = ComposeFontWeight.Bold;
+ if (letterSpacing.HasValue)
+ node.LetterSpacing = new Sp(1) * letterSpacing.Value;
return node;
}
@@ -195,6 +211,21 @@ public static void MapFont(TimePickerHandler handler, ITimePicker tp)
== Microsoft.Maui.FontWeight.Bold;
}
+ ///
+ /// Map to the Compose
+ /// letterSpacing slot in .
+ ///
+ public static void MapCharacterSpacing(TimePickerHandler handler, ITimePicker tp) =>
+ handler._letterSpacing.Value = tp.CharacterSpacing != 0 ? (float)tp.CharacterSpacing : null;
+
+ ///
+ /// Map to the Compose dialog's
+ /// open slot — external pushes pop / dismiss the dialog without a
+ /// trigger tap.
+ ///
+ public static void MapIsOpen(TimePickerHandler handler, ITimePicker tp) =>
+ handler._open.Value = tp.IsOpen;
+
///
/// Map to
/// Modifier.fillMaxWidth() when the picker asks to fill its