Skip to content

MAUI Phase 3 Slice 1: CollectionViewHandler (LazyColumn / LazyRow / LazyVerticalGrid)#274

Open
jonathanpeppers wants to merge 3 commits into
mainfrom
jonathanpeppers/maui-phase-3-collections
Open

MAUI Phase 3 Slice 1: CollectionViewHandler (LazyColumn / LazyRow / LazyVerticalGrid)#274
jonathanpeppers wants to merge 3 commits into
mainfrom
jonathanpeppers/maui-phase-3-collections

Conversation

@jonathanpeppers

Copy link
Copy Markdown
Owner

Summary

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 sample's home page itself proves it — but each Compose-folded cell spawns its own ComposeView. A managed handler is worth shipping because it removes those islands and sets the pattern for CarouselViewHandler (Slice 2). Other Phase 3 candidates investigated below.

Shipped this slice

  • Layout dispatch: LinearItemsLayout.VerticalLazyColumn, HorizontalLazyRow, GridItemsLayout.VerticalLazyVerticalGrid(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 / HorizontalItemSpacingArrangement.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

Control Why deferred
ListView Deprecated; defer until customer asks. Stock fallback works.
TableView Rare in modern MAUI apps. Stock fallback works.
CarouselView + two-way Position Own slice. Needs PagerState Phase-4b state-holder (parameterised Remember with pageCount).
SwipeView SwipeToDismissBox doesn't match SwipeView's left/right action-panel shape; needs more bridge work.
Selected-row highlight styling Data path ships in this PR; visual emphasis is follow-up.
ScrollTo / Scrolled event LazyListState.ScrollToItem / AnimateScrollToItem already exist; wiring is mechanical.
ItemsUpdatingScrollMode / RemainingItemsThreshold / Header / Footer / ItemSizingStrategy / grouping / per-item handler caching All deferred.

Lessons learned (highlights)

  • 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 facades already use ComposableLambdas.Instantiate4; the handler just contributes a Func<T, ComposableNode> that fires inside that lambda. Building per-item nodes with Wrap4(composer, …) crashes with Expected applyChanges() to have been called.
  • Globally registering CollectionViewHandler regresses any app that depends on SelectionChanged for navigation. The sample's own HomePage.xaml uses CollectionView + 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.
  • LazyColumn inside a vertical ScrollView requires explicit height. Compose's lazy lists throw Vertically scrollable component was measured with an infinity maximum height constraints when the parent is also vertically scrollable. Handler honors VisualElement.WidthRequest / HeightRequest (mirroring BoxViewHandler's size switch). FillMaxSize only when neither is set; otherwise Modifier.Size / Width().FillMaxHeight / FillMaxWidth().Height(). Hosting a CollectionView inside a ScrollView still requires the consumer to set HeightRequest, the same constraint Compose enforces on raw LazyColumn.

Full lessons-learned list in docs/maui-backend.md.

Verification

All 5 builds clean:

  • dotnet test src/Microsoft.AndroidX.Compose.SourceGenerators.Tests155/155 pass
  • dotnet build src/Microsoft.AndroidX.Compose — 0/0
  • dotnet build src/Microsoft.AndroidX.Compose.Maui — 0/0
  • dotnet build src/Microsoft.AndroidX.Compose.Maui.Sample -t:Install — 0/0
  • dotnet build src/Microsoft.AndroidX.Compose.Gallery — 0/0

On-device (Pixel 0A041FDD400327):

  • Home navigation works (Selection regression fixed)
  • CollectionsPage renders vertical list + horizontal chips + grid + empty view, no crash
  • ObservableCollection mutations + empty-view toggle round-trip correctly
  • HeightRequest honored — CollectionView inside a ScrollView no longer crashes

Conventions

  • Versionless <PackageReference> at project level (none added here).
  • One class per file, file-scoped namespaces, XML docs on public types.
  • ?? throw new InvalidOperationException("X not set on YHandler.") for inherited-property null checks; no ! postfix.
  • ArgumentNullException.ThrowIfNull reserved for parameter checks.
  • Co-author trailer present on the commit.

…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>
Copilot AI review requested due to automatic review settings June 12, 2026 23:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via DeferredViewNode, observable-collection subscription, empty-view fallback, selection routing, and sizing modifiers.
  • New VerticalArrangement / HorizontalArrangement public properties on LazyColumn<T> and LazyVerticalGrid<T> facades, with axis validation and $default mask integration.
  • Sample CollectionsPage exercising 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

Comment on lines +170 to +173
if ((items.Count == 0 || template is null) && view.EmptyView is not null)
{
return BuildEmptyView(view, context);
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +336 to +339
static ComposableNode BuildFromTemplate(MauiDataTemplate template, object item, IMauiContext context)
{
var resolved = template is MauiDataTemplateSel selector
? selector.SelectTemplate(item, container: null)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -----

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, removed all four // ----- Section ----- banners.

Comment thread docs/maui-backend.md Outdated
Comment on lines +2011 to +2014
- **`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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by fixing the code (passing view as container) per the sibling thread; the doc lesson is rewritten to describe the new behaviour.

jonathanpeppers and others added 2 commits June 15, 2026 14:04
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants