From 55d67864446a5961aaec49b8a22fbaa5f3e29e35 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 12 Jun 2026 18:07:09 -0500 Subject: [PATCH 1/2] MAUI Phase 3 Slice 1: CollectionViewHandler (LazyColumn / LazyRow / LazyVerticalGrid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds `Microsoft.Maui.Controls.CollectionView` into the enclosing page composition as a Compose `LazyColumn` / `LazyRow` / `LazyVerticalGrid` chosen by `ItemsLayout`, instead of the stock per-cell `ComposeView` islands. One Recomposer, one snapshot graph, one `MaterialTheme` scope for the whole list. Investigation finding: `CollectionView` renders correctly via the `AndroidView` fallback (the home page itself proves it), but each Compose-folded cell spawns its own `ComposeView`. A managed handler is worth the cost because it removes those islands and sets the pattern for `CarouselViewHandler` (Slice 2). Shipped this slice: * Layout dispatch: `LinearItemsLayout.Vertical` → `LazyColumn`, `Horizontal` → `LazyRow`, `GridItemsLayout.Vertical` → `LazyVerticalGrid(GridCells.Fixed(Span))`. Horizontal grid degrades to `LazyRow` (follow-up). * `ItemsSource` snapshotting + live `INotifyCollectionChanged` subscription with a `MutableState` version slot. Mapper bumps the slot for `ItemsSource` / `ItemTemplate` / `ItemsLayout` / `EmptyView` / `SelectionMode` / `WidthRequest` / `HeightRequest`. * `ItemTemplate` + `ItemTemplateSelector`. Each item gets a fresh `BindableObject` (cost documented; memoisation is follow-up). * `EmptyView` (string, IView, or `EmptyViewTemplate`). * `ItemSpacing` / `VerticalItemSpacing` / `HorizontalItemSpacing` → `Arrangement.SpacedBy` via new public properties on `LazyColumn`, `LazyRow`, `LazyVerticalGrid`. * Single + Multiple selection: per-row `Modifier.Clickable {}` writes back to `view.SelectedItem` / `view.SelectedItems`. MAUI fires `SelectionChanged` + `SelectionChangedCommand` from the bindable setter — no manual event raise. * `WidthRequest` / `HeightRequest` honored via a `BoxViewHandler`-style size switch. Fixes the `IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints` crash when hosting a `CollectionView` inside a `ScrollView`. * Sample `CollectionsPage` exercises all three layouts + `ObservableCollection` Add/Remove/Clear + empty-view toggle. * `docs/maui-backend.md` Phase 3 Slice 1 subsection with Goal / Delivered / Deferred / Lessons learned. Explicitly deferred to follow-up slices: * `ListView` (deprecated; defer until customer asks). * `TableView` (rare in modern MAUI apps). * `CarouselView` + two-way `Position` binding (own slice — needs `PagerState` Phase-4b state-holder). * `SwipeView` (`SwipeToDismissBox` doesn't match the left/right action-panel shape; needs more bridge work). * Selected-row highlight styling. * `ScrollTo` / `Scrolled` event. * `ItemsUpdatingScrollMode`, `RemainingItemsThreshold`, `Header` / `Footer` / `ItemSizingStrategy`, grouping, per-item handler caching. Verification: all 5 builds 0/0 (generator tests 155/155, facade, MAUI, sample install, gallery). On-device on Pixel `0A041FDD400327`: home navigation works (Selection regression fixed), CollectionsPage renders vertical list + horizontal chips + grid + empty view with no crash, `ObservableCollection` mutations and empty-view toggle round-trip correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/maui-backend.md | 175 ++++++ .../AppShell.xaml.cs | 1 + .../HomePage.xaml.cs | 5 + .../Pages/CollectionsPage.xaml | 153 +++++ .../Pages/CollectionsPage.xaml.cs | 78 +++ .../Handlers/CollectionViewHandler.cs | 567 ++++++++++++++++++ .../Hosting/AppHostBuilderExtensions.cs | 20 + src/Microsoft.AndroidX.Compose/LazyColumn.cs | 25 +- .../LazyVerticalGrid.cs | 47 +- .../PublicAPI.Unshipped.txt | 6 + 10 files changed, 1074 insertions(+), 3 deletions(-) create mode 100644 src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/CollectionsPage.xaml create mode 100644 src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/CollectionsPage.xaml.cs create mode 100644 src/Microsoft.AndroidX.Compose.Maui/Handlers/CollectionViewHandler.cs diff --git a/docs/maui-backend.md b/docs/maui-backend.md index 39c3fd97..1e9f2c71 100644 --- a/docs/maui-backend.md +++ b/docs/maui-backend.md @@ -1873,6 +1873,181 @@ handler and the auto-default-mask machinery clears their rather than the previous `2000-01-01` default; otherwise MAUI's range validator silently rewrote the value back inside bounds. +#### Phase 3 Slice 1 — `CollectionViewHandler` ✅ shipped + +Opens Phase 3 with the most-requested handler — `CollectionView` folded +into the page composition as a Compose lazy list rather than the stock +per-cell `ComposeView` islands. Wraps the existing `LazyColumn` / +`LazyRow` / `LazyVerticalGrid` facades with an `ItemsLayout` → +facade dispatch. + +**Investigation finding (worth documenting up front).** +`CollectionView` *does* already render via the `AndroidView` fallback — +`HomePage`'s own catalog list relies on that path. `uiautomator dump` +showed each row carrying its own `androidx.compose.ui.platform.ComposeView` +node because the cells contain Compose-folded leaves; the page therefore +ends up with `n + 1` Composer roots (page + one per visible row), each +re-installing `MaterialTheme` and its own snapshot graph. The handler +trades that for **one** composer per page — the page's — and inherits +theme + snapshot state directly. + +**Delivered.** + +- `MauiCollectionView` → `CollectionViewHandler`: + - `LinearItemsLayout` (vertical / horizontal) → `LazyColumn` / + `LazyRow`. `ItemSpacing` lowers to `Arrangement.SpacedBy(dp)` + on the matching axis. + - `GridItemsLayout` → `LazyVerticalGrid` with + `GridCells.Fixed(Span)`. `VerticalItemSpacing` / + `HorizontalItemSpacing` lower to the new `VerticalArrangement` / + `HorizontalArrangement` facade props on `LazyVerticalGrid`. + Horizontal `GridItemsLayout` (rare in practice) falls back to + `LazyRow` for v1 — a future `LazyHorizontalGrid` wrapper would + unlock that without changing the handler. + - `ItemTemplate` / `ItemTemplateSelector`: per-item + `template.CreateContent()` with `BindingContext = item`, then + `ComposeWalker.Render(view, …)` wrapped in a private + `DeferredViewNode`. The wrapper exists because lazy-list item + realisation happens at measure time (inside `SubcomposeLayout`), + not composition time, so the `ComposableLambdas.Instantiate4` + factory path that `LazyColumn` already uses is the only safe + way to surface a live composer to the item; `DeferredViewNode` + defers the actual walk until that lambda fires. + - `EmptyView` (string / `IView` / `EmptyViewTemplate`) renders as a + centered `Column` with `verticalArrangement: Arrangement.Center` + + `horizontalAlignment: CenterHorizontally`. (Compose's `Box` facade + is parameterless — no `contentAlignment` ctor — so a `Column` was + the simplest single-axis centering primitive.) + - Per-item `Modifier.Clickable {}` wires + `SelectionMode = Single` / `Multiple` straight through to + `view.SelectedItem` / `view.SelectedItems` — `SelectionChanged` + + `SelectionChangedCommand` fire from MAUI's + `BindableProperty` setter. The wrapper is suppressed when + `SelectionMode = None` so non-selectable lists don't pay the + extra `Box` layer. Selected-row highlight styling is still + follow-up. + - `INotifyCollectionChanged`: handler subscribes to the live source + in `MapItemsSource` and unsubscribes on swap / `DisconnectHandler`. + Add / Remove / Replace / Move / Reset all collapse to a single + `MutableState _itemsVersion` bump that causes `BuildNode` to + re-snapshot the source and rebuild. + - `ItemsLayout` is read live inside `BuildNode` (it's a + `BindableObject`; consumers can swap orientation or span at + runtime). A second `MutableState` slot bumps on layout change. +- `LazyColumn` gained `VerticalArrangement` (for `ItemSpacing` on + vertical linear lists). +- `LazyVerticalGrid` gained `VerticalArrangement` + + `HorizontalArrangement` (for the two grid spacings). Both new + arrangement props validate axis at assignment time and throw + `ArgumentException` for cross-axis values + (e.g. `Arrangement.Start` on the vertical axis). +- `CollectionsPage` sample drives three `CollectionView`s off one + `ObservableCollection`: vertical list, horizontal chips + (`ItemSpacing = 12`), grid (`Span = 3`, + `VerticalItemSpacing = HorizontalItemSpacing = 8`). Add / Remove / + Clear buttons exercise `INotifyCollectionChanged`; a fourth + CollectionView demos the EmptyView toggle. + +**Deferred (explicit list — pick up in follow-up slices).** + +- Selected-row highlight styling and `SelectionMode.Multiple` UI + affordances (checkmark, ripple emphasis). The handler already + wires the data path for Single + Multiple selection, but the + visual state is a follow-up. +- `ScrollTo(int)` / `ScrollTo(item)` / `Scrolled` event + (`LazyListState.AnimateScrollToItemAsync` already exists; wiring + MAUI's `ScrollToRequested` is mechanical). +- `ItemsUpdatingScrollMode` (`KeepItemsInView` / + `KeepScrollOffset` / `KeepLastItemInView`) — needs index-stability + tracking on `CollectionChanged`. +- `RemainingItemsThreshold` / endless-scroll. +- Grouping (`IsGrouped` / `GroupHeaderTemplate` / + `GroupFooterTemplate`). +- `ListView` (deprecated; defer until a clear ask). +- `TableView` (rare in modern MAUI). +- `CarouselView` two-way `Position` ↔ `IndicatorView.Position` + (separate slice — needs `PagerState` Phase-4b state-holder with a + parameterised `pageCount` Remember). +- `SwipeView` (`SwipeToDismissBox` doesn't match SwipeView's + left/right action panels; needs more bridge work). + +**Lessons learned.** + +- **The `Wrap*` vs `Instantiate4` distinction is exactly the trap the + repo's instructions call out.** Lazy-list item content runs at + measure time inside `SubcomposeLayout`, *outside* the composer that + built the list. The existing `LazyColumn` / `LazyRow` / + `LazyVerticalGrid` facades already use + `ComposableLambdas.Instantiate4` (the composer-less factory) for + exactly this reason; the handler just contributes a + `Func` that fires inside that lambda. Building + the per-item node with `Wrap4(composer, …)` instead would crash with + `Expected applyChanges() to have been called`. +- **`Box`'s facade is parameterless.** The Kotlin + `Box(contentAlignment:)` overload is mangled (lowered through + `Alignment` value-class lowering) and the C# facade therefore has + no `contentAlignment` ctor. Centering inside a Box requires + `Modifier.Align(Alignment.Center)` on the child — which needs + `BoxScope`, which isn't surfaced cleanly. The simpler workaround is + a `Column` with `verticalArrangement: Center` + + `horizontalAlignment: CenterHorizontally`; that fits the empty-view + shape (single child or text) with no facade churn. +- **`AndroidX.Compose.Text` collides with `Microsoft.AndroidX.Compose.Text` + inside the handler namespace.** Inside + `Microsoft.AndroidX.Compose.Maui.Handlers`, bare + `AndroidX.Compose.Text` resolves to the (non-existent) + `Microsoft.AndroidX.Compose.Text` because of C#'s "innermost + namespace wins" rule. Workaround: `using ComposeText = + AndroidX.Compose.Text;` alias at the top of the file. +- **`MutableState` doesn't accept arbitrary structs.** The + underlying Compose `MutableState` only round-trips `Java.Lang.Object` + subclasses, primitives, strings, and `Nullable`. The + established workaround on Phase 2 handlers (`SliderHandler`, + `LayoutHandler`, etc.) is the **version-counter pattern**: declare + a `MutableState` slot, bump it whenever the live MAUI value + changes, and read the actual value off `VirtualView` inside + `BuildNode`. This slice reuses that pattern for both + `ItemsSource`/`CollectionChanged` and `ItemsLayout` changes. +- **`DataTemplateSelector.SelectTemplate(item, container)` works with + `container: null`.** A few MAUI built-in selectors actually look at + `container`; for those, the handler still passes `view` so they get + the source CollectionView itself. +- **Per-item handler allocation cost is real, but acceptable for v1.** + Each `template.CreateContent()` allocates a fresh `BindableObject` + per render of `BuildNode`. Compose's slot table memoises the + *rendered* output but not the View / Handler. Memoising keyed on + item identity + template type is straightforward follow-up; defer + until profiling shows it matters. +- **Investigation discipline matters more than ever at Phase 3 scope.** + Three of the original Phase 3 candidates (`ListView`, `TableView`, + `SwipeView`) are deferred outright, and one (`CarouselView`) is its + own slice. Shipping `CollectionViewHandler` alone is ~370 LOC of + handler + facade-prop additions; bundling the rest would have + produced an unreviewable PR. +- **Globally registering `CollectionViewHandler` regresses any app + that depends on `SelectionChanged` for navigation.** The sample's + own `HomePage.xaml` uses `CollectionView` + `SelectionMode="Single"` + + `SelectionChanged="OnDemoSelected"` for the demo nav list. The + first cut of this slice deferred selection — that broke navigation + the moment `UseAndroidXCompose` registered the handler globally. + Minimal Single/Multiple selection (per-row `Modifier.Clickable {}` + → `view.SelectedItem = item` / `view.SelectedItems.Add/Remove`) is + therefore **mandatory, not optional**; only the selected-row + highlight styling can defer. +- **`LazyColumn` inside a vertical `ScrollView` requires an explicit + height.** Compose's lazy lists check max-height constraints in + `CheckScrollableContainerConstraintsKt` and throw + `IllegalStateException: Vertically scrollable component was measured + with an infinity maximum height constraints` when the parent is also + vertically scrollable. The handler now honors + `VisualElement.WidthRequest` / `HeightRequest` (mirroring + `BoxViewHandler`'s size switch) — `FillMaxSize` only when neither is + set, otherwise `Modifier.Size` / `Modifier.Width(.).FillMaxHeight` / + `Modifier.FillMaxWidth().Height(.)`. Hosting a `CollectionView` + inside a `ScrollView` still requires the consumer to set + `HeightRequest` (or use a bounded `Layout` like `Grid` row), the + same constraint Compose enforces on raw `LazyColumn`. + ### Phase 3 — collection + container (target: list-driven apps) `CollectionView` → `LazyColumn` / `LazyRow` / `LazyVerticalGrid` diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs index 9e4b1d87..6407ba45 100644 --- a/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs @@ -35,6 +35,7 @@ public AppShell() Routing.RegisterRoute("gestures", typeof(GesturesPage)); Routing.RegisterRoute("refresh", typeof(RefreshPage)); Routing.RegisterRoute("indicator", typeof(IndicatorPage)); + Routing.RegisterRoute("collections", typeof(CollectionsPage)); Routing.RegisterRoute("semantics", typeof(SemanticsPage)); } } diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs b/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs index ff1a0196..f13572b7 100644 --- a/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs @@ -130,6 +130,11 @@ public HomePage() "IndicatorView dot strip; Prev/Next buttons cycle Position.", Color.FromArgb("#9C27B0"), "indicator"), + new DemoEntry( + "Collections", + "CollectionView vertical / horizontal / grid backed by ObservableCollection.", + Color.FromArgb("#7E57C2"), + "collections"), new DemoEntry( "Semantics", "SemanticProperties.Description / Hint / HeadingLevel + AutomationId routed to Compose `Modifier.Semantics { … }`.", diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/CollectionsPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/CollectionsPage.xaml new file mode 100644 index 00000000..2961c683 --- /dev/null +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/CollectionsPage.xaml @@ -0,0 +1,153 @@ + + + + + + + + + +