En la vista: `sheet`, `fullScreenCover` y `popover` con `$store.scope`
El hueco ya está en el estado y el reducer hijo ya corre dentro de `ifLet`; falta que SwiftUI presente algo. El puente son las sobrecargas de `sheet`, `fullScreenCover`, `popover` y `navigationDestination` que aceptan un binding a un store opcional, producido por `$store.scope`. Esta lección explica por qué el modificador recibe un store y no un booleano, qué escribe exactamente ese binding cuando el usuario arrastra la hoja hacia abajo, cómo se rebana un caso concreto de un enum de destino con el key path opcional, y por qué la vista nunca decide presentar: solo refleja un estado que ya decidió.
En SwiftUI corriente, presentar una hoja es cosa de un booleano que la vista posee y voltea. Ese diseño tiene un defecto que solo se nota cuando la app crece: la verdad de qué hay en pantalla vive en la capa de vistas, dispersa entre banderas locales, y no hay forma de preguntarle al modelo dónde está el usuario. TCA invierte el sentido de la flecha. El modelo ya sabe qué hay presentado, porque presentar fue asignar un valor a un hueco, y lo que la vista necesita no es decidir sino enterarse. La pieza que la entera es una familia de sobrecargas de los modificadores de siempre que, en lugar de un Bool o un identificable suelto, aceptan un binding a un store opcional: si el store existe, hay pantalla y ahí está su feature completa; si no existe, no hay nada que presentar. Toda la lección cabe en esa sustitución, pero sus consecuencias llegan hasta el testing.
- Derivar un store opcional para el hijo presentado con
$store.scopesobre un estado marcado con@Presents. - Elegir entre
sheet,fullScreenCover,popoverynavigationDestinationsegún la forma de presentación. - Rebanar un caso concreto de un enum de destino combinando key path opcional en el estado y ruta de caso en la acción.
- Explicar qué acción se envía cuando el usuario descarta con un gesto y por qué la vista nunca muta el estado directamente.
Del hueco del estado al binding de SwiftUI
La vista declara su store como @Bindable y deriva de él un binding a un store hijo opcional. Ese binding es lo único que el modificador necesita.
struct InventarioView: View {
@Bindable var store: StoreOf<Inventario>
var body: some View {
List {
ForEach(store.articulos) { articulo in
Button(articulo.nombre) { store.send(.articuloPulsado(articulo.id)) }
}
}
.sheet(item: $store.scope(state: \.detalle, action: \.detalle)) { detalleStore in
DetalleView(store: detalleStore)
}
}
}
Tres detalles merecen atención en esas dos líneas. El primero es el item: en lugar de isPresented:: la sobrecarga que usamos no recibe un booleano sino un valor opcional identificable, y aquí ese valor es el propio store del hijo, que TCA hace conformar a Identifiable usando el identificador de presentación de la lección anterior. Presentar y descartar dejan de ser dos estados de una bandera y pasan a ser la presencia o ausencia de un objeto con identidad, que es justo lo que SwiftUI necesita para animar el reemplazo de un detalle por otro sin cerrar y reabrir la hoja.
El segundo es el key path sin dólar. En el reducer escribías \.$detalle porque ifLet necesita el envoltorio completo; aquí escribes \.detalle porque la vista solo quiere el valor, y @ObservableState se encarga de que leerlo registre la dependencia de observación. El tercero es el más importante y el menos visible: DetalleView recibe un StoreOf<Detalle> y nada más. Ni una closure de cierre, ni un binding al booleano del padre, ni una referencia al padre. La feature presentada ignora por completo que está dentro de una hoja.
Cuatro modificadores, un mismo patrón
La familia entera sigue la misma forma, así que elegir es una decisión de diseño de interfaz y no de arquitectura.
| Modificador | Presentación | Cuándo conviene |
|---|---|---|
sheet |
hoja modal que deja ver el fondo | tareas cortas, formularios, detalles secundarios |
fullScreenCover |
cubre la pantalla completa | flujos que exigen atención total | onboarding | cámara |
popover |
burbuja anclada a un control | opciones contextuales en pantallas anchas |
navigationDestination |
empuje dentro de un NavigationStack |
profundizar en la jerarquía sin salir de ella |
.fullScreenCover(item: $store.scope(state: \.bienvenida, action: \.bienvenida)) { bienvenidaStore in
BienvenidaView(store: bienvenidaStore)
}
.popover(item: $store.scope(state: \.filtros, action: \.filtros)) { filtrosStore in
FiltrosView(store: filtrosStore)
}
.navigationDestination(item: $store.scope(state: \.perfil, action: \.perfil)) { perfilStore in
PerfilView(store: perfilStore)
}
Cuando el hueco es un enum de destino, el scope gana una interrogación en el lado del estado y una ruta de caso en el lado de la acción, y se escribe un modificador por cada caso que quieras presentar de una forma distinta.
.sheet(item: $store.scope(state: \.destino?.editar, action: \.destino.editar)) { editorStore in
EditorView(store: editorStore)
}
.popover(item: $store.scope(state: \.destino?.ajustes, action: \.destino.ajustes)) { ajustesStore in
AjustesView(store: ajustesStore)
}
La lectura del par es directa: del estado, dame el hueco si está lleno y además si su caso es editar; de las acciones, envuelve lo que salga de ahí en destino y luego en editar. Como el hueco es uno solo, jamás habrá dos de esos modificadores con store no nulo a la vez, y por eso conviven en la misma vista sin pelearse por quién presenta.
Dos errores frecuentes al empezar. El primero es enlazar el mismo hueco a dos modificadores distintos —una sheet y un popover sobre \.destino?.editar— con la idea de adaptarse al tamaño de pantalla: acabas con dos presentaciones compitiendo por el mismo estado y comportamientos indefinidos al descartar. Si de verdad quieres formas distintas según el dispositivo, ramifica en la vista y aplica un solo modificador por rama. El segundo es olvidar @Bindable en la declaración del store: sin él no existe el $store del que cuelga scope, y el compilador te lo dirá con un error que menciona el operador de proyección y no la causa real.
Quién descarta cuando el usuario arrastra
Aquí está el detalle que separa esta integración de un truco de conveniencia. El binding que produce $store.scope no es de solo lectura: SwiftUI escribe en él cuando el usuario cierra la hoja con un gesto, pulsa fuera del popover o toca el botón atrás. Y lo que ese binding hace al recibir nil no es mutar el estado —la vista no tiene permiso para mutar nada— sino enviar al store la acción .destino(.dismiss), que viaja por el mismo camino unidireccional que cualquier otra.
flowchart TD ST[State con hueco presentado] --> SC[dollar store scope produce store opcional] SC --> MOD[Modificador sheet o popover] MOD --> V[Vista del hijo con su store] V -->|acciones del hijo| ST G[Gesto de descarte del usuario] --> B[El binding escribe nil] B -->|envia PresentationAction dismiss| RED[Reducer del padre e ifLet] RED --> ST style ST fill:#89b4fa,color:#11111b style B fill:#f9e2af,color:#11111b
La consecuencia práctica es enorme y conviene enunciarla sin rodeos: no existe ninguna forma de que una pantalla desaparezca sin que una acción lo cuente. El gesto más informal del sistema operativo —arrastrar una hoja hacia abajo— entra por la misma puerta que un toque en un botón, queda registrado en _printChanges, se puede afirmar en un TestStore y puede desencadenar efectos como cualquier otra acción. Esa propiedad es lo que hace que las cuatro cosas siguientes salgan gratis.
Testeable sin simulador
Afirmar que se presentó el editor es comparar el estado con .editar(...), sin inspeccionar jerarquías de vistas ni esperar animaciones.
Deep linking directo
Abrir la app en la hoja de edición es construir el State con el hueco ya lleno; la vista presenta al primer render.
Vista del hijo reutilizable
Como solo recibe su StoreOf, la misma vista sirve en hoja, en popover o empujada en la pila sin cambiar una línea.
Limpieza automática
El descarte por gesto pasa por ifLet, que vacía el hueco y cancela los efectos del hijo sin que nadie escriba código de limpieza.
La segunda de esas propiedades se comprueba en diez segundos y es la mejor prueba de que la integración está bien hecha: una preview que arranca con el hueco ya lleno debe mostrar la hoja abierta desde el primer fotograma, sin animación de apertura y sin que nadie envíe ninguna acción.
#Preview("Arrancando con el editor abierto") {
InventarioView(
store: Store(
initialState: Inventario.State(
articulos: [.prueba],
destino: .editar(Editor.State(borrador: .prueba))
)
) {
Inventario()
}
)
}
Si esa preview funciona, tu deep linking ya funciona, porque abrir la app desde una URL profunda es exactamente lo mismo con otro punto de entrada. Y si no funciona, el fallo casi siempre está en el mismo sitio: una vista que sigue guardando estado de presentación propio y necesita un evento para sincronizarse con el modelo.
Una última nota de oficio sobre la vista del hijo. Cuando la feature presentada necesita barra de navegación, botones de cabecera o su propio título, el NavigationStack va dentro de la closure del modificador y envuelve a la vista hija, no al revés: la pila pertenece a la presentación, no a la feature, y meterla dentro del hijo le impediría servir también empujada en la pila de su padre.
Hay una frase que se repite tanto en SwiftUI que ha perdido filo: la vista es una función del estado. Casi todo el mundo la acepta para el contenido —este texto, este color, esta lista— y sin embargo la abandona sin darse cuenta en cuanto aparece la navegación, porque un @State private var mostrandoHoja = false es exactamente estado de vista, local, invisible desde el modelo e irrecuperable desde fuera. Lo que hacen estas sobrecargas es cerrar esa fuga: extienden el dominio de la función hasta cubrir también qué pantalla hay encima. A partir de ahí la vista deja de tener secretos, y la afirmación se vuelve literal en lugar de aspiracional: dado un valor de State, la pantalla entera —contenido, jerarquía y presentaciones incluidas— queda determinada, y dos valores iguales producen dos pantallas indistinguibles. Merece la pena detenerse en la asimetría que sostiene todo esto, porque es sutil. La lectura del binding es una proyección hacia abajo, del árbol de estado al árbol de vistas, y es total: cualquier cambio en el hueco se refleja. La escritura, en cambio, no es una mutación simétrica hacia arriba sino una traducción: SwiftUI cree que está poniendo un opcional a nil y en realidad está emitiendo un mensaje que el reducer decidirá cómo interpretar, y que puede perfectamente decidir ignorar —un formulario con cambios sin guardar puede negarse a cerrarse y responder con una alerta de confirmación—. Ese pequeño gesto de traducir la escritura en lugar de aplicarla es lo que impide que la capa de vistas recupere por la puerta de atrás la autoridad que le quitamos por la principal. La vista puede pedir; solo el reducer dispone. Y como toda petición es un dato con nombre viajando por un canal único, la navegación deja de ser el rincón oscuro de la aplicación —ese sitio donde los tests no llegan y los bugs se reproducen una vez de cada veinte— y pasa a ser tan inspeccionable como incrementar un contador.
- Toma una feature hija que hoy presentes con
sheety unBool, y reescribe la presentación con$store.scopesobre un hueco anotado con@Presents. - Comprueba que la vista del hijo no recibe ningún parámetro además de su
StoreOf. Si recibe una closure de cierre o un binding del padre, quítalos y anota qué se rompe. - Cambia el modificador por
fullScreenCover, luego porpopovery luego pornavigationDestination, sin tocar ni una línea del hijo ni del reducer padre. Confirma que las cuatro funcionan. - Añade un enum de destino con un segundo caso y presenta cada caso con un modificador distinto en la misma vista. Verifica que abrir uno cierra el otro sin código de cierre.
- Descarta la hoja arrastrándola con
_printChangesactivo y localiza en la consola la accióndismiss. Ese registro es la prueba de que ningún gesto escapa al ciclo.