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

·

·

ListView is deprecated in .NET 10. If you’re building new screens, use CollectionView. If you’re maintaining existing MAUI apps, you’ll need to migrate. The good news: most migrations are 90% mechanical find-and-replace. The 10% that isn’t mechanical — grouping, swipe actions, context menus — is covered in this guide.

This is a complete reference. Find your ListView pattern, see the CollectionView equivalent, migrate.

Table of Contents

Why Migrate: CollectionView Advantages

Before the patterns, here’s what you gain from migrating:

  • No ViewCell overhead — DataTemplates contain views directly, reducing element count in the visual tree
  • Flexible layouts — vertical, horizontal, or grid with ItemsLayout
  • Built-in EmptyView — no wrapper hacks for empty state
  • Better virtualization — more consistent recycling behavior across platforms
  • Multiple selection — native API, no event handler workarounds
  • Header/Footer as views — not separate list sections, simpler to customize

Basic List Migration

The simplest case: a list of items with a custom data template.

<!-- BEFORE: ListView -->
<ListView ItemsSource="{Binding Products}"
          HasUnevenRows="True"
          SeparatorVisibility="None">
    <ListView.ItemTemplate>
        <DataTemplate>
            <ViewCell>
                <Grid Padding="16,12" ColumnDefinitions="Auto,*,Auto">
                    <Image Source="{Binding ImageUrl}" WidthRequest="48" HeightRequest="48" />
                    <VerticalStackLayout Grid.Column="1" Padding="12,0">
                        <Label Text="{Binding Name}" FontAttributes="Bold" />
                        <Label Text="{Binding Category}" TextColor="#666" />
                    </VerticalStackLayout>
                    <Label Grid.Column="2" Text="{Binding Price, StringFormat='${0:F2}'}"
                           TextColor="#4CAF50" FontAttributes="Bold" />
                </Grid>
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

<!-- AFTER: CollectionView -->
<CollectionView ItemsSource="{Binding Products}">
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="model:Product">
            <!-- ViewCell removed — Grid goes directly in DataTemplate -->
            <Grid Padding="16,12" ColumnDefinitions="Auto,*,Auto">
                <Image Source="{Binding ImageUrl}" WidthRequest="48" HeightRequest="48" />
                <VerticalStackLayout Grid.Column="1" Padding="12,0">
                    <Label Text="{Binding Name}" FontAttributes="Bold" />
                    <Label Text="{Binding Category}" TextColor="#666" />
                </VerticalStackLayout>
                <Label Grid.Column="2" Text="{Binding Price, StringFormat='${0:F2}'}"
                       TextColor="#4CAF50" FontAttributes="Bold" />
            </Grid>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Changes: Remove ViewCell, add x:DataType for compiled bindings, remove HasUnevenRows and SeparatorVisibility (CollectionView measures items automatically; add separators manually if needed).

Adding Separators (If Needed)

<CollectionView.ItemTemplate>
    <DataTemplate>
        <VerticalStackLayout>
            <Grid Padding="16,12">
                <!-- item content -->
            </Grid>
            <BoxView HeightRequest="1" BackgroundColor="#E0E0E0" Margin="16,0" />
        </VerticalStackLayout>
    </DataTemplate>
</CollectionView.ItemTemplate>

Pull-to-Refresh

ListView had pull-to-refresh built in. CollectionView doesn’t — use a RefreshView wrapper instead.

<!-- BEFORE: ListView pull-to-refresh -->
<ListView ItemsSource="{Binding Items}"
          IsPullToRefreshEnabled="True"
          RefreshCommand="{Binding RefreshCommand}"
          IsRefreshing="{Binding IsRefreshing}">
</ListView>

<!-- AFTER: RefreshView + CollectionView -->
<RefreshView Command="{Binding RefreshCommand}"
             IsRefreshing="{Binding IsRefreshing}">
    <CollectionView ItemsSource="{Binding Items}">
        <CollectionView.ItemTemplate>
            <DataTemplate>
                <!-- item template -->
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>
</RefreshView>

The ViewModel code stays the same — RefreshCommand and IsRefreshing properties are unchanged.

Item Selection and Taps

Single Item Tap Navigation

<!-- BEFORE: ListView ItemSelected event -->
<ListView ItemSelected="OnItemSelected" />

// Code-behind
private async void OnItemSelected(object sender, SelectedItemChangedEventArgs e)
{
    if (e.SelectedItem is Product product)
    {
        ((ListView)sender).SelectedItem = null; // Deselect
        await Navigation.PushAsync(new ProductDetailPage(product));
    }
}
<!-- AFTER: CollectionView with TapGestureRecognizer (MVVM-friendly) -->
<CollectionView ItemsSource="{Binding Products}" SelectionMode="None">
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="model:Product">
            <Grid Padding="16,12">
                <Grid.GestureRecognizers>
                    <TapGestureRecognizer
                        Command="{Binding Source={RelativeSource AncestorType={x:Type vm:ProductListViewModel}},
                                          Path=NavigateToProductCommand}"
                        CommandParameter="{Binding .}" />
                </Grid.GestureRecognizers>
                <Label Text="{Binding Name}" />
            </Grid>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

// ViewModel
[RelayCommand]
private async Task NavigateToProductAsync(Product product)
{
    await Shell.Current.GoToAsync("productdetail",
        new Dictionary<string, object> { { "Product", product } });
}

Single Selection Mode

<CollectionView SelectionMode="Single"
                SelectedItem="{Binding SelectedProduct}"
                SelectionChanged="OnSelectionChanged">
</CollectionView>

// Code-behind (if needed)
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var selected = e.CurrentSelection.FirstOrDefault() as Product;
    // e.PreviousSelection also available
}

Multiple Selection

<CollectionView SelectionMode="Multiple"
                SelectedItems="{Binding SelectedProducts}">
</CollectionView>

// ViewModel
[ObservableProperty]
private IList<object> _selectedProducts = new List<object>();

Context Actions → SwipeView

ListView’s ContextActions (long-press menu on iOS, swipe on Android) become SwipeView in CollectionView.

<!-- BEFORE: ListView context actions -->
<ListView>
    <ListView.ItemTemplate>
        <DataTemplate>
            <ViewCell>
                <ViewCell.ContextActions>
                    <MenuItem Text="Delete"
                              IsDestructive="True"
                              Command="{Binding Source={x:Reference Page}, Path=BindingContext.DeleteCommand}"
                              CommandParameter="{Binding .}" />
                    <MenuItem Text="Archive"
                              Command="{Binding Source={x:Reference Page}, Path=BindingContext.ArchiveCommand}"
                              CommandParameter="{Binding .}" />
                </ViewCell.ContextActions>
                <Label Text="{Binding Title}" Padding="12" />
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

<!-- AFTER: CollectionView with SwipeView -->
<CollectionView ItemsSource="{Binding Items}">
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="model:TaskItem">
            <SwipeView>
                <SwipeView.RightItems>
                    <SwipeItems Mode="Reveal">
                        <SwipeItem Text="Delete"
                                   BackgroundColor="#F44336"
                                   IconImageSource="delete.png"
                                   Command="{Binding Source={RelativeSource AncestorType={x:Type vm:TaskViewModel}}, Path=DeleteCommand}"
                                   CommandParameter="{Binding .}" />
                        <SwipeItem Text="Archive"
                                   BackgroundColor="#9E9E9E"
                                   IconImageSource="archive.png"
                                   Command="{Binding Source={RelativeSource AncestorType={x:Type vm:TaskViewModel}}, Path=ArchiveCommand}"
                                   CommandParameter="{Binding .}" />
                    </SwipeItems>
                </SwipeView.RightItems>
                <Grid Padding="16,12">
                    <Label Text="{Binding Title}" />
                </Grid>
            </SwipeView>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

SwipeItems.Mode options: Reveal (slide to show actions, default) or Execute (swiping all the way triggers action immediately).

Grouped Lists

<!-- BEFORE: ListView grouping -->
<ListView ItemsSource="{Binding GroupedContacts}"
          IsGroupingEnabled="True"
          GroupDisplayBinding="{Binding Key}">
    <ListView.GroupHeaderTemplate>
        <DataTemplate>
            <ViewCell>
                <Label Text="{Binding Key}" BackgroundColor="#F5F5F5"
                       Padding="16,8" FontAttributes="Bold" />
            </ViewCell>
        </DataTemplate>
    </ListView.GroupHeaderTemplate>
    <ListView.ItemTemplate>
        <DataTemplate>
            <ViewCell>
                <Label Text="{Binding Name}" Padding="16,12" />
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

<!-- AFTER: CollectionView grouping -->
<CollectionView ItemsSource="{Binding GroupedContacts}"
                IsGrouped="True">
    <CollectionView.GroupHeaderTemplate>
        <DataTemplate x:DataType="model:ContactGroup">
            <Label Text="{Binding Key}" BackgroundColor="#F5F5F5"
                   Padding="16,8" FontAttributes="Bold" />
        </DataTemplate>
    </CollectionView.GroupHeaderTemplate>
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="model:Contact">
            <Label Text="{Binding Name}" Padding="16,12" />
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Group Data Structure

// ViewModel grouping setup
public ObservableCollection<ContactGroup> GroupedContacts { get; } = new();

// ContactGroup must implement IEnumerable and expose Key
public class ContactGroup : List<Contact>
{
    public string Key { get; }
    public ContactGroup(string key, List<Contact> contacts) : base(contacts)
    {
        Key = key;
    }
}

// Build grouped collection
var grouped = contacts
    .GroupBy(c => c.Name[0].ToString().ToUpper())
    .OrderBy(g => g.Key)
    .Select(g => new ContactGroup(g.Key, g.ToList()));

GroupedContacts = new ObservableCollection<ContactGroup>(grouped);

Empty State / No Data View

<!-- BEFORE: ListView empty state (hacky) -->
<ListView ItemsSource="{Binding Items}" />
<Label Text="No items yet"
       IsVisible="{Binding Items.Count, Converter={StaticResource ZeroToBoolConverter}}" />

<!-- AFTER: CollectionView EmptyView (built-in) -->
<CollectionView ItemsSource="{Binding Items}">
    <CollectionView.EmptyView>
        <VerticalStackLayout HorizontalOptions="Center" VerticalOptions="Center" Spacing="12">
            <Image Source="empty_state.png" WidthRequest="120" />
            <Label Text="No items yet" FontSize="18" HorizontalOptions="Center" />
            <Label Text="Tap the + button to add your first item."
                   TextColor="#666" HorizontalOptions="Center" />
        </VerticalStackLayout>
    </CollectionView.EmptyView>
</CollectionView>
<CollectionView ItemsSource="{Binding Products}">
    <CollectionView.Header>
        <Grid Padding="16" BackgroundColor="#6200EE">
            <Label Text="Featured Products" TextColor="White" FontSize="20" FontAttributes="Bold" />
        </Grid>
    </CollectionView.Header>

    <CollectionView.Footer>
        <Button Text="Load More Products"
                Command="{Binding LoadMoreCommand}"
                IsVisible="{Binding HasMore}"
                Margin="16" />
    </CollectionView.Footer>

    <CollectionView.ItemTemplate>
        <DataTemplate><!-- item template --></DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Infinite Scroll / Load More

<CollectionView ItemsSource="{Binding Items}"
                RemainingItemsThreshold="5"
                RemainingItemsThresholdReached="OnThresholdReached"
                RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
</CollectionView>

// ViewModel
[RelayCommand]
private async Task LoadMoreAsync()
{
    if (IsLoadingMore || !HasMoreItems) return;
    IsLoadingMore = true;
    var nextPage = await _service.GetPageAsync(++_currentPage);
    foreach (var item in nextPage)
        Items.Add(item);
    HasMoreItems = nextPage.Count == PageSize;
    IsLoadingMore = false;
}

Grid Layout (No ListView Equivalent)

This is a CollectionView-only feature — ListView couldn’t do grids without third-party libraries:

<CollectionView ItemsSource="{Binding Photos}">
    <CollectionView.ItemsLayout>
        <GridItemsLayout Orientation="Vertical"
                         Span="3"
                         HorizontalItemSpacing="4"
                         VerticalItemSpacing="4" />
    </CollectionView.ItemsLayout>
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="model:Photo">
            <Image Source="{Binding ThumbnailUrl}"
                   Aspect="AspectFill"
                   HeightRequest="120" />
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Built-in Cell Types Migration

ListView’s built-in cells (TextCell, ImageCell, SwitchCell, EntryCell) don’t have CollectionView equivalents. Build equivalent UI directly in the DataTemplate:

<!-- TextCell equivalent -->
<DataTemplate x:DataType="model:Item">
    <Grid Padding="16,12" ColumnDefinitions="*,Auto">
        <VerticalStackLayout>
            <Label Text="{Binding Title}" FontAttributes="Bold" />
            <Label Text="{Binding Detail}" TextColor="#666" FontSize="13" />
        </VerticalStackLayout>
        <Image Grid.Column="1" Source="chevron.png" WidthRequest="16"
               VerticalOptions="Center" />
    </Grid>
</DataTemplate>

<!-- ImageCell equivalent -->
<DataTemplate x:DataType="model:Item">
    <Grid Padding="12" ColumnDefinitions="48,*">
        <Image Source="{Binding ImageSource}" WidthRequest="40" HeightRequest="40"
               Aspect="AspectFill" VerticalOptions="Center" />
        <VerticalStackLayout Grid.Column="1" Padding="12,0" VerticalOptions="Center">
            <Label Text="{Binding Title}" FontAttributes="Bold" />
            <Label Text="{Binding Detail}" TextColor="#666" FontSize="13" />
        </VerticalStackLayout>
    </Grid>
</DataTemplate>

Performance Tips for CollectionView

  • Always use x:DataType — compiled bindings are measurably faster for lists, especially with 50+ items
  • Keep DataTemplates shallow — fewer layout layers = faster measurement and rendering
  • Use DataTemplateSelector sparingly — multiple template types reduce recycling efficiency
  • Avoid transparency in cells — opaque backgrounds allow the renderer to skip compositing
  • Pre-size images — avoid dynamic image resizing in cells by loading correctly-sized thumbnails
// DataTemplate selector for different item types
public class ItemTemplateSelector : DataTemplateSelector
{
    public DataTemplate HeaderTemplate { get; set; } = default!;
    public DataTemplate StandardTemplate { get; set; } = default!;

    protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
        => item is HeaderItem ? HeaderTemplate : StandardTemplate;
}

FAQ

Does CollectionView support the iOS native alphabet index scrubber?

Not out of the box. The native section index on iOS (the A-Z scrubber on the right side) requires a custom handler. Third-party libraries like DevExpress MAUI controls provide this if you need it.

My ListView had custom cell height. How do I set item height in CollectionView?

CollectionView measures each item automatically — you set the height through your DataTemplate layout (set HeightRequest on the root view if you need a fixed height). For uniform height lists, setting a fixed height improves performance since the layout engine skips measurement.

How do I programmatically scroll to a specific item in CollectionView?

// Scroll to item
myCollectionView.ScrollTo(item, animate: true);

// Scroll to index
myCollectionView.ScrollTo(index: 42, animate: true, position: ScrollToPosition.Start);

Can I use DataTemplateSelector with CollectionView grouping?

Yes — assign a DataTemplateSelector to GroupHeaderTemplate or ItemTemplate. Each item or group header is evaluated independently by the selector.

Is there a CollectionView equivalent of ListView’s RecycleElement strategy?

CollectionView handles recycling automatically and doesn’t expose a strategy option — it always uses the most efficient recycling approach for the platform. You don’t need to configure it.

Conclusion

Every ListView pattern has a clean CollectionView equivalent. The migration is mostly removing ViewCell, wrapping in RefreshView for pull-to-refresh, and replacing ContextActions with SwipeView. The grouping and selection APIs are structurally similar.

The time investment is worth it beyond avoiding future forced migration — CollectionView’s grid layout, EmptyView, and better virtualization are features you’ll actively benefit from. Migrate your highest-traffic screens first to maximize impact.

[INTERNAL_LINK: What’s new in .NET 10 for MAUI developers] [INTERNAL_LINK: .NET MAUI MVVM with CommunityToolkit.Mvvm guide]


Leave a Reply

Your email address will not be published. Required fields are marked *