Start Debugging

Migra un ListView de alto rendimiento de Xamarin.Forms a CollectionView de MAUI

Migración paso a paso de ListView de Xamarin.Forms 5.0 a CollectionView de .NET MAUI 11 para aplicaciones que ya exprimieron el rendimiento de ListView. Cubre reciclaje de celdas, virtualización, agrupación, pull-to-refresh, acciones contextuales, selección, ItemsLayout, EmptyView y los detalles que afectan a aplicaciones reales.

La versión corta: reemplaza ListView con CollectionView envuelto en un RefreshView si necesitabas pull-to-refresh, cambia ContextActions por SwipeView, descarta HasUnevenRows, RowHeight y CachingStrategy porque la virtualización con reciclaje de celdas es el único modo en MAUI, y reescribe la selección de ItemTapped / ItemSelected a SelectionMode más SelectionChangedCommand. Si tu antiguo ListView estaba ajustado con CachingStrategy="RecycleElement", HasUnevenRows="True" y un DataTemplateSelector, el cambio es mayormente mecánico. Probado con .NET MAUI 11.0 GA en net11.0-android35.0, net11.0-ios18.0 y net11.0-maccatalyst18.0. Espera de uno a tres días de trabajo enfocado para una aplicación con cinco a diez listas, más tiempo si dependes mucho de agrupación al estilo TableView o de renderers personalizados de ViewCell.

Xamarin.Forms dejó de tener soporte el 1 de mayo de 2024, y MAUI 11 GA salió en noviembre de 2025 con la tercera generación de correcciones de CollectionView para el jank en Android. Si has estado postergando la migración porque el rendimiento de tu aplicación estaba ajustado a mano alrededor de peculiaridades de ListView como RecycleElementAndDataTemplate, vale la pena hacer la actualización ahora: CollectionView en MAUI 11 finalmente iguala o supera al antiguo ListView de Xamarin.Forms en Android una vez que desactivas ItemSizingStrategy.MeasureAllItems. Esta guía es para esa audiencia.

Por qué migrar ahora

Qué se rompe

ÁreaListView de Xamarin.FormsCollectionView de MAUISeveridad
Estrategia de cachéCachingStrategy="RetainElement" o RecycleElementSiempre recicla. La propiedad no existe.media
Altura de filaHasUnevenRows, RowHeightItemSizingStrategy="MeasureAllItems" o MeasureFirstItemmedia
Pull to refreshIsPullToRefreshEnabled, RefreshCommand en ListViewEnvolver en RefreshViewalta
Tipo de celdaViewCell, TextCell, ImageCellDataTemplate plano con cualquier layout como raízalta
Manejo de tapItemTapped, ItemSelectedSelectionMode, SelectionChanged, SelectionChangedCommandalta
Acciones de swipeContextActionsSwipeViewalta
SeparadoresSeparatorVisibility, SeparatorColorNinguno. Agrega un BoxView en la plantilla.baja
Headers / footersHeader, Footer (objeto o plantilla)Mismos nombres, pero solo como plantillas / vistasbaja
AgrupaciónIsGroupingEnabled, GroupHeaderTemplateIsGrouped, GroupHeaderTemplate, GroupFooterTemplatemedia
Scroll toScrollTo(item, ScrollToPosition)ScrollTo(item, position, animate)baja
Estado vacíoEtiqueta hermana manualEmptyView, EmptyViewTemplatebaja

Los dos cambios que duelen son pull-to-refresh y selección. Todo lo demás es un renombrado directo o una eliminación.

Lista de verificación previa al despegue

  1. SDK de .NET 11 instalado (dotnet --version reporta 11.0.x). MAUI 11 requiere el SDK de .NET 11. Los SDK anteriores no tienen el manifiesto de la workload.
  2. Workload de MAUI instalada: dotnet workload install maui. Si actualizaste sobre una instalación anterior, ejecuta dotnet workload repair primero.
  3. Plataformas Android SDK 35, iOS 18 y Mac Catalyst 18 disponibles. Visual Studio 2022 17.13 o Visual Studio Code con la extensión .NET MAUI 1.6+.
  4. Una rama de git que puedas descartar. Mantén el proyecto de Xamarin.Forms compilando en main hasta que la compilación de MAUI esté en verde en un dispositivo real.
  5. Una lista de cada instancia de ListView en el proyecto. Agrúpalas por área de funcionalidad; migra una funcionalidad a la vez y publica detrás de un feature flag si tu cadencia de releases es mensual.
  6. Ejecuta el .NET Upgrade Assistant sobre el proyecto una vez para manejar los cambios de csproj, espacios de nombres y MauiProgram. El Upgrade Assistant no reescribe el XAML de ListView por ti, que es lo que cubre el resto de este post.

Pasos de la migración

1. Reemplaza ListView por CollectionView

El caso más simple es una lista plana con un DataTemplate personalizado:

<!-- Before. Xamarin.Forms 5.0 -->
<ListView ItemsSource="{Binding Articles}"
          CachingStrategy="RecycleElementAndDataTemplate"
          HasUnevenRows="True"
          SeparatorVisibility="None"
          ItemTapped="OnArticleTapped">
    <ListView.ItemTemplate>
        <DataTemplate>
            <ViewCell>
                <Grid Padding="12" RowDefinitions="Auto,Auto">
                    <Label Text="{Binding Title}" FontAttributes="Bold" />
                    <Label Grid.Row="1" Text="{Binding Excerpt}" />
                </Grid>
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>
<!-- After. .NET MAUI 11.0 -->
<CollectionView ItemsSource="{Binding Articles}"
                SelectionMode="Single"
                SelectionChangedCommand="{Binding OpenArticleCommand}"
                SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}">
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="vm:ArticleVm">
            <Grid Padding="12" RowDefinitions="Auto,Auto">
                <Label Text="{Binding Title}" FontAttributes="Bold" />
                <Label Grid.Row="1" Text="{Binding Excerpt}" />
            </Grid>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Lo que cambió:

Verifica: compila para Android (dotnet build -t:Run -f net11.0-android35.0) y haz scroll en una lista de 1000 elementos en un dispositivo de gama media. El tiempo de frame debería estar por debajo de 16 ms en la vista Profile GPU Rendering de Android Studio. Si no lo está, revisa que x:DataType esté configurado y que ningún binding dentro de la plantilla use Source={x:Reference ...}.

2. Envuelve en RefreshView para pull-to-refresh

IsPullToRefreshEnabled, IsRefreshing y RefreshCommand ya no están en la lista. Viven en RefreshView, que envuelve cualquier contenido scrollable.

<!-- After. .NET MAUI 11.0 -->
<RefreshView IsRefreshing="{Binding IsRefreshing}"
             Command="{Binding RefreshCommand}">
    <CollectionView ItemsSource="{Binding Articles}"
                    SelectionMode="Single">
        <CollectionView.ItemTemplate>
            <DataTemplate x:DataType="vm:ArticleVm">
                <!-- as before -->
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>
</RefreshView>

RefreshView solo se dispara cuando el contenido scrollable envuelto está en offset cero, así que el comportamiento coincide con el antiguo ListView. No hay IsPullToRefreshEnabled; si necesitas desactivarlo, configura IsEnabled="False" en el RefreshView.

Verifica: desliza hacia abajo en la lista mientras está en offset cero. El spinner de la plataforma debería aparecer y RefreshCommand debería dispararse exactamente una vez por gesto.

3. Reescribe la selección

El evento ItemTapped de Xamarin.Forms se dispara en cada tap, incluso cuando SelectedItem no cambió. CollectionView separa esto claramente: el tap es selección, y la selección es observable a través de SelectionChanged o SelectionChangedCommand. Si dependías del tap para navegar al mismo elemento dos veces seguidas, tienes que salir del estado de selección leyendo y limpiando SelectedItem después de cada navegación:

// MainViewModel.cs, .NET MAUI 11.0, C# 14
[RelayCommand]
private async Task OpenArticleAsync(ArticleVm? selected)
{
    if (selected is null) return;
    await Shell.Current.GoToAsync(nameof(ArticleDetailPage),
        new Dictionary<string, object> { ["article"] = selected });
    SelectedArticle = null; // re-arm so the same row can fire next time
}

SelectionMode="None" más un TapGestureRecognizer dentro de la plantilla es el equivalente más cercano a la antigua semántica de ItemTapped. Úsalo solo si tienes una razón. El patrón basado en selección es lo que las plataformas esperan y es lo que te da anillos de foco de accesibilidad gratis en Windows.

Verifica: toca un elemento, navega hacia atrás, toca el mismo elemento. Ambos taps deberían abrir la página de detalle.

4. Reemplaza ContextActions por SwipeView

<!-- Before. Xamarin.Forms 5.0 -->
<ViewCell>
    <ViewCell.ContextActions>
        <MenuItem Text="Delete" IsDestructive="True"
                  Command="{Binding DeleteCommand}" />
    </ViewCell.ContextActions>
    <Grid>...</Grid>
</ViewCell>
<!-- After. .NET MAUI 11.0 -->
<SwipeView>
    <SwipeView.RightItems>
        <SwipeItems Mode="Execute">
            <SwipeItem Text="Delete"
                       BackgroundColor="Crimson"
                       Command="{Binding DeleteCommand}" />
        </SwipeItems>
    </SwipeView.RightItems>
    <Grid>...</Grid>
</SwipeView>

SwipeView es un swipe real a izquierda y derecha con fondos personalizados. IsDestructive se convierte en un BackgroundColor normal. Mode="Execute" coincide con el comportamiento antiguo de descartar al tocar; Mode="Reveal" mantiene el menú abierto. La pulsación larga en iOS para revelar un menú desapareció; si la necesitas, usa MenuFlyout en MAUI 11 (nuevo en esta versión).

Verifica: desliza a la izquierda en iOS y Android. Toca delete. El elemento se elimina. Desliza de nuevo en la siguiente fila para confirmar que solo hay un swipe abierto a la vez.

5. Cambia la agrupación

<!-- After. .NET MAUI 11.0 -->
<CollectionView ItemsSource="{Binding ArticlesByDay}"
                IsGrouped="True">
    <CollectionView.GroupHeaderTemplate>
        <DataTemplate x:DataType="vm:ArticleDayVm">
            <Label Text="{Binding Day, StringFormat='{0:D}'}"
                   FontAttributes="Bold"
                   Padding="12,8" />
        </DataTemplate>
    </CollectionView.GroupHeaderTemplate>
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="vm:ArticleVm">
            <!-- as before -->
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Cambian dos cosas. La bandera es IsGrouped, no IsGroupingEnabled. Y la fuente debe ser una colección de colecciones donde cada elemento exterior sea a su vez enumerable. El patrón más limpio es una clase Grouping<TKey, TItem> que deriva de List<TItem> y expone Key. Ese es el mismo patrón que usaba Xamarin.Forms y se porta sin cambios.

Verifica: haz scroll en una lista agrupada de 30 días. Los headers de grupo deberían quedarse fijos en la parte superior de su grupo en iOS por defecto (GroupHeadersStick="True" en iOS nativo; el valor por defecto de MAUI lo refleja).

6. Elige un ItemsLayout

El valor por defecto es LinearItemsLayout vertical, que coincide con ListView. Cambia a una cuadrícula con una sola línea:

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

Span="3" es un conteo fijo de columnas. No hay una cuadrícula adaptativa integrada; para eso dimensionas las celdas contra el ancho disponible con un behaviour o vuelves a vincular en cambios de tamaño.

Verifica: rota el dispositivo. La cuadrícula debería redibujarse sin perder la posición de scroll.

7. Agrega un EmptyView

<CollectionView ItemsSource="{Binding Articles}">
    <CollectionView.EmptyView>
        <VerticalStackLayout HorizontalOptions="Center"
                             VerticalOptions="Center" Spacing="8">
            <Label Text="No articles yet" FontSize="18" />
            <Button Text="Refresh" Command="{Binding RefreshCommand}" />
        </VerticalStackLayout>
    </CollectionView.EmptyView>
</CollectionView>

Para múltiples estados vacíos (sin datos vs error), vincula EmptyView a una propiedad y usa EmptyViewTemplate con un DataTemplateSelector.

Verifica: limpia la fuente. La vista vacía aparece. Vuelve a llenar la fuente. La vista vacía desaparece sin un frame de superposición.

Lista de verificación

Después de migrar cada lista:

Plan de reversión

En la práctica, esta es una migración de un solo sentido. Una vez que el proyecto está en target frameworks net11.0-* y MauiProgram.cs, volver a Xamarin.Forms significa restaurar la forma antigua del csproj, el Xamarin.Forms.Forms.Init y los puntos de entrada de aplicación específicos de la plataforma. Nada en este post sobre CollectionView en sí es reversible por separado de la actualización del proyecto. Planea publicar una compilación de MAUI funcionando antes de borrar la rama de Xamarin.Forms.

Detalles que nos afectaron

Relacionado

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver