Introducción
El componente más común de SwiftUI es una lista (List),pero SwiftUI no nos proporciona por defecto ningún mecanismo de paginación para listas, así que en este tutorial vamos a implementarlo.
Aunque podemos acceder a un elemento en la iteración actual en el bloque de contenido de la vista de Lista, no sabemos nada sobre su posición actual en la lista o si está cerca del final de la lista. Ahí es donde entra la paginación.
La paginación puede significar cosas diferentes para diferentes personas. Así que tenemos que definirlo:
Durante el desplazamiento, la lista debe buscar y agregar los elementos de las páginas siguientes. Se debe mostrar una vista de carga cuando el usuario llega al final de la lista y una petición aún está en curso.
Con este fin en mente, implementemos una solución que solucione estos problemas y agregue soporte de paginación a la vista de Lista.
Entorno
Este tutorial está escrito usando el siguiente entorno:
-
- Hardware: MacBook Pro 15’ (2,5 GHz Intel Core i7, 16GB DDR3, mediados de 2015)
- Sistema operativo: macOS Catalina 10.15
- Versiones del software:
- Xcode: 11
- iOS SDK: 13.4
Es importante saber que para probar SwiftUI se necesita instalar Mac OS Catalina 10.15 y Xcode 11.
Primer enfoque
En esta sección, veremos dos enfoques diferentes. El primer enfoque es más obvio, los usuarios avanzados pueden apreciar la funcionalidad orientada al usuario del segundo enfoque.
Una solución simple es verificar si el elemento en la iteración actual es el último elemento de la lista. Si eso es cierto, activamos una petición asincrónica para recuperar los elementos de la página siguiente.
Debido a que la vista de Lista implementa RandomAccessCollection, podemos crear una extensión e implementar una función isLastItem. La clave es el requisito de Self, que restringe la extensión a las colecciones donde los elementos se ajustan al protocolo Identifiable.
extension RandomAccessCollection where Self.Element: Identifiable { func isLastItem<Item: Identifiable>(_ item: Item) -> Bool { guard !isEmpty else { return false } guard let itemIndex = firstIndex(where: { $0.id.hashValue == item.id.hashValue }) else { return false } let distance = self.distance(from: itemIndex, to: endIndex) return distance == 1 } }
Pasa un elemento que implementa el protocolo Identifiable a la función, y te devuelva true si el elemento es el último elemento de la colección.
La función busca el índice del elemento dado en la colección. Utiliza el valor hash de la propiedad id (requisito del protocolo Identifiable) para compararlo con los otros elementos de la lista. Si se encuentra el índice de elementos, eso significa que la distancia entre el índice de elementos y el índice final debe ser exactamente uno (el índice final es igual al número actual de elementos en la colección). Así es como sabemos que el elemento dado es el último elemento.
En lugar de comparar los valores hash, podemos usar el envoltorio AnyHashable para comparar directamente los ids que son del tipo Hashable.
guard let itemIndex = firstIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else { return false }
Ahora que se sentaron las bases, podemos implementar la interfaz de usuario.
La interfaz gráfica
Queremos activar una actualización de la lista si se llega al final. Para lograrlo, podemos usar el modificador onAppear en la vista raíz de cada elemento. (En este ejemplo, es un VStack). Esto llama a la función listItemAppears a continuación.
Si el elemento de la iteración actual es el último elemento, se mostrará una vista de carga al usuario. En este ejemplo simple, es LoadingView().
Como SwiftUI es declarativo, el siguiente código debería explicarse por sí mismo:
VStack() { List { ForEach(self.viewModel.characters, id: \.id) { character in NavigationLink( destination: DetailView(result: character) ) { VStack(spacing: 10) { Text("\(character.name ?? "")").frame(maxWidth: .infinity, alignment: Alignment.leading) if self.viewModel.state == MarvelViewModel.State.loading && self.viewModel.characters.isLastItem(character) { Divider() LoadingView() } }.onAppear { self.listItemAppears(character) self.animationStarted = true } } }.onDelete { indices in indices.forEach { self.viewModel.characters.remove(at: $0) } } } } }.frame(maxHeight: .infinity, alignment: .top) }
La función auxiliar listItemAppears comprueba internamente si el elemento dado es el último elemento. Si es el último elemento, la página actual se incrementa y los elementos de la página siguiente se agregan a la lista. Además, hacemos un seguimiento del estado de carga a través de la variable isLoading, que define cuándo mostrar la vista de carga.
extension ListPaginationExampleView { private func listItemAppears<Item: Identifiable>(_ item: Item) { if self.viewModel.characters.isLastItem(item: item) { self.viewModel.page+=1 self.viewModel.loadCharacters(searchTerm: self.viewModel.searchText) } } }
Con esta implementación, extraemos la siguiente página de elementos solo si el elemento en la iteración actual es el último elemento.
Pero esa no es realmente la mejor experiencia de usuario, ¿verdad? En una aplicación real, queremos precargar la página siguiente si se alcanza o supera un umbral definido. Además, solo deberíamos interrumpir al usuario con un indicador de carga si es realmente necesario (es decir, si la solicitud tarda más de lo esperado). Esto, en mi opinión, llevaría a una mejor experiencia de usuario.
Dadas estas preocupaciones sobre la experiencia del usuario, pasemos al segundo enfoque.
Secundo enfoque
Aquí aprenderemos cómo obtener la siguiente página de elementos si se supera un umbral determinado. Nuevamente, comenzaremos extendiendo RandomAccessCollection. Esta vez implementaremos una función llamada isThresholdItem que determina si el elemento dado es el elemento umbral.
extension RandomAccessCollection where Self.Element: Identifiable { func isThresholdItem<Item: Identifiable>(offset: Int, item: Item) -> Bool { guard !isEmpty else { return false } guard let itemIndex = firstIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else { return false } let distance = self.distance(from: itemIndex, to: endIndex) let offset = offset < count ? offset : count - 1 return offset == (distance - 1) } }
Esta función busca el índice del elemento dado. Si se encuentra, calcula la distancia al índice final. El desplazamiento especificado (es decir, el número de elementos antes del final) debe ser igual a la distancia: 1. Tenemos que restar 1 de la distancia porque el índice final es igual al valor de la propiedad de count (es decir, el número actual de elementos en la colección ) También agregué una verificación de validación simple para el desplazamiento. El desplazamiento debe ser menor que el número actual de elementos en la colección.
Ahora estamos listos para pasar a la interfaz de usuario una vez más.
La implementación de la interfaz de usuario es casi idéntica a nuestra interfaz de usuario en el primer enfoque. Sin embargo, hay una diferencia clave, y está en la función listItemAppears.
Ten en cuenta que aquí reutilizamos la función isLastItem desde el primer enfoque. La vista de carga se mostrará solo si el usuario llega al final de la lista y la petición para la siguiente página aún está en curso.
List { ForEach(self.viewModel.characters, id: \.id) { character in NavigationLink( destination: DetailView(result: character) ) { VStack(spacing: 10) { Text("\(character.name ?? "")").frame(maxWidth: .infinity, alignment: Alignment.leading) if self.viewModel.state == MarvelViewModel.State.loading && self.viewModel.characters.isLastItem(character) { Divider() LoadingView() } }.onAppear { self.listItemAppears(character) self.animationStarted = true } } } } } }.frame(maxHeight: .infinity, alignment: .top) }
En lugar de llamar a isLastItem, llamamos a isThresholdItem para verificar si el elemento dado es el elemento umbral.
extension ListPaginationThresholdExampleView { private func listItemAppears<Item: Identifiable>(_ item: Item) { if items.isThresholdItem(offset: offset, item: item) { isLoading = true /* Pedimos otra porción de los datos si el elemento es umbral */ if self.viewModel.characters.isThresholdItem(offset: 16, item: item) { self.viewModel.page+=1 self.viewModel.loadCharacters(searchTerm: self.viewModel.searchText) } } } }
Si eres un lector especialmente atento, es posible que hayas notado que faltan algunas piezas de código.
getMoreItems
A continuación se muestra la implementación de la función getMoreItems:
extension ListPaginationExampleView { /* In a real app you would probably fetch data from an external API. */ private func getMoreItems(forPage page: Int, pageSize: Int) -> [String] { let maximum = ((page * pageSize) + pageSize) - 1 let moreItems: [String] = Array(items.count...maximum).map { "Item \($0)" } return moreItems } }
String + Identifiable
Aquí está la extensión final necesaria para que el código de la vista de Lista funcione:
/* Si quieres mostrar un array de String en la vista de Lista, debes especificar una ruta de clave (key path), de modo que cada String se pueda identificar de forma exclusiva. Con esta extensión no tienes que hacer eso. */ extension String: Identifiable { public var id: String { return self } }
Esta extensión de String facilita el uso directo de un array de String en el inicializador de vista de lista relacionado.
Resultado
Finalmente, veamos nuestros resultados.
El primer GIF muestra el enfoque con isLastItem:
El siguiente gif muestra el enfoque con isThresholdItem:
Felicidades! Ahora estás listo para usar la paginación en tus listas de SwiftUI.
Conclusiones
Hemos hecho nuestra implementación de paginación en las listas de SwiftUI. Como habéis visto es bastante sencillo, pero creo que Apple en el futuro añadirá métodos especiales en el componente List para hacer paginación mas sencillo. Pero siempre viene bien realizar tu versión para entender mejor el funcionamiento interno de cualquier sistema/componente.