Evitar un crash en iOS al hacer scroll automático en un chat .NET MAUI

Esta semana el caso mas interesante no fue un error grande, sino uno de esos fallos de interfaz que solo aparecen cuando la aplicación empieza a correr en un dispositivo real, con listas virtualizadas, filas de altura variable y eventos que llegan en momentos poco cómodos.

El sintoma era serio: en iOS, la app podia cerrarse al recibir o cargar mensajes en un chat. La operación que parecía inocente era el scroll automático al final de la conversación, el dato ya estaba en la coleccion, pero la vista nativa todavía no siempre tenia preparada la fila correspondiente.

El contexto

En una pantalla de chat, hacer scroll al ultimo mensaje parece una accion sencilla, cuando llega un mensaje nuevo, se actualiza la coleccion y se pide al listado que se posicione al final:

var item = messages[messages.Count - 1];
listView.ScrollTo(item, position: ScrollToPosition.End, animated: false);

El problema es que en una lista real no hay una equivalencia instantanea entre "el elemento ya existe en el ViewModel" y "la celda nativa ya existe en pantalla". En iOS, por debajo, el ListView acaba hablando con un UITableView. Si se llama a ScrollTo demasiado pronto, se puede intentar posicionar la lista sobre una fila que aun no esta materializada.

La causa

El fallo estaba en asumir que bastaba con esperar un poco y llamar a ScrollTo. Ya habia varios reintentos con pequeños retardos, porque iOS puede necesitar varios ciclos para medir y crear filas con alturas variables, pero faltaba una comprobación importante: verificar que la fila existia realmente en la vista nativa antes de pedirle al listado que hiciera scroll hasta ella.

En otras palabras: la lista de mensajes podia tener 40 elementos, pero el UITableView aun no haber publicado 40 filas en su seccion. Esa diferencia temporal era suficiente para provocar el cierre.

La corrección

El cambio consistió en separar la logica de scroll en métodos mas defensivos y reutilizables. Primero, se centralizaron los retardos de reintento:

private readonly int[] _iosScrollRetryDelays = { 50, 120, 250 };

Después, el scroll al último mensaje dejo de llamar directamente a ScrollTo y paso por una funcion que espera y comprueba si el item ya es desplazable:

private async Task ScrollToLastItemWhenReadyiOS(ListView listView, int requestId)
{
    foreach (var delay in _iosScrollRetryDelays)
    {
        await Task.Yield();
        await Task.Delay(delay);

        if (requestId != _scrollToBottomRequestId || !_isActive)
            return;

        bool didScroll = false;
        await MainThread.InvokeOnMainThreadAsync(() =>
        {
            var messages = vm?.Messages;
            if (messages == null || messages.Count == 0)
                return;

            var item = messages[messages.Count - 1];
            if (item != null && CanScrollToItem(listView, item))
            {
                listView.ScrollTo(item, position: ScrollToPosition.End, animated: false);
                didScroll = true;
            }
        });

        if (didScroll)
            return;
    }
}

La parte clave es CanScrollToItem. No basta con que el item este en la colección; también se comprueba que el indice exista en la tabla nativa de iOS:

private bool CanScrollToItem(ListView listView, Message item)
{
    if (listView == null || item == null || vm?.Messages == null)
        return false;

    int itemIndex = vm.Messages.IndexOf(item);
    if (itemIndex < 0)
        return false;

#if IOS
    if (listView.Handler?.PlatformView is UIKit.UITableView tableView)
    {
        if (tableView.NumberOfSections() == 0)
            return false;

        return tableView.NumberOfRowsInSection(0) > itemIndex;
    }
#endif

    return itemIndex < vm.Messages.Count;
}

Otro punto delicado: cargar mensajes antiguos

El mismo patron tambien servia para otro flujo: cuando el usuario llega al principio del chat y se cargan mensajes anteriores, en ese caso interesa mantener la posicion visual cerca del primer mensaje que habia antes de cargar la pagina nueva.

Antes se hacia un ScrollTo directo sobre ese item, después del fix, también pasa por una espera controlada:

if (actualItem != null)
    await ScrollToItemWhenReadyiOS((ListView)lstRecords, actualItem, ScrollToPosition.End);

Es el mismo problema con otra forma: el item existe, pero la fila nativa puede no estar lista justo en ese instante.

Por qué el arreglo es mejor que un simple delay

Un Task.Delay aislado suele arreglar el problema en el dispositivo del desarrollador, hasta que deja de hacerlo, depende de la carga, del tamaño de los mensajes, de imagenes, del teclado, del dispositivo y del momento exacto en que iOS termina de medir la tabla.

La mejora aqui es que el retardo no es la condicion de éxito, el retardo solo da tiempo a la UI, la condición de éxito es comprobar que se puede hacer scroll al item, si la fila no esta lista, se reintenta, si llega una peticion de scroll mas reciente, la anterior se cancela con requestId. Si la pagina ya no esta activa, se sale sin tocar la UI.

La lección

En aplicaciones moviles, especialmente con .NET MAUI y vistas nativas por debajo, no siempre basta con mirar el estado del ViewModel. A veces hay que comprobar tambien el estado real de la plataforma que va a ejecutar la accion.

Este bug se resume en una frase: que el dato exista no significa que la fila exista. Y cuando una API de UI necesita una fila nativa para hacer scroll, esa diferencia puede ser la frontera entre una experiencia fluida y un cierre inesperado.

La solucion no fue "esperar mas", sino esperar con criterio: reintentar poco, cancelar lo obsoleto y no llamar a ScrollTo hasta que iOS confirme que tiene una fila a la que desplazarse.

Comentarios

Entradas populares de este blog

Qué necesito para ser desarrollador de aplicaciones móviles (2ª parte)

Distribución de aplicaciones iOS en App Store mediante Visual Studio 2019

Automatización y workflows de emails con Mailchimp