Una guía de montaje implementada con List o un VStack puede mostrar un aviso al llegar al último paso. Para hacerlo necesitamos observar la posición del contenido, pero guardar cada desplazamiento en un @State introduce actualizaciones que esa pantalla no necesita. La información útil es una condición: estamos al final o todavía queda recorrido.
Desde iOS 18 y macOS 15, SwiftUI ofrece onScrollGeometryChange para reaccionar a la geometría de una vista desplazable. Forma parte de las mejoras de desplazamiento presentadas en la WWDC24. Permite resolver este caso sin montar una cadena de GeometryReader y preferencias para transmitir las medidas.
El modificador recibe dos bloques. El primero transforma un ScrollGeometry en un valor que conforme a Equatable. El segundo recibe el valor anterior y el nuevo cuando cambia el resultado de esa transformación. Si devolvemos un Bool, el bloque de acción no se ejecuta por cada punto recorrido, sino cuando cambia la condición observada. La documentación de Apple recomienda precisamente reducir la información para evitar actualizaciones excesivas.
Antes de escribir la comparación, conviene separar tres medidas: contentSize.height describe la altura del contenido, containerSize.height la del contenedor y contentOffset.y su posición de desplazamiento. También tenemos contentInsets, los márgenes que intervienen en el espacio desplazable. Apple especifica que ese espacio incluye el tamaño del contenido más los márgenes.
A partir de esas medidas podemos calcular el límite inferior de un desplazamiento vertical convencional, con el contenido inicialmente alineado arriba:
let bottomOffset = max(
-geometry.contentInsets.top,
geometry.contentSize.height
+ geometry.contentInsets.bottom
- geometry.containerSize.height
)
let remaining = bottomOffset - geometry.contentOffset.y
let isAtBottom = remaining <= 1
El segundo término calcula la posición necesaria para alcanzar el extremo inferior, incluido su margen. El primero establece el límite superior habitual, -contentInsets.top. Usar max evita obtener un límite inferior anterior al superior cuando el contenido es demasiado corto para desplazarse. En ese caso, consideramos que ya estamos al final.
Veámoslo con números: con 1.200 puntos de contenido, un contenedor de 600 y un margen inferior de 40, el límite inferior está en 640. Si contentOffset.y vale 500, quedan 140 puntos. El margen superior interviene en la posición inicial y en el recorrido total; no sustituye al margen inferior al calcular dónde termina ese recorrido.
La comparación admite un punto de tolerancia para no depender de una igualdad exacta entre valores con decimales. También considera que seguimos al final si el rebote lleva el contenido algo más allá. Apple documenta que el desplazamiento puede superar los límites del contenido por los márgenes o por ese efecto elástico.
Apliquemos el cálculo a la guía de montaje. Utilizaremos un VStack para que los pasos de este documento pequeño se midan conjuntamente:
import SwiftUI
@available(iOS 18.0, macOS 15.0, *)
struct AssemblyGuideView: View {
@State private var isAtBottom = false
private let steps = [
"Comprueba que están todas las piezas.",
"Extiende una manta para proteger el suelo.",
"Coloca los laterales junto a la base.",
"Encaja los soportes en sus alojamientos.",
"Aprieta los tornillos de forma alterna.",
"Coloca los estantes a la altura deseada.",
"Verifica que el mueble está nivelado.",
"Revisa las fijaciones antes de utilizarlo."
]
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 32) {
ForEach(steps.indices, id: \.self) { index in
VStack(alignment: .leading, spacing: 8) {
Text("Paso \(index + 1)")
.font(.headline)
Text(steps[index])
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(24)
}
.onScrollGeometryChange(for: Bool.self) { geometry in
let bottomOffset = max(
-geometry.contentInsets.top,
geometry.contentSize.height
+ geometry.contentInsets.bottom
- geometry.containerSize.height
)
return geometry.contentOffset.y >= bottomOffset - 1
} action: { _, newValue in
isAtBottom = newValue
}
Text(isAtBottom ? "Fin de la guía" : "Quedan pasos por ver")
.font(.footnote)
.frame(maxWidth: .infinity)
.padding()
}
}
}
El aviso ocupa siempre su espacio fuera del ScrollView. Así, cambiar el texto no añade ni elimina elementos dentro del documento observado. Si introdujéramos un pie de altura variable al detectar el final, cambiaríamos la propia medida utilizada para decidir si estamos allí: una posible fuente de cambios de estado repetidos.
El estado del ejemplo describe la posición actual. Al subir, vuelve a false. Si la aplicación necesita recordar que se alcanzó el final alguna vez, debe guardar otro estado y activarlo únicamente cuando newValue sea true. Tampoco conviene interpretar ese dato como prueba de lectura: el contenido puede caber entero o cambiar de tamaño sin que el usuario deslice el dedo.
Llegar al límite inferior y ver el último elemento son condiciones diferentes. Un espacio situado después del último paso puede mantenerlo visible antes de agotar el recorrido. Para reaccionar a un elemento concreto, SwiftUI proporciona onScrollVisibilityChange, que permite indicar qué proporción debe estar visible. Su umbral predeterminado es del 50 %. Es una alternativa más directa si queremos saber cuándo aparece una ilustración o el último paso.
Si adaptamos la detección geométrica para cargar más resultados, podemos ampliar la tolerancia a unos cientos de puntos y anticipar la petición. Pero el cambio del Bool solo indica que hemos entrado en esa zona. La capa de carga debe controlar las peticiones simultáneas, los errores y si quedan páginas. Además, si añadimos pocos elementos y seguimos dentro del umbral, puede no producirse otro cambio de false a true: continuar cargando requiere una decisión explícita después de cada respuesta.
Conviene revisar el comportamiento con contenido corto, texto grande, rotación y márgenes inferiores. En colecciones con contenedores calculados de forma asíncrona, también hay que comprobar las variaciones de tamaño al medirse nuevas filas. La geometría describe la disposición disponible en ese momento; no garantiza que el tamaño final permanezca constante.
onScrollGeometryChange resulta especialmente útil cuando transformamos las medidas en una condición concreta de la interfaz. Una comparación pequeña y un estado localizado bastan para mostrar el final de la guía, manteniendo separadas la posición del contenido, la visibilidad de sus elementos y las acciones que la aplicación ejecuta después.