MAUI Phase 3 Slice 1: CollectionViewHandler (LazyColumn / LazyRow / LazyVerticalGrid)#274
MAUI Phase 3 Slice 1: CollectionViewHandler (LazyColumn / LazyRow / LazyVerticalGrid)#274jonathanpeppers wants to merge 3 commits into
Conversation
…azyVerticalGrid)
Folds `Microsoft.Maui.Controls.CollectionView` into the enclosing page
composition as a Compose `LazyColumn<T>` / `LazyRow<T>` /
`LazyVerticalGrid<T>` 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<int>` 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<Fruit>` 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>
There was a problem hiding this comment.
Pull request overview
This PR introduces CollectionViewHandler for the MAUI Compose backend (Phase 3 Slice 1), folding Microsoft.Maui.Controls.CollectionView into the page's single Compose composition as LazyColumn<T> / LazyRow<T> / LazyVerticalGrid<T> instead of the stock per-cell ComposeView islands. The handler dispatches layout type based on ItemsLayout, supports ItemTemplate / ItemTemplateSelector, EmptyView, INotifyCollectionChanged live subscriptions, Single/Multiple selection via per-row Modifier.Clickable, and WidthRequest/HeightRequest sizing. Supporting changes add VerticalArrangement to LazyColumn<T> and VerticalArrangement + HorizontalArrangement to LazyVerticalGrid<T> for item spacing.
Changes:
- New
CollectionViewHandler(~567 LOC) with layout dispatch, template materialisation viaDeferredViewNode, observable-collection subscription, empty-view fallback, selection routing, and sizing modifiers. - New
VerticalArrangement/HorizontalArrangementpublic properties onLazyColumn<T>andLazyVerticalGrid<T>facades, with axis validation and$defaultmask integration. - Sample
CollectionsPageexercising vertical, horizontal, and grid layouts plus mutation buttons and empty-view toggle; wired into the demo app navigation.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/Microsoft.AndroidX.Compose.Maui/Handlers/CollectionViewHandler.cs |
New handler: layout dispatch, templates, INCC subscription, empty-view, selection, sizing |
src/Microsoft.AndroidX.Compose/LazyColumn.cs |
Add VerticalArrangement property + axis validation + default-mask clearing |
src/Microsoft.AndroidX.Compose/LazyVerticalGrid.cs |
Add VerticalArrangement + HorizontalArrangement + axis validation + default-mask clearing |
src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt |
Register new public API entries for arrangement properties |
src/Microsoft.AndroidX.Compose.Maui/Hosting/AppHostBuilderExtensions.cs |
Register CollectionViewHandler in UseAndroidXCompose() + XML docs |
src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/CollectionsPage.xaml |
Sample page: vertical list, horizontal chips, grid, and empty-view CollectionViews |
src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/CollectionsPage.xaml.cs |
Code-behind with ObservableCollection<Fruit> + Add/Remove/Clear/Toggle handlers |
src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs |
Add "Collections" demo entry to the home catalog |
src/Microsoft.AndroidX.Compose.Maui.Sample/AppShell.xaml.cs |
Register CollectionsPage route |
docs/maui-backend.md |
Phase 3 Slice 1 section: delivered/deferred/lessons-learned |
| if ((items.Count == 0 || template is null) && view.EmptyView is not null) | ||
| { | ||
| return BuildEmptyView(view, context); | ||
| } |
There was a problem hiding this comment.
Good catch — fixed. Empty-view now triggers only when ItemsSource is null/empty, never on a null ItemTemplate. The template is null branch already falls through to ToString-per-item rendering below, which matches stock MAUI's TextCell default.
| static ComposableNode BuildFromTemplate(MauiDataTemplate template, object item, IMauiContext context) | ||
| { | ||
| var resolved = template is MauiDataTemplateSel selector | ||
| ? selector.SelectTemplate(item, container: null) |
There was a problem hiding this comment.
Fixed — BuildFromTemplate now takes the MauiCollectionView and passes it as container to SelectTemplate(item, container: view). Matches what stock MAUI's ItemsViewAdapter does.
| return buffer; | ||
| } | ||
|
|
||
| // ----- subscriptions ----- |
There was a problem hiding this comment.
Fair, removed all four // ----- Section ----- banners.
| - **`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. |
There was a problem hiding this comment.
Resolved by fixing the code (passing view as container) per the sibling thread; the doc lesson is rewritten to describe the new behaviour.
…phase-3-collections
* Empty-view gating: only when ItemsSource is null/empty. Stock MAUI falls through to ToString-per-item rendering when ItemTemplate is null even with EmptyView set, so a populated list with no template must NOT show EmptyView. * DataTemplateSelector.SelectTemplate(item, container: view) — pass the source CollectionView so selectors that branch on the host see the same BindableObject stock MAUI's adapter passes. Updates the matching docs lesson to reflect the code. * Remove `// ----- Section -----` banners per repo style; one type per file makes them noise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Folds
Microsoft.Maui.Controls.CollectionViewinto the enclosing page composition as a ComposeLazyColumn<T>/LazyRow<T>/LazyVerticalGrid<T>chosen byItemsLayout, instead of the stock per-cellComposeViewislands. One Recomposer, one snapshot graph, oneMaterialThemescope for the whole list.Investigation finding
CollectionViewrenders correctly via theAndroidViewfallback — the sample's home page itself proves it — but each Compose-folded cell spawns its ownComposeView. A managed handler is worth shipping because it removes those islands and sets the pattern forCarouselViewHandler(Slice 2). Other Phase 3 candidates investigated below.Shipped this slice
LinearItemsLayout.Vertical→LazyColumn,Horizontal→LazyRow,GridItemsLayout.Vertical→LazyVerticalGrid(GridCells.Fixed(Span)). Horizontal grid degrades toLazyRow(follow-up).ItemsSourcesnapshotting + liveINotifyCollectionChangedsubscription with aMutableState<int>version slot. Mapper bumps the slot forItemsSource/ItemTemplate/ItemsLayout/EmptyView/SelectionMode/WidthRequest/HeightRequest.ItemTemplate+ItemTemplateSelector. Each item gets a freshBindableObject(cost documented; memoisation is follow-up).EmptyView(string,IView, orEmptyViewTemplate).ItemSpacing/VerticalItemSpacing/HorizontalItemSpacing→Arrangement.SpacedByvia new public properties onLazyColumn,LazyRow,LazyVerticalGrid.Modifier.Clickable {}writes back toview.SelectedItem/view.SelectedItems. MAUI firesSelectionChanged+SelectionChangedCommandfrom the bindable setter — no manual event raise.WidthRequest/HeightRequesthonored via aBoxViewHandler-style size switch. Fixes theIllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraintscrash when hosting aCollectionViewinside aScrollView.CollectionsPageexercises all three layouts +ObservableCollection<Fruit>Add/Remove/Clear + empty-view toggle.docs/maui-backend.mdPhase 3 Slice 1 subsection with Goal / Delivered / Deferred / Lessons learned.Explicitly deferred to follow-up slices
ListViewTableViewCarouselView+ two-wayPositionPagerStatePhase-4b state-holder (parameterised Remember withpageCount).SwipeViewSwipeToDismissBoxdoesn't match SwipeView's left/right action-panel shape; needs more bridge work.ScrollTo/ScrolledeventLazyListState.ScrollToItem/AnimateScrollToItemalready exist; wiring is mechanical.ItemsUpdatingScrollMode/RemainingItemsThreshold/Header/Footer/ItemSizingStrategy/ grouping / per-item handler cachingLessons learned (highlights)
Wrap*vsInstantiate4distinction is exactly the trap the repo's instructions call out. Lazy-list item content runs at measure time insideSubcomposeLayout, outside the composer that built the list. The existing facades already useComposableLambdas.Instantiate4; the handler just contributes aFunc<T, ComposableNode>that fires inside that lambda. Building per-item nodes withWrap4(composer, …)crashes withExpected applyChanges() to have been called.CollectionViewHandlerregresses any app that depends onSelectionChangedfor navigation. The sample's ownHomePage.xamlusesCollectionView+SelectionMode="Single"+SelectionChanged="OnDemoSelected". The first cut of this slice deferred selection — that broke the demo nav the moment the handler was registered globally. Minimal Single/Multiple selection is mandatory, not optional; only the highlight styling can defer.LazyColumninside a verticalScrollViewrequires explicit height. Compose's lazy lists throwVertically scrollable component was measured with an infinity maximum height constraintswhen the parent is also vertically scrollable. Handler honorsVisualElement.WidthRequest/HeightRequest(mirroringBoxViewHandler's size switch).FillMaxSizeonly when neither is set; otherwiseModifier.Size/Width().FillMaxHeight/FillMaxWidth().Height(). Hosting a CollectionView inside a ScrollView still requires the consumer to setHeightRequest, the same constraint Compose enforces on rawLazyColumn.Full lessons-learned list in
docs/maui-backend.md.Verification
All 5 builds clean:
dotnet test src/Microsoft.AndroidX.Compose.SourceGenerators.Tests— 155/155 passdotnet build src/Microsoft.AndroidX.Compose— 0/0dotnet build src/Microsoft.AndroidX.Compose.Maui— 0/0dotnet build src/Microsoft.AndroidX.Compose.Maui.Sample -t:Install— 0/0dotnet build src/Microsoft.AndroidX.Compose.Gallery— 0/0On-device (Pixel
0A041FDD400327):ObservableCollectionmutations + empty-view toggle round-trip correctlyHeightRequesthonored —CollectionViewinside aScrollViewno longer crashesConventions
<PackageReference>at project level (none added here).?? throw new InvalidOperationException("X not set on YHandler.")for inherited-property null checks; no!postfix.ArgumentNullException.ThrowIfNullreserved for parameter checks.