Modales y su lugar: sheets, covers y popovers
Qué significa realmente presentar de forma modal, cómo se diferencian sheet, fullScreenCover y popover, por qué la presentación por elemento es más segura que la booleana, cómo convive una pila dentro de un modal y qué criterio de las guías de diseño decide entre navegar y presentar.
Navegar y presentar parecen dos formas de enseñar una pantalla, y son dos contratos distintos con el usuario. Navegar dice: sigues en el mismo sitio, más adentro. Presentar dice: para lo que estabas haciendo, atiende esto, y luego decide si aceptas o cancelas. Elegir mal no produce un fallo técnico, produce una app que se siente confusa sin que nadie sepa explicar por qué. Esta lección cierra el nivel poniendo cada herramienta en su sitio.
- Entender la modalidad como interrupción con salida explícita, no como otra forma de navegar.
- Distinguir hoja, cubierta a pantalla completa y globo emergente por su contrato de interacción.
- Presentar por elemento en lugar de por booleano y saber por qué importa.
- Aplicar un criterio defendible para decidir entre navegar y presentar.
Qué es presentar de forma modal
Un modal crea un contexto nuevo que suspende el anterior. Mientras esté arriba, la tarea de fondo está congelada, y el usuario tiene ante sí un mini flujo con dos salidas nombradas: confirmar o descartar. Esa es la diferencia esencial con la navegación, donde no hay nada que confirmar y el retroceso siempre está disponible sin consecuencias.
struct Lista: View {
@State private var enEdicion: Tarea? // por elemento, no por bandera
var body: some View {
List(tareas) { tarea in
Button(tarea.titulo) { enEdicion = tarea }
}
.sheet(item: $enEdicion) { tarea in
EditorTarea(tarea: tarea)
}
}
}
La presentación por elemento no es un capricho estilístico. Con un booleano necesitas dos estados —la bandera y el dato que se va a mostrar— y nada garantiza que estén sincronizados: el clásico modal que aparece vacío o mostrando el elemento anterior nace siempre de ahí. Con un opcional, el dato y la presencia del modal son el mismo valor, y el estado inconsistente deja de ser expresable.
Dentro del modal, la forma correcta de cerrarse es leer la acción de descarte del entorno con dismiss en lugar de recibir un binding del padre. Así la vista presentada no necesita saber quién la presentó, funciona igual dentro de una hoja o de una cubierta, y el padre no queda acoplado a los botones de su hijo.
El repertorio y su contrato
Las tres presentaciones no se diferencian por su aspecto sino por cuánto interrumpen y cómo se sale de ellas.
// hoja: interrupcion ligera, descartable con gesto, el fondo sigue visible
.sheet(item: $elemento) { Editor(elemento: $0) }
// altura parcial: menos interrupcion todavia, el contexto de fondo se mantiene
.sheet(isPresented: $mostrando) {
Filtros().presentationDetents([.medium, .large])
}
// cubierta: interrupcion total, sin gesto de descarte, salida solo por boton
.fullScreenCover(isPresented: $onboarding) { Bienvenida() }
// globo: contextual y anclado al control que lo abrio
.popover(isPresented: $mostrandoAyuda) { Ayuda() }
Las alturas parciales merecen un comentario aparte porque cambian el significado de la presentación. Una hoja a media altura deja ver el contenido de fondo y comunica que la interrupción es reversible y ligera; una a altura completa comunica que ahora hay una tarea que atender. Ofrecer ambas alturas y dejar que el usuario elija es habitualmente la mejor decisión para paneles de filtros, selectores y ajustes rápidos.
.sheet(isPresented: $mostrando) {
Filtros()
.presentationDetents([.height(220), .medium, .large])
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
}
La última línea es la que hace posible un patrón muy común: mientras la hoja esté por debajo de la altura media, el usuario puede seguir tocando el mapa o la lista del fondo. Es modalidad graduada, no modalidad a medias.
Hoja
Tarea corta y opcional. El usuario puede abandonarla deslizando. La opción por defecto en caso de duda.
Cubierta
Flujos que no admiten abandono accidental: introducción inicial, pago, cámara, contenido inmersivo.
Globo
Ajustes u opciones ligados a un control concreto. En el teléfono se convierte en hoja automáticamente.
Navegación
Profundizar en contenido que ya estabas viendo. Sin decisión que confirmar y con retroceso libre.
Un modal puede contener su propia pila, y de hecho suele necesitarla: un editor con varias fases es un flujo con historial propio que no debe alterar el historial de fondo.
.sheet(item: $enEdicion) { tarea in
NavigationStack(path: $rutaEdicion) {
EditorTarea(tarea: tarea)
.navigationDestination(for: RutaEdicion.self) { destino($0) }
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancelar") { ... } }
ToolbarItem(placement: .confirmationAction) { Button("Guardar") { ... } }
}
}
}
Esa pila es independiente: nace al presentarse el modal y muere al descartarlo. Intentar que un modal empuje pantallas en la pila de fondo es el síntoma más claro de que lo que querías no era un modal, sino navegar.
Presentar un modal desde otro modal es legal y casi siempre un error de diseño: el usuario acumula contextos suspendidos y pierde de vista cuántas veces tiene que cancelar para volver. Si un flujo necesita varias pantallas, usa una pila dentro de un único modal. Y evita presentar mientras otra presentación está en curso: la segunda se descarta o se ignora.
Navegar o presentar
flowchart TD
A[quiero mostrar otra pantalla] --> B{hay decision que confirmar o cancelar}
B -- no --> N[navegar en la pila]
B -- si --> C{el usuario puede abandonar sin coste}
C -- si --> H[hoja]
C -- no --> F[cubierta a pantalla completa]
N --> Z[el retroceso es libre]
style N fill:#a6e3a1,color:#11111b
style H fill:#89b4fa,color:#11111b
style F fill:#f9e2af,color:#11111bLas guías de diseño de Apple resumen el criterio en una frase que conviene tomarse en serio: la modalidad debe usarse con moderación y solo cuando interrumpir está justificado. Se justifica en tres situaciones: obtener información imprescindible para continuar, aislar una tarea con riesgo de error, y advertir de algo que exige una respuesta. Fuera de esas, interrumpir es una molestia disfrazada de jerarquía.
Hay un caso intermedio que conviene nombrar: la hoja con trabajo sin guardar. Ni la cubierta a pantalla completa ni la hoja normal lo resuelven bien, porque la primera es demasiado severa para un editor breve y la segunda permite descartar con un gesto involuntario. La respuesta correcta es bloquear el descarte interactivo y ofrecer una confirmación explícita.
.sheet(item: $enEdicion) { tarea in
Editor(tarea: tarea)
.interactiveDismissDisabled(hayCambios)
}
De ahí se derivan tres obligaciones concretas para todo modal que escribas. Debe tener un título que nombre la tarea, para que el usuario sepa qué contexto ha entrado. Debe ofrecer una salida explícita en la barra, aunque además se pueda deslizar, porque el gesto no es descubrible para todo el mundo. Y debe proteger el trabajo no guardado con una confirmación antes de descartar, ya que lo que se pierde en un modal se pierde sin rastro.
Una presentación no aparece en tu path ni se restaura con él. Si necesitas que un enlace profundo abra una hoja concreta, tendrás que modelar esa presentación como estado propio —un opcional en el mismo sitio donde vive la pila— y decidir explícitamente si se restaura. Casi nunca debería restaurarse: un flujo interrumpido a medias es justo lo que el usuario no espera encontrarse al reabrir la app.
Si te quedas con una sola idea de este nivel, que sea esta distinción, porque no es una convención de plataforma sino una diferencia de naturaleza. La navegación organiza el espacio de tu app: dice qué contiene a qué, qué está más adentro y qué más afuera, y por eso puede describirse con un valor, guardarse, fabricarse desde una URL y comprobarse en una prueba, tal como has visto en las cuatro lecciones anteriores. La modalidad, en cambio, organiza el tiempo: dice que ahora mismo hay una tarea abierta que debe resolverse antes de seguir, y su contrato con el usuario es una promesa de que existe una salida limpia con dos nombres, aceptar y cancelar. Confundir ambas cosas produce los dos defectos más frecuentes que verás en apps reales, y ahora puedes nombrarlos: presentar lo que se debía navegar convierte una jerarquía tranquila en una sucesión de interrupciones que el usuario debe ir cerrando hacia atrás; navegar lo que se debía presentar deja tareas a medias sin un punto de cancelación, y el retroceso pasa a significar dos cosas incompatibles —he terminado y me arrepiento— sin que nadie sepa cuál se aplicó. El criterio que resuelve casi todos los casos cabe en una pregunta: ¿el usuario va a volver o va a terminar? Si va a volver a lo que estaba, con el contenido intacto y sin decidir nada, eso es una pila. Si va a terminar algo y su respuesta cambia el estado del mundo, eso es un modal. Y cuando ninguna de las dos respuestas convence, la señal casi siempre es que la pantalla está haciendo dos trabajos a la vez, y lo que hay que dividir no es la navegación: es la pantalla.
- Convierte una hoja gobernada por booleano en una gobernada por opcional y explica qué estado ha dejado de ser posible.
- Añade alturas parciales a una hoja de filtros y comprueba cómo cambia la percepción del contexto de fondo.
- Mete un
NavigationStackcon dos pasos dentro de un modal, con cancelar y guardar en la barra. - Sustituye una cubierta a pantalla completa por una hoja en un flujo con datos sin guardar y describe el riesgo que aparece.
- Toma tres pantallas de una app tuya y clasifícalas con la pregunta volver o terminar, justificando cada una.