Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions docs/maui-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -1873,6 +1873,182 @@ 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<T>` /
`LazyRow<T>` / `LazyVerticalGrid<T>` 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<T>` /
`LazyRow<T>`. `ItemSpacing` lowers to `Arrangement.SpacedBy(dp)`
on the matching axis.
- `GridItemsLayout` → `LazyVerticalGrid<T>` with
`GridCells.Fixed(Span)`. `VerticalItemSpacing` /
`HorizontalItemSpacing` lower to the new `VerticalArrangement` /
`HorizontalArrangement` facade props on `LazyVerticalGrid<T>`.
Horizontal `GridItemsLayout` (rare in practice) falls back to
`LazyRow<T>` 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<T>` 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<int> _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<int>` slot bumps on layout change.
- `LazyColumn<T>` gained `VerticalArrangement` (for `ItemSpacing` on
vertical linear lists).
- `LazyVerticalGrid<T>` 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<Fruit>`: 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<T, ComposableNode>` 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<T>` doesn't accept arbitrary structs.** The
underlying Compose `MutableState` only round-trips `Java.Lang.Object`
subclasses, primitives, strings, and `Nullable<primitive>`. The
established workaround on Phase 2 handlers (`SliderHandler`,
`LayoutHandler`, etc.) is the **version-counter pattern**: declare
a `MutableState<int>` 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)` is called
with `view` (the source `CollectionView`) as the container** so
selectors that branch on the host (e.g. "different template under a
CarouselView vs a list") see the same `BindableObject` stock MAUI's
adapter passes.
- **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<T>` / `LazyRow<T>` / `LazyVerticalGrid<T>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Routing.RegisterRoute("navigation", typeof(NavigationDemoPage));

Expand Down
5 changes: 5 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui.Sample/HomePage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>.",
Color.FromArgb("#7E57C2"),
"collections"),
new DemoEntry(
"Semantics",
"SemanticProperties.Description / Hint / HeadingLevel + AutomationId routed to Compose `Modifier.Semantics { … }`.",
Expand Down
153 changes: 153 additions & 0 deletions src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/CollectionsPage.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Microsoft.AndroidX.Compose.Maui.Sample.Pages"
x:Class="Microsoft.AndroidX.Compose.Maui.Sample.Pages.CollectionsPage"
x:DataType="local:CollectionsPage"
x:Name="Self"
Title="Collections">

<!--
Exercises `CollectionViewHandler` (Phase 3 Slice 1).

Three CollectionViews on one page exercise the layout dispatch
path:

* VerticalList — default LinearItemsLayout.Vertical → LazyColumn.
Mutate via Add / Remove / Clear buttons; the
ObservableCollection wired to ItemsSource raises
CollectionChanged, the handler bumps its items version slot,
and BuildNode re-snapshots and re-walks the templates.

* HorizontalChips — LinearItemsLayout horizontal + ItemSpacing
→ LazyRow with Arrangement.SpacedBy.

* GridList — GridItemsLayout Span=3 →
LazyVerticalGrid(GridCells.Fixed(3)) with vertical +
horizontal item spacing.

EmptyDemo flips ItemsSource between a populated collection and
an empty one; the handler renders EmptyView (a centered Label)
when the source is empty.
-->
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="24">

<!-- ===== Vertical list ===== -->

<Label Text="Vertical list (LazyColumn)"
Style="{StaticResource Headline}" />

<Label Text="ObservableCollection&lt;Fruit&gt; with Add / Remove / Clear. Each row uses a DataTemplate with two labels and a colored bar; mutations rebuild the list via INotifyCollectionChanged."
LineBreakMode="WordWrap" />

<HorizontalStackLayout Spacing="8">
<Button Text="Add" Clicked="OnAddFruit" />
<Button Text="Remove" Clicked="OnRemoveFruit" />
<Button Text="Clear" Clicked="OnClearFruits" />
</HorizontalStackLayout>

<CollectionView x:Name="VerticalList"
ItemsSource="{Binding Fruits, Source={x:Reference Self}, x:DataType=local:CollectionsPage}"
HeightRequest="280">
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="6" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="local:Fruit">
<Grid Padding="8" ColumnDefinitions="8,*,Auto">
<BoxView Grid.Column="0" Color="{Binding Accent}" WidthRequest="6" />
<VerticalStackLayout Grid.Column="1" Padding="10,0,0,0" Spacing="2">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Label Text="{Binding Origin}" FontSize="12" />
</VerticalStackLayout>
<Label Grid.Column="2" Text="{Binding Calories, StringFormat='{0} kcal'}" FontSize="12" VerticalOptions="Center" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>

<!-- ===== Horizontal chips ===== -->

<Label Text="Horizontal chips (LazyRow)"
Style="{StaticResource Headline}" />

<Label Text="Same items, LinearItemsLayout.Horizontal with ItemSpacing=12."
LineBreakMode="WordWrap" />

<CollectionView x:Name="HorizontalChips"
ItemsSource="{Binding Fruits, Source={x:Reference Self}, x:DataType=local:CollectionsPage}"
HeightRequest="64">
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Horizontal" ItemSpacing="12" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="local:Fruit">
<Border Stroke="{Binding Accent}"
StrokeThickness="1.5"
StrokeShape="RoundRectangle 16"
Padding="14,8">
<Label Text="{Binding Name}" />
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>

<!-- ===== Grid ===== -->

<Label Text="Grid (LazyVerticalGrid, Span=3)"
Style="{StaticResource Headline}" />

<Label Text="GridItemsLayout Span=3 with Vertical / Horizontal item spacing 8dp."
LineBreakMode="WordWrap" />

<CollectionView x:Name="GridList"
ItemsSource="{Binding Fruits, Source={x:Reference Self}, x:DataType=local:CollectionsPage}"
HeightRequest="280">
<CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Vertical"
Span="3"
VerticalItemSpacing="8"
HorizontalItemSpacing="8" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="local:Fruit">
<Border Stroke="{Binding Accent}" StrokeThickness="1" Padding="10">
<VerticalStackLayout Spacing="4">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Label Text="{Binding Calories, StringFormat='{0} kcal'}" FontSize="11" />
</VerticalStackLayout>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>

<!-- ===== Empty view ===== -->

<Label Text="Empty view"
Style="{StaticResource Headline}" />

<Label Text="Toggle between a populated source and an empty one. EmptyView is a centered Label rendered when ItemsSource is null/empty."
LineBreakMode="WordWrap" />

<Button Text="Toggle population" Clicked="OnToggleEmpty" />

<CollectionView x:Name="EmptyDemo"
HeightRequest="180">
<CollectionView.EmptyView>
<Label Text="No items yet — tap 'Toggle population' to add some."
HorizontalOptions="Center"
VerticalOptions="Center"
FontAttributes="Italic" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Label Text="{Binding .}" Padding="10,6" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>

</VerticalStackLayout>
</ScrollView>

</ContentPage>
Loading
Loading