El enum Destination: todas las salidas de una feature en un solo tipo
Una pantalla real no lleva a un sitio: lleva a un detalle, a una hoja de edición, a una alerta de confirmación y a un diálogo de compartir. Modelar cada salida con su propio opcional multiplica los estados y devuelve el problema de las dos hojas simultáneas. Esta lección construye el patrón canónico de TCA: un enum `Destination` anotado con la macro `@Reducer`, que agrupa todos los destinos alcanzables desde una feature, se guarda en un único `@Presents` opcional y se compone con un solo `ifLet`. Revisamos qué genera la macro —los enums anidados `State` y `Action` y el cuerpo con un `ifCaseLet` por caso—, cómo se conectan las vistas con `scope` sobre los case paths encadenados, y qué invariante inquebrantable regala el diseño.
Las dos lecciones anteriores te dieron las dos piezas: un estado hijo opcional que existe o no, y un enum cuyos casos llevan estados hijos mutuamente excluyentes. El patrón que domina la navegación en TCA no es ninguna de las dos por separado, sino su composición exacta: un opcional de un enum. El opcional responde a la pregunta de si esta pantalla ha navegado a alguna parte; el enum responde a la pregunta de a cuál. Juntos forman un único valor que describe por completo la situación de navegación de una feature, y del que se deduce —sin margen para la interpretación— qué hay presentado encima. Ese valor se llama por convención destination, su tipo se llama Destination, y la macro @Reducer sobre un enum convierte el patrón en tres líneas de declaración.
- Componer el patrón canónico de navegación en árbol: un
@Presentsopcional sobre un enum de destinos. - Anotar un enum con
@Reducery saber qué tipos y qué cuerpo sintetiza la macro por ti. - Enrutar todas las salidas de una feature con un solo caso de acción y un solo
ifLet. - Conectar cada destino con su modificador de presentación mediante
scopesobre case paths encadenados.
Un enum de destinos anotado con @Reducer
Empieza por el enunciado del problema. Desde la pantalla de un artículo el usuario puede abrir el detalle del autor, editar el artículo o recibir una alerta de confirmación de borrado. Son tres features hijas distintas y tres presentaciones que jamás deben coexistir. La declaración canónica agrupa las tres en un enum y deja que la macro haga el trabajo pesado.
@Reducer
struct Articulo {
@Reducer
enum Destination {
case autor(Autor)
case edicion(Edicion)
case confirmarBorrado(AlertState<Alerta>)
@CasePathable
enum Alerta { case borrarConfirmado }
}
@ObservableState
struct State: Equatable {
var texto = ""
@Presents var destination: Destination.State?
}
enum Action {
case autorTocado
case editarTocado
case borrarTocado
case destination(PresentationAction<Destination.Action>)
}
}
Observa que los casos del enum no llevan estados, llevan reducers: Autor, Edicion. Esa es la novedad de la macro @Reducer aplicada a un enum. A partir de esa declaración sintetiza dos tipos anidados que sí son de datos y de mensajes: un Destination.State con un caso por cada destino portando el State de su reducer, y un Destination.Action con un caso por cada destino portando su Action. También sintetiza el cuerpo del reducer del enum, que no es otra cosa que un ifCaseLet por caso —exactamente el operador de la lección anterior, escrito por ti tantas veces como destinos tuvieras si la macro no existiera—. El caso de la alerta es el único que no lleva un reducer sino un AlertState, porque una alerta no tiene lógica propia: es datos puros más un enum de acciones que el padre interpreta.
Un solo opcional, un solo caso de acción, un solo ifLet
Con el enum declarado, el reducer padre se compone con el operador de la primera lección, porque Destination.State? no deja de ser un estado hijo opcional.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .autorTocado:
state.destination = .autor(Autor.State(id: state.autorID))
return .none
case .editarTocado:
state.destination = .edicion(Edicion.State(texto: state.texto))
return .none
case .borrarTocado:
state.destination = .confirmarBorrado(
AlertState {
TextState("Seguro que quieres borrarlo")
} actions: {
ButtonState(role: .destructive, action: .borrarConfirmado) {
TextState("Borrar")
}
}
)
return .none
case .destination(.presented(.confirmarBorrado(.borrarConfirmado))):
state.texto = ""
return .none
case .destination:
return .none
}
}
.ifLet(\.$destination, action: \.destination)
}
Repara en la última línea: ifLet sin closure. Cuando el estado opcional es un enum anotado con @Reducer, la macro ya proporciona el reducer del destino y no hay nada que pasar. Repara también en la aritmética de la declaración: tres destinos, tres pantallas, un solo campo de estado, un solo caso de acción y un solo operador de composición. Añadir un cuarto destino mañana es añadir un caso al enum y un modificador a la vista; el State del padre, su Action y su body no cambian de forma.
Un valor que lo dice todo
destination responde a la vez si hay navegación y hacia dónde. No hay segunda fuente de verdad que consultar.
La macro escribe el ifCaseLet
@Reducer sobre un enum sintetiza State, Action y un cuerpo con un ifCaseLet por destino.
Case paths encadenados
\.destination?.edicion navega opcional y caso de una vez para alimentar el scope de la vista.
Invariante gratis
Con un solo opcional de enum, dos destinos simultáneos no son un fallo evitable: son un valor inexpresable.
Nada de lo que hace @Reducer sobre un enum es mágico ni te está vedado. Puedes escribirlo a mano: declaras enum State con un caso por destino, declaras enum Action igual, marcas ambos con @CasePathable y escribes un body que encadene un ifCaseLet por cada caso. La macro existe porque ese código es mecánico, largo y proporcional al número de destinos, no porque esconda una construcción distinta de las que ya conoces. Expándela una vez con la utilidad del Nivel 5 y verás salir, literalmente, los operadores de la lección anterior. Esa comprobación es la que convierte el patrón de receta memorizada en consecuencia derivable, y es también la que te permitirá depurar el día en que el compilador se queje de un case path que no existe.
La vista: un modificador por destino sobre el mismo store
En la vista, cada destino se conecta con su modificador de presentación natural, y todos beben del mismo campo de estado a través de un scope que encadena el opcional con el case path del destino.
struct ArticuloView: View {
@Bindable var store: StoreOf<Articulo>
var body: some View {
Form { Text(store.texto) }
.sheet(
item: $store.scope(state: \.destination?.edicion, action: \.destination.edicion)
) { store in
EdicionView(store: store)
}
.navigationDestination(
item: $store.scope(state: \.destination?.autor, action: \.destination.autor)
) { store in
AutorView(store: store)
}
.alert(
$store.scope(state: \.destination?.confirmarBorrado, action: \.destination.confirmarBorrado)
)
}
}
Los tres modificadores conviven en la misma vista sin conflicto porque los tres consultan el mismo valor y a lo sumo uno de los tres scope produce un store no nulo. Si el destino es .edicion, el scope del autor devuelve nulo y su navigationDestination permanece inerte. La coordinación entre presentaciones, que en el mundo imperativo exige saber qué se presentó antes y esperar a que termine de animarse, aquí no existe como problema porque nunca hay dos candidatos.
flowchart TD
S[campo destination opcional] --> Q{hay valor}
Q -->|nil| P[ninguna pantalla presentada]
Q -->|caso autor| A[navigationDestination con AutorView]
Q -->|caso edicion| E[sheet con EdicionView]
Q -->|caso confirmar borrado| C[alert con AlertState]
style P fill:#89b4fa,color:#11111b
style S fill:#a6e3a1,color:#11111bCuando alguien llega nuevo a una feature escrita con este patrón, hay un sitio y solo uno donde mirar para saber a dónde puede ir el usuario desde esa pantalla: el enum Destination. No hay que rastrear llamadas de presentación por el árbol de vistas, ni buscar quién pone a verdadero qué bandera, ni reconstruir el flujo ejecutándolo mentalmente. La topología de salidas está declarada como un tipo, y un tipo se lee de un vistazo. Eso convierte al enum en algo más que una técnica de modelación: es documentación ejecutable de la arquitectura de navegación, con la propiedad de que no puede quedar desactualizada porque el compilador la verifica. Y la verificación va en las dos direcciones. Si añades un destino y olvidas su modificador en la vista, el destino existirá en el estado sin mostrarse y el aviso llegará pronto; si eliminas un destino y queda un scope apuntando a él, no compila. Súmale que el valor es serializable, restaurable y afirmables en un test, y obtienes que el deep link, la restauración de sesión y la prueba automatizada del flujo dejan de ser tres problemas de ingeniería distintos para ser tres formas de escribir el mismo valor. La pregunta que una app corriente no sabe responder —qué tienes presentado ahora mismo— aquí se contesta imprimiendo un campo. Ese es el rendimiento entero del patrón: haber transformado la navegación, que es la parte más imperativa e histórica de un framework de interfaz, en un dato inspeccionable de un tipo que cabe en cinco líneas.
- Toma una pantalla tuya con tres presentaciones controladas por opcionales o banderas independientes y cuenta cuántos estados representables tiene su navegación hoy.
- Reescríbela con un enum
Destinationanotado con@Reducer, un@Presentsy un solo caso de acción. Cuenta de nuevo los estados representables. - Expande la macro e identifica en la salida los
ifCaseLetque habrías escrito a mano. Compara la longitud de ambas versiones. - Conecta los tres modificadores de vista con
scopesobre case paths encadenados e intenta, deliberadamente, que dos se muestren a la vez. Explica por qué no lo consigues. - Escribe un test que abra la edición, la descarte con
.dismiss, abra la alerta y confirme el borrado, afirmando el valor completo dedestinationen cada paso.