wandres.dev
@PRESENTS · sheets, alerts, popovers

Presentaciones anidadas: un sheet que abre otro sheet

Una hoja que presenta una hoja que presenta una alerta es el punto donde la navegación imperativa se rompe: aparecen banderas que hay que coordinar entre pantallas, cierres en cascada que se olvidan y estados imposibles que nadie sabe reproducir. En TCA la profundidad no añade ningún mecanismo nuevo, solo repite el mismo: cada feature declara su propio hueco `@Presents` y su propio `ifLet`, y el resultado es un árbol de estado isomorfo al árbol de pantallas. Esta lección monta un anidamiento de tres niveles, sigue el viaje de una acción desde el nieto hasta la raíz, explica por qué cada nivel debe traducir en lugar de dejar mirar, y muestra qué regalan gratis el descarte en cascada y el deep link cuando toda la jerarquía es un valor.

⏱ 19 min

Las presentaciones de un solo nivel las resuelve cualquier arquitectura con relativa dignidad. La prueba de fuego llega con la segunda: una lista presenta un detalle, el detalle presenta un editor y el editor, al intentar cerrarse con cambios sin guardar, presenta una alerta. En el modelo imperativo esa cadena es donde se acumulan los bugs legendarios —la hoja que no se cierra porque el booleano equivocado sigue en true, el editor que reaparece tras volver atrás, la alerta que se muestra sobre una pantalla que ya no existe— y donde los tests dejan de escribirse porque reproducir el escenario cuesta más que arreglar el bug. TCA no aporta ninguna herramienta especial para la profundidad, y ese es justamente el titular de la lección: aporta que no haga falta ninguna. La composición es cerrada respecto a sí misma, así que anidar presentaciones es aplicar tres veces el mismo patrón de tres piezas, y el resultado es un árbol de estado que puedes imprimir, comparar y construir de una sentada.

🎯 Al terminar esta lección sabrás
  • Montar un anidamiento de tres niveles donde cada feature declara su propio hueco y su propio ifLet.
  • Colocar cada modificador de presentación en la vista de la feature que lo posee, y no en la del ancestro.
  • Traducir los hechos del nieto en cada nivel intermedio en lugar de permitir que la raíz mire hacia abajo.
  • Aprovechar el descarte en cascada, el deep link directo y los tests de flujo que el árbol de estado hace triviales.

Cada feature con su hueco

El anidamiento no se declara en ningún sitio: emerge de que la State de una feature presentada contiene a su vez un hueco de presentación. Nadie escribe la palabra profundidad.

@Reducer
struct Inventario {
  @Reducer
  enum Destino {
    case detalle(Detalle)
  }

  @ObservableState
  struct State: Equatable {
    var articulos: IdentifiedArrayOf<Articulo> = []
    @Presents var destino: Destino.State?
  }

  enum Action {
    case articuloPulsado(Articulo.ID)
    case destino(PresentationAction<Destino.Action>)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .articuloPulsado(id):
        guard let articulo = state.articulos[id: id] else { return .none }
        state.destino = .detalle(Detalle.State(articulo: articulo))
        return .none

      case let .destino(.presented(.detalle(.delegate(.articuloActualizado(articulo))))):
        state.articulos[id: articulo.id] = articulo
        return .none

      case .destino: return .none
      }
    }
    .ifLet(\.$destino, action: \.destino)
  }
}

La feature presentada repite el patrón sin saber que lo repite: declara su propio enum de destino —aquí con dos casos, un editor y una alerta de borrado—, su propio hueco y su propio contrato publicado.

@Reducer
struct Detalle {
  @Reducer
  enum Destino {
    case editar(Editor)
    case confirmarBorrado(AlertState<Alerta>)

    @CasePathable
    enum Alerta: Equatable { case borrar }
  }

  @ObservableState
  struct State: Equatable {
    var articulo: Articulo
    @Presents var destino: Destino.State?
  }

  enum Action {
    case delegate(Delegate)
    case editarPulsado
    case destino(PresentationAction<Destino.Action>)

    @CasePathable
    enum Delegate: Equatable { case articuloActualizado(Articulo) }
  }
}

El estado resultante es un árbol literal: Inventario.State contiene opcionalmente un Detalle.State, que contiene opcionalmente un Editor.State o una alerta. Y como cada nivel aplica su propio ifLet, cada nivel obtiene por separado las tres garantías del nivel: su reducer corre solo mientras exista su estado, sus efectos se cancelan al desaparecer, y su presentación tiene identidad propia.

En la vista ocurre lo mismo con una regla que conviene grabar: el modificador vive en la vista de quien posee el hueco. La vista del inventario presenta el detalle; la vista del detalle presenta el editor y la alerta. Un ancestro que intentara presentar al nieto directamente estaría reclamando un estado que no es suyo.

Form { /* ... */ }
  .sheet(item: $store.scope(state: \.destino?.editar, action: \.destino.editar)) { editorStore in
    NavigationStack { EditorView(store: editorStore) }
  }
  .alert($store.scope(state: \.destino?.confirmarBorrado, action: \.destino.confirmarBorrado))
💡
Nunca cuelgues dos presentaciones del mismo nivel de la misma vista

SwiftUI no admite que una vista presente dos hojas a la vez, y si lo intentas obtendrás una advertencia en tiempo de ejecución y una presentación silenciosamente ignorada. El error aparece cuando alguien decide, por comodidad, poner el modificador del editor en la vista del inventario en lugar de en la del detalle. La regla del hueco lo previene por construcción: si el editor está dentro de Detalle.State, su modificador va en la vista del detalle y punto. Cuando de verdad necesites que la misma vista presente varias cosas, hazlo con un enum de destino y un modificador por caso, que es justo lo que hace el fragmento de arriba con el editor y la alerta.

El viaje de una acción entre tres niveles

La acción de un nieto llega a la raíz envuelta en dos capas de presentación, y escribir el patrón completo es tan posible como desaconsejable.

// ANTIPATRÓN: la raíz mira dentro del nieto
case .destino(.presented(.detalle(.destino(.presented(.editar(.delegate(.guardado(articulo)))))))):

Ese patrón compila y funciona, y acopla el inventario a la existencia del editor, a su posición en la jerarquía y a la forma exacta del destino del detalle. La regla que lo corrige es la del contrato delegate llevada a la profundidad: cada nivel traduce. El detalle observa el delegate del editor, decide qué hacer con él y, si el hecho importa también arriba, publica un hecho propio en su vocabulario.

// En Detalle: escucha al hijo y publica un hecho propio
case let .destino(.presented(.editar(.delegate(.guardado(articulo))))):
  state.articulo = articulo
  state.destino = nil
  return .send(.delegate(.articuloActualizado(articulo)))

Así, el inventario nunca menciona al editor: solo conoce el contrato del detalle. Si mañana el detalle sustituye su editor por otro distinto, o edita en línea sin presentar nada, el inventario ni se entera. La traducción en cada nivel cuesta tres líneas por salto y es lo único que impide que el árbol de features se convierta en un árbol de dependencias.

En rutas de caso, esas capas se atraviesan sin ceremonia porque PresentationAction se salta sola, lo que hace los tests de flujo sorprendentemente legibles:

await store.send(\.destino.detalle.editarPulsado) {
  $0.destino?.detalle?.destino = .editar(Editor.State(borrador: .prueba))
}
await store.send(\.destino.detalle.destino.editar.delegate.guardado, articuloEditado)
await store.receive(\.destino.detalle.delegate.articuloActualizado)

Lo que el árbol regala

Cuatro propiedades vienen incluidas y ninguna exigió código adicional. El descarte en cascada: asignar nil al hueco de la raíz destruye el subárbol entero y cancela de golpe los efectos de todos sus descendientes, a cualquier profundidad. La ausencia de estados imposibles: cada nivel tiene un hueco único y tipado, así que no existe la combinación de editor abierto sin detalle presentado. Los flujos afirmables: una travesía de tres pantallas es la secuencia de acciones y comparaciones de valor que acabas de leer, sin esperas ni inspección de jerarquías de vistas. Y el deep link de un salto, que es la más vistosa de las cuatro porque construye a mano lo que el usuario tardaría cuatro toques en alcanzar.

// La pantalla profunda es un valor, no una coreografía
Inventario.State(
  articulos: [articulo],
  destino: .detalle(Detalle.State(
    articulo: articulo,
    destino: .editar(Editor.State(borrador: articulo))
  ))
)
flowchart TD
R[Inventario State] -->|Presents destino| D[Detalle State]
D -->|Presents destino| E[Editor State]
D -->|o bien| A[AlertState confirmar borrado]
E -->|delegate guardado| D
D -->|delegate articuloActualizado| R
R -->|asignar nil| X[Subarbol destruido y efectos cancelados]
style D fill:#89b4fa,color:#11111b
style X fill:#f38ba8,color:#11111b
El árbol de pantallas y el árbol de estado son el mismo árbol, y por eso la profundidad deja de costar

Lo que hace manejable el anidamiento no es ninguna facilidad de la biblioteca, sino una correspondencia estructural que conviene enunciar con precisión: en una app escrita así, la jerarquía de pantallas visibles y la jerarquía de valores anidados en el estado son la misma jerarquía vista desde dos sitios. A cada pantalla presentada le corresponde exactamente un hueco lleno, y a cada hueco lleno exactamente una pantalla; presentar es llenar, descartar es vaciar, y no existe ninguna operación en un lado que no tenga su gemela en el otro. Esa correspondencia es lo que elimina de raíz la categoría entera de bugs de navegación, porque todos ellos son, sin excepción, desincronizaciones entre las dos jerarquías: el booleano que se quedó en true cuando la pantalla ya se fue, la pantalla que sigue ahí cuando el modelo cree que la cerró, el nieto vivo bajo un padre difunto. Ninguna de esas situaciones es representable cuando el modelo no describe la pantalla sino que es la pantalla. Y hay una segunda consecuencia, menos evidente y más profunda, que explica por qué el coste no crece con la profundidad. El patrón de tres piezas —hueco anotado, acción envuelta, ifLet— no distingue entre presentar una feature simple y presentar una feature que a su vez presenta: la operación de composición es cerrada respecto a su propio resultado, igual que sumar dos números da un número que se puede volver a sumar. Por eso el nivel tres se escribe igual que el nivel uno, y por eso una app de cincuenta pantallas no tiene cincuenta mecanismos de navegación sino uno solo aplicado cincuenta veces. La ceremonia que TCA cobra por presentar una sola hoja parece cara mientras hay una hoja; se paga sola en cuanto hay tres, y a partir de la quinta la comparación deja de ser con otra arquitectura y pasa a ser con no tener el problema. Un flujo entero de la aplicación es, literalmente, un valor: se construye, se guarda, se compara, se envía por la red y se afirma en una prueba, y todo lo que en la navegación imperativa era una secuencia irrepetible de sucesos aquí es un dato que puedes escribir con las manos.

⚔️ Anida tres niveles y luego destrúyelos de un golpe
  1. Toma dos features que ya presentes por separado y encadénalas: haz que la presentada declare su propio hueco @Presents y su propio ifLet para presentar a la tercera.
  2. Comprueba dónde has puesto cada modificador. Si alguno cuelga de la vista de un ancestro en lugar de la de su dueño, muévelo y observa la advertencia que desaparece.
  3. Busca cualquier patrón de la raíz contra el interior del nieto y sustitúyelo por una traducción en el nivel intermedio, con su propio caso delegate.
  4. Escribe un deep link que construya el estado de los tres niveles en una sola expresión y arranca la app con él. Confirma que las tres pantallas aparecen sin ninguna secuencia de toques.
  5. Con el editor abierto y un efecto de larga vida corriendo en él, asigna nil al hueco de la raíz. Verifica en la consola que el efecto del nieto muere en ese mismo instante: eso es la cascada.