CollectionView Patterns in .NET MAUI: Beyond the Basics
CollectionView is MAUI's primary list control, replacing the older ListView. It's more flexible, more performant, and supports layouts that ListView never could. But many apps only scratch the surface. Here are the patterns that make CollectionView genuinely useful.
Basic Setup
A minimal CollectionView with a data template:
<CollectionView ItemsSource="{Binding Tasks}"
x:DataType="vm:TaskListViewModel">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:TaskItem">
<Grid Padding="10" ColumnDefinitions="*, Auto">
<Label Text="{Binding Title}" />
<Label Grid.Column="1"
Text="{Binding DueDate, StringFormat='{0:dd MMM}'}"
TextColor="Gray" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
Always set x:DataType on both the page and the DataTemplate for compiled binding support.
Empty States
Show a message when the collection is empty:
<CollectionView ItemsSource="{Binding Tasks}">
<CollectionView.EmptyView>
<StackLayout HorizontalOptions="Center"
VerticalOptions="Center"
Padding="20">
<Image Source="empty_tasks.png"
HeightRequest="120"
Aspect="AspectFit" />
<Label Text="No tasks yet"
FontSize="18"
HorizontalTextAlignment="Center" />
<Label Text="Tap + to create your first task"
TextColor="Gray"
HorizontalTextAlignment="Center" />
</StackLayout>
</CollectionView.EmptyView>
<!-- ItemTemplate here -->
</CollectionView>
You can also swap empty views based on state — for instance, showing a different view when the list is empty due to a filter versus being genuinely empty:
<CollectionView.EmptyViewTemplate>
<DataTemplate>
<Label Text="{Binding EmptyMessage}" />
</DataTemplate>
</CollectionView.EmptyViewTemplate>
Selection
CollectionView supports single and multiple selection:
<CollectionView ItemsSource="{Binding Tasks}"
SelectionMode="Single"
SelectedItem="{Binding SelectedTask}"
SelectionChangedCommand="{Binding TaskSelectedCommand}">
For multiple selection:
<CollectionView SelectionMode="Multiple"
SelectedItems="{Binding SelectedTasks}">
Handle selection in the view model:
[ObservableProperty]
private TaskItem _selectedTask;
[RelayCommand]
private async Task TaskSelected()
{
if (SelectedTask is null) return;
await Shell.Current.GoToAsync("taskdetail", new Dictionary<string, object>
{
{ "task", SelectedTask }
});
// Clear selection so the user can tap the same item again
SelectedTask = null;
}
Grouping
Group items with headers:
public class TaskGroup : ObservableCollection<TaskItem>
{
public string Name { get; }
public TaskGroup(string name, IEnumerable<TaskItem> items)
: base(items)
{
Name = name;
}
}
// In view model
public ObservableCollection<TaskGroup> GroupedTasks { get; } = new()
{
new TaskGroup("Today", todayTasks),
new TaskGroup("This Week", weekTasks),
new TaskGroup("Later", laterTasks)
};
<CollectionView ItemsSource="{Binding GroupedTasks}"
IsGrouped="True">
<CollectionView.GroupHeaderTemplate>
<DataTemplate x:DataType="vm:TaskGroup">
<Label Text="{Binding Name}"
FontAttributes="Bold"
FontSize="16"
Padding="10,8" />
</DataTemplate>
</CollectionView.GroupHeaderTemplate>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:TaskItem">
<Label Text="{Binding Title}" Padding="20,5" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
Layout Options
CollectionView isn't limited to vertical lists. Change the layout entirely:
<!-- Horizontal list -->
<CollectionView ItemsSource="{Binding Categories}">
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Horizontal"
ItemSpacing="10" />
</CollectionView.ItemsLayout>
</CollectionView>
<!-- Grid layout — 2 columns -->
<CollectionView ItemsSource="{Binding Products}">
<CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Vertical"
Span="2"
HorizontalItemSpacing="10"
VerticalItemSpacing="10" />
</CollectionView.ItemsLayout>
</CollectionView>
Pull to Refresh
Wrap the CollectionView in a RefreshView:
<RefreshView IsRefreshing="{Binding IsRefreshing}"
Command="{Binding RefreshCommand}">
<CollectionView ItemsSource="{Binding Tasks}">
<!-- template -->
</CollectionView>
</RefreshView>
[ObservableProperty]
private bool _isRefreshing;
[RelayCommand]
private async Task RefreshAsync()
{
try
{
var freshData = await _taskService.GetAllAsync();
Tasks = new ObservableCollection<TaskItem>(freshData);
}
finally
{
IsRefreshing = false;
}
}
Incremental Loading
Load data as the user scrolls using RemainingItemsThreshold:
<CollectionView ItemsSource="{Binding Tasks}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
private int _page = 0;
[RelayCommand]
private async Task LoadMoreAsync()
{
_page++;
var moreItems = await _taskService.GetPageAsync(_page, pageSize: 20);
foreach (var item in moreItems)
Tasks.Add(item);
}
Set the threshold to trigger loading before the user reaches the end — a value of 5 means loading starts when 5 items remain off-screen.
Swipe Actions
Add context actions with SwipeView inside the item template:
<DataTemplate x:DataType="models:TaskItem">
<SwipeView>
<SwipeView.RightItems>
<SwipeItems>
<SwipeItem Text="Delete"
BackgroundColor="Red"
Command="{Binding Source={RelativeSource
AncestorType={x:Type vm:TaskListViewModel}},
Path=DeleteCommand}"
CommandParameter="{Binding .}" />
</SwipeItems>
</SwipeView.RightItems>
<Grid Padding="10">
<Label Text="{Binding Title}" />
</Grid>
</SwipeView>
</DataTemplate>
Performance Tips
Use ObservableCollection<T> so the UI updates when items are added or removed without rebinding the entire list.
Keep templates simple. Complex nested layouts in item templates hurt scroll performance. If you need elaborate items, consider CachingStrategy or simplifying the visual tree.
Avoid binding to properties that change during scrolling. If an item's appearance changes frequently, consider using a value converter with caching rather than triggering repeated property changes.
CollectionView handles most list scenarios you'll encounter in mobile apps. Master these patterns and you'll rarely need to reach for a third-party list control.