Migrating from ListView to CollectionView in .NET MAUI 10: The Complete Guide

ListView and TableView are deprecated in .NET MAUI 10. This step-by-step migration guide covers TextCell, ImageCell, ViewCell, context actions, grouping, pull-to-refresh, and settings screens — with side-by-side XAML code comparisons.

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, and ImageCell abstractions add overhead and limit what you can actually do with your layouts.
  • No grid layout: ListView only 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:

FeatureListViewCollectionView
Item appearanceCells (TextCell, ViewCell, etc.)DataTemplate directly
LayoutsVertical list onlyVertical list, horizontal list, vertical grid, horizontal grid
SelectionSingle onlyNone, single, or multiple
Pull to refreshBuilt-in (IsPullToRefreshEnabled)Via RefreshView wrapper
Context actionsBuilt-in (ContextActions)Via SwipeView in the template
SeparatorsBuilt-in (SeparatorColor)Manual (via template styling)
GroupingIsGroupingEnabled, GroupDisplayBindingIsGrouped, GroupHeaderTemplate
Headers/FootersHeader, Footer propertiesHeader, Footer, HeaderTemplate, FooterTemplate
Empty stateManual implementationBuilt-in EmptyView / EmptyViewTemplate
Incremental loadingManual implementationBuilt-in RemainingItemsThreshold
Row heightRowHeight, HasUnevenRowsItemSizingStrategy
ScrollingScrollToScrollTo 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: CollectionView throws an exception if you update ItemsSource from a background thread. Use MainThread.BeginInvokeOnMainThread() or Dispatcher.Dispatch() when updating collections from async operations. This one catches a lot of people.
  • Missing SelectedItem clearing: Unlike ListView, setting SelectedItem = null in the SelectionChanged handler 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 — CollectionView defaults to ItemSizingStrategy.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.

About the Author Editorial Team

Our team of expert writers and editors.