With .NET MAUI 10, Microsoft has officially deprecated ListView, TableView, and all the associated cell types — TextCell, ImageCell, SwitchCell, ViewCell, and EntryCell. If you're maintaining a MAUI app that still relies on these controls, it's time to start migrating. I've been through this process on a couple of production apps now, and honestly, the migration is more straightforward than you might expect — though there are a few gotchas.
This guide walks you through every step of moving from ListView to CollectionView, with side-by-side code comparisons, real-world patterns, and solutions for features that don't have a direct replacement.
Why Microsoft Deprecated ListView in .NET MAUI 10
ListView has been part of the Xamarin and .NET MAUI ecosystem for over a decade. It served us well, but it carries significant technical debt:
- Cell-based architecture: The
ViewCell,TextCell, andImageCellabstractions add overhead and limit what you can actually do with your layouts. - No grid layout:
ListViewonly supports vertical single-column lists. That's it. - Limited selection modes: Only single selection is natively supported.
- Platform inconsistencies: Caching strategies (
RetainElement,RecycleElement,RecycleElementAndDataTemplate) behave differently across iOS and Android — something that's burned many developers over the years. - Performance ceiling: The cell recycling model just can't keep up with native platform virtualization.
CollectionView replaces all of this with a modern architecture that uses DataTemplate directly, leverages native virtualization engines, and supports lists, grids, horizontal layouts, and multi-selection out of the box.
In .NET MAUI 10, the iOS and Mac Catalyst handlers for CollectionView received major stability and performance improvements that were optional in .NET 9. They're now the default, which is a big deal if you've been hesitant to switch because of iOS quirks.
Key Differences at a Glance
Before diving into the migration, here's a quick reference of how the two controls differ:
| Feature | ListView | CollectionView |
|---|---|---|
| Item appearance | Cells (TextCell, ViewCell, etc.) | DataTemplate directly |
| Layouts | Vertical list only | Vertical list, horizontal list, vertical grid, horizontal grid |
| Selection | Single only | None, single, or multiple |
| Pull to refresh | Built-in (IsPullToRefreshEnabled) | Via RefreshView wrapper |
| Context actions | Built-in (ContextActions) | Via SwipeView in the template |
| Separators | Built-in (SeparatorColor) | Manual (via template styling) |
| Grouping | IsGroupingEnabled, GroupDisplayBinding | IsGrouped, GroupHeaderTemplate |
| Headers/Footers | Header, Footer properties | Header, Footer, HeaderTemplate, FooterTemplate |
| Empty state | Manual implementation | Built-in EmptyView / EmptyViewTemplate |
| Incremental loading | Manual implementation | Built-in RemainingItemsThreshold |
| Row height | RowHeight, HasUnevenRows | ItemSizingStrategy |
| Scrolling | ScrollTo | ScrollTo with more options |
So, let's get into the actual migration steps.
Step 1: Migrate a Basic TextCell List
The simplest ListView pattern uses TextCell to display a title and detail string. This is the easiest conversion you'll do.
Before — ListView with TextCell
<ListView ItemsSource="{Binding Contacts}">
<ListView.ItemTemplate>
<DataTemplate>
<TextCell Text="{Binding Name}"
Detail="{Binding Email}"
DetailColor="Gray" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
After — CollectionView with DataTemplate
<CollectionView ItemsSource="{Binding Contacts}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Contact">
<VerticalStackLayout Padding="16,12">
<Label Text="{Binding Name}"
FontSize="16"
FontAttributes="Bold" />
<Label Text="{Binding Email}"
FontSize="14"
TextColor="Gray" />
</VerticalStackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
Notice that CollectionView uses x:DataType for compiled bindings — a significant performance win over the reflection-based bindings that TextCell relied on. Always specify x:DataType in your data templates. Seriously, don't skip this.
Step 2: Migrate ImageCell to a Custom Template
ImageCell showed an image alongside text. With CollectionView, you build this layout yourself — which sounds like more work, but you gain full control over sizing, spacing, and aspect ratio. In practice, I've found this actually results in better-looking lists.
Before — ListView with ImageCell
<ListView ItemsSource="{Binding Products}">
<ListView.ItemTemplate>
<DataTemplate>
<ImageCell ImageSource="{Binding ThumbnailUrl}"
Text="{Binding Name}"
Detail="{Binding Price, StringFormat='${0:F2}'}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
After — CollectionView with Grid Template
<CollectionView ItemsSource="{Binding Products}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Product">
<Grid ColumnDefinitions="60,*"
ColumnSpacing="12"
Padding="16,10">
<Image Source="{Binding ThumbnailUrl}"
Aspect="AspectFill"
HeightRequest="60"
WidthRequest="60" />
<VerticalStackLayout Grid.Column="1"
VerticalOptions="Center">
<Label Text="{Binding Name}"
FontSize="16"
FontAttributes="Bold" />
<Label Text="{Binding Price, StringFormat='${0:F2}'}"
FontSize="14"
TextColor="Gray" />
</VerticalStackLayout>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
Step 3: Replace Context Actions with SwipeView
ListView had built-in context actions (those swipe-to-reveal buttons). With CollectionView, you wrap your item template content in a SwipeView instead.
Before — ListView ContextActions
<ListView ItemsSource="{Binding Tasks}">
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<ViewCell.ContextActions>
<MenuItem Text="Delete"
IsDestructive="True"
Command="{Binding Source={RelativeSource AncestorType={x:Type viewmodels:TaskViewModel}}, Path=DeleteCommand}"
CommandParameter="{Binding .}" />
<MenuItem Text="Archive"
Command="{Binding Source={RelativeSource AncestorType={x:Type viewmodels:TaskViewModel}}, Path=ArchiveCommand}"
CommandParameter="{Binding .}" />
</ViewCell.ContextActions>
<Label Text="{Binding Title}" Padding="16,12" />
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
After — CollectionView with SwipeView
<CollectionView ItemsSource="{Binding Tasks}"
x:Name="taskCollection">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:TaskItem">
<SwipeView>
<SwipeView.RightItems>
<SwipeItems>
<SwipeItem Text="Archive"
BackgroundColor="LightGreen"
Command="{Binding Source={RelativeSource AncestorType={x:Type viewmodels:TaskViewModel}}, Path=ArchiveCommand}"
CommandParameter="{Binding .}" />
<SwipeItem Text="Delete"
BackgroundColor="Red"
Command="{Binding Source={RelativeSource AncestorType={x:Type viewmodels:TaskViewModel}}, Path=DeleteCommand}"
CommandParameter="{Binding .}" />
</SwipeItems>
</SwipeView.RightItems>
<Grid Padding="16,12">
<Label Text="{Binding Title}" />
</Grid>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
SwipeView actually gives you way more flexibility than the old ContextActions. You can add icons, customize colors per item, support both left and right swipe, and use SwipeMode.Execute for single-gesture actions like swipe-to-delete.
Step 4: Migrate Pull-to-Refresh
ListView had pull-to-refresh built in. With CollectionView, you'll need to wrap it in a RefreshView. It's one extra line of XAML — not a big deal.
Before — ListView Pull-to-Refresh
<ListView ItemsSource="{Binding Items}"
IsPullToRefreshEnabled="True"
IsRefreshing="{Binding IsRefreshing}"
RefreshCommand="{Binding RefreshCommand}">
<!-- template -->
</ListView>
After — RefreshView Wrapping CollectionView
<RefreshView IsRefreshing="{Binding IsRefreshing}"
Command="{Binding RefreshCommand}">
<CollectionView ItemsSource="{Binding Items}">
<!-- template -->
</CollectionView>
</RefreshView>
Worth noting: in .NET MAUI 10, RefreshView gained an IsRefreshEnabled property that's separate from IsEnabled. This means you can disable pull-to-refresh while keeping the child content interactive — handy for login forms or read-only states.
Step 5: Migrate Grouping
Both controls support grouping, but the API is quite different. CollectionView replaces the string-based GroupDisplayBinding with fully customizable header and footer templates, which is a huge upgrade if you've ever struggled with styling group headers.
Before — ListView Grouping
<ListView ItemsSource="{Binding GroupedAnimals}"
IsGroupingEnabled="True"
GroupDisplayBinding="{Binding Key}"
GroupShortNameBinding="{Binding Key}">
<ListView.ItemTemplate>
<DataTemplate>
<TextCell Text="{Binding Name}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
After — CollectionView Grouping
<CollectionView ItemsSource="{Binding GroupedAnimals}"
IsGrouped="True">
<CollectionView.GroupHeaderTemplate>
<DataTemplate x:DataType="models:AnimalGroup">
<Label Text="{Binding Key}"
FontSize="18"
FontAttributes="Bold"
BackgroundColor="{AppThemeBinding Light=#F0F0F0, Dark=#2A2A2A}"
Padding="16,8" />
</DataTemplate>
</CollectionView.GroupHeaderTemplate>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Animal">
<Label Text="{Binding Name}"
Padding="16,10" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
The good news? Your data model stays the same. You still use a collection of groups where each group is an ObservableCollection (or similar) with a Key property:
public class AnimalGroup : ObservableCollection<Animal>
{
public string Key { get; }
public AnimalGroup(string key, IEnumerable<Animal> animals)
: base(animals)
{
Key = key;
}
}
Step 6: Migrate Selection Handling
CollectionView offers more powerful selection than ListView, including multi-selection support — something that used to require a lot of manual work.
Before — ListView Single Selection
<ListView ItemsSource="{Binding Items}"
SelectedItem="{Binding SelectedItem}"
ItemSelected="OnItemSelected">
<!-- template -->
</ListView>
void OnItemSelected(object sender, SelectedItemChangedEventArgs e)
{
if (e.SelectedItem is MyItem item)
{
// Handle selection
}
}
After — CollectionView Single and Multiple Selection
<!-- Single selection -->
<CollectionView ItemsSource="{Binding Items}"
SelectionMode="Single"
SelectedItem="{Binding SelectedItem}"
SelectionChanged="OnSelectionChanged">
<!-- template -->
</CollectionView>
<!-- Multiple selection -->
<CollectionView ItemsSource="{Binding Items}"
SelectionMode="Multiple"
SelectedItems="{Binding SelectedItems}"
SelectionChanged="OnSelectionChanged">
<!-- template -->
</CollectionView>
void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
var previousSelection = e.PreviousSelection;
var currentSelection = e.CurrentSelection;
foreach (MyItem item in currentSelection)
{
// Handle selected items
}
}
Watch out for the event signature change here: ItemSelected / SelectedItemChangedEventArgs becomes SelectionChanged / SelectionChangedEventArgs. The new event args give you both previous and current selections, which makes tracking state transitions much easier.
Step 7: Add Separators Manually
ListView drew separators between rows automatically. CollectionView doesn't. You'll need to add them yourself in the item template:
<CollectionView ItemsSource="{Binding Contacts}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Contact">
<VerticalStackLayout>
<Grid Padding="16,12">
<Label Text="{Binding Name}" FontSize="16" />
</Grid>
<BoxView HeightRequest="1"
Color="{AppThemeBinding Light=#E0E0E0, Dark=#3A3A3A}"
HorizontalOptions="FillAndExpand" />
</VerticalStackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
This is actually a blessing in disguise. You get full control over separator styling — add margins for indented separators (like iOS system apps), use different colors per section, or hide separators on the last item with a converter. The old SeparatorColor property was pretty limiting by comparison.
Step 8: Use EmptyView for Empty State
Here's something CollectionView has that ListView never did: built-in empty state support. No more manually toggling visibility on a "no items" label — this alone makes the migration worth it for some screens.
<CollectionView ItemsSource="{Binding SearchResults}">
<CollectionView.EmptyView>
<VerticalStackLayout HorizontalOptions="Center"
VerticalOptions="Center"
Spacing="12">
<Image Source="empty_search.png"
HeightRequest="120"
Opacity="0.6" />
<Label Text="No results found"
FontSize="18"
HorizontalTextAlignment="Center"
TextColor="Gray" />
<Label Text="Try adjusting your search terms"
FontSize="14"
HorizontalTextAlignment="Center"
TextColor="LightGray" />
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<!-- template -->
</CollectionView.ItemTemplate>
</CollectionView>
Step 9: Add Incremental Loading
Another CollectionView exclusive: built-in support for loading more items as the user scrolls toward the end of the list. No more hacky scroll-position detection.
<CollectionView ItemsSource="{Binding Items}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
<!-- template -->
</CollectionView>
public partial class ItemsViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<Item> items = new();
[RelayCommand]
private async Task LoadMore()
{
var nextPage = await _apiService.GetItemsAsync(Items.Count, pageSize: 20);
foreach (var item in nextPage)
{
Items.Add(item);
}
}
}
When the user scrolls to within 5 items of the end, LoadMoreCommand fires automatically. This pattern works really well with paged APIs — set the threshold to whatever gives your network requests enough lead time.
Step 10: Migrate TableView Settings Screens
TableView is also deprecated in .NET MAUI 10 (along with SwitchCell and EntryCell). Settings screens need a different approach, and honestly, this is the part of the migration that takes the most thought.
Before — TableView Settings
<TableView Intent="Settings">
<TableRoot>
<TableSection Title="Notifications">
<SwitchCell Text="Push Notifications"
On="{Binding PushEnabled}" />
<SwitchCell Text="Email Alerts"
On="{Binding EmailEnabled}" />
</TableSection>
<TableSection Title="Account">
<EntryCell Label="Display Name"
Text="{Binding DisplayName}" />
</TableSection>
</TableRoot>
</TableView>
After — VerticalStackLayout Settings
<ScrollView>
<VerticalStackLayout Spacing="0">
<!-- Section: Notifications -->
<Label Text="NOTIFICATIONS"
FontSize="13"
TextColor="Gray"
Padding="16,24,16,8" />
<Grid ColumnDefinitions="*,Auto"
Padding="16,12"
BackgroundColor="{AppThemeBinding Light=White, Dark=#1C1C1E}">
<Label Text="Push Notifications"
VerticalOptions="Center" />
<Switch Grid.Column="1"
IsToggled="{Binding PushEnabled}" />
</Grid>
<BoxView HeightRequest="1"
Color="{AppThemeBinding Light=#E0E0E0, Dark=#3A3A3A}"
Margin="16,0,0,0" />
<Grid ColumnDefinitions="*,Auto"
Padding="16,12"
BackgroundColor="{AppThemeBinding Light=White, Dark=#1C1C1E}">
<Label Text="Email Alerts"
VerticalOptions="Center" />
<Switch Grid.Column="1"
IsToggled="{Binding EmailEnabled}" />
</Grid>
<!-- Section: Account -->
<Label Text="ACCOUNT"
FontSize="13"
TextColor="Gray"
Padding="16,24,16,8" />
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12"
Padding="16,12"
BackgroundColor="{AppThemeBinding Light=White, Dark=#1C1C1E}">
<Label Text="Display Name"
VerticalOptions="Center"
TextColor="Gray" />
<Entry Grid.Column="1"
Text="{Binding DisplayName}"
Placeholder="Enter name" />
</Grid>
</VerticalStackLayout>
</ScrollView>
For settings screens with a fixed number of items, a plain VerticalStackLayout inside a ScrollView is often simpler than using a CollectionView. Reserve CollectionView for dynamic or data-bound lists where items come and go.
Performance Tips After Migration
Once you've migrated to CollectionView, don't just call it done — take advantage of its performance features. These can make a noticeable difference, especially on lower-end devices.
1. Use Compiled Bindings
Always set x:DataType on your DataTemplate elements. This enables compiled bindings, which eliminate reflection at runtime:
<DataTemplate x:DataType="models:Contact">
<Label Text="{Binding Name}" />
</DataTemplate>
2. Set ItemSizingStrategy for Uniform Items
If all your items have the same height, set ItemSizingStrategy="MeasureFirstItem" so MAUI only measures the first item and applies that size to all others:
<CollectionView ItemsSource="{Binding Items}"
ItemSizingStrategy="MeasureFirstItem">
<!-- template with fixed-height items -->
</CollectionView>
3. Don't Wrap CollectionView in StackLayout
This is a common mistake. Never put a CollectionView inside a VerticalStackLayout or StackLayout. It breaks virtualization, may limit the number of visible items, and can completely kill scrolling. Use a Grid as the parent instead.
4. Use the .NET 10 Layout Diagnostics
.NET MAUI 10 adds new layout diagnostics that track Measure and Arrange operations. Use these to verify your CollectionView items aren't being excessively re-measured:
// In MauiProgram.cs — enable OpenTelemetry metrics
using System.Diagnostics.Metrics;
var meter = new Meter("Microsoft.Maui");
var listener = new MeterListener();
listener.InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == "Microsoft.Maui")
listener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
Console.WriteLine($"{instrument.Name}: {measurement}");
});
listener.Start();
Handling the .NET MAUI 10 Deprecation Warnings
After upgrading to .NET MAUI 10, you'll see compiler warnings for every ListView, TableView, and cell type usage. If you can't migrate everything at once (and let's be realistic — most teams can't), you can suppress the warnings temporarily:
<!-- In your .csproj file -->
<PropertyGroup>
<NoWarn>$(NoWarn);CS0618</NoWarn>
</PropertyGroup>
Fair warning: only use this as a short-term measure. Track your remaining ListView usages and schedule their migration. The deprecated types could be removed entirely in a future .NET release.
Common Migration Pitfalls
A few things that tripped me up (and that I've seen others run into):
- ItemsSource updates off the UI thread:
CollectionViewthrows an exception if you updateItemsSourcefrom a background thread. UseMainThread.BeginInvokeOnMainThread()orDispatcher.Dispatch()when updating collections from async operations. This one catches a lot of people. - Missing SelectedItem clearing: Unlike
ListView, settingSelectedItem = nullin theSelectionChangedhandler works consistently. Use this pattern for navigate-on-tap scenarios. - iOS handler differences: The new default CollectionView handler on iOS in .NET MAUI 10 may behave differently from the old one. If you hit layout issues, you can temporarily revert to the .NET 9 handler while investigating — but the new handler is significantly more stable overall.
- No built-in HasUnevenRows: If you relied on
HasUnevenRows="True", don't worry —CollectionViewdefaults toItemSizingStrategy.MeasureAllItems, which handles variable-height rows automatically.
Frequently Asked Questions
Is ListView completely removed in .NET MAUI 10?
No. ListView is deprecated but not removed — your existing code will still compile and run. You'll get compiler warnings, and Microsoft recommends migrating to CollectionView since the deprecated types may be removed in a future release.
Can I use CollectionView with a grid layout?
Absolutely. CollectionView supports four layout modes: vertical list, horizontal list, vertical grid, and horizontal grid. Set the ItemsLayout property to a GridItemsLayout with a Span value to create multi-column or multi-row grids.
How do I add pull-to-refresh to CollectionView?
Wrap your CollectionView in a RefreshView and bind its IsRefreshing and Command properties to your view model. In .NET MAUI 10, RefreshView also supports the IsRefreshEnabled property for more granular control.
What replaces ViewCell in CollectionView?
Nothing — and that's the point. CollectionView doesn't use cells at all. Your DataTemplate can contain any view directly: Grid, VerticalStackLayout, Border, or any other layout. It's simpler and more performant than the old ViewCell wrapper.
How do I handle swipe-to-delete in CollectionView?
Use SwipeView as the root element of your DataTemplate. Add SwipeItem elements to SwipeView.LeftItems or SwipeView.RightItems with commands bound to your view model's delete logic.