wandres.dev
@PRESENTS · sheets, alerts, popovers

El descarte: quién cierra la pantalla y con qué autoridad

Abrir una pantalla es fácil porque solo hay un candidato a hacerlo: el padre, que asigna el hueco. Cerrarla es la decisión de diseño más delicada del nivel, porque hay tres actores posibles —el sistema con un gesto, el padre poniendo `nil`, el hijo pidiéndolo con la dependencia `dismiss`— y cada uno implica un reparto de autoridad distinto. Esta lección compara los tres mecanismos, muestra por qué un caso `delegate` llamado `cierrame` es un antipatrón mientras que uno llamado `articuloGuardado` no lo es, explica por qué `dismiss` es asíncrono y debe invocarse desde un efecto, y termina en la regla que resuelve el noventa por ciento de los casos reales.

⏱ 19 min

Presentar tiene un solo protagonista posible. El hueco vive en el estado del padre, así que solo el padre puede llenarlo, y no hay debate. Cerrar es otra historia, porque la pantalla que debe desaparecer suele ser precisamente la que sabe que ha terminado: el editor que acaba de guardar, el formulario que el usuario canceló, la pantalla de pago que recibió la confirmación. El hijo tiene la información y el padre tiene la autoridad, y toda la dificultad de esta lección consiste en juntar las dos cosas sin que ninguna feature acabe sabiendo dónde vive. TCA ofrece dos caminos legítimos para ello y uno que parece legítimo y no lo es, y distinguirlos exige entender qué está diciendo exactamente cada mecanismo cuando se dispara.

🎯 Al terminar esta lección sabrás
  • Enumerar los tres orígenes posibles de un descarte y qué acción produce cada uno.
  • Cerrar desde el padre reaccionando a un hecho publicado por el hijo mediante un caso delegate.
  • Usar la dependencia dismiss para que el hijo termine su propia presentación sin nombrar a nadie.
  • Aplicar la regla de decisión entre ambos patrones y reconocer el antipatrón del delegate imperativo.

Tres maneras de vaciar un hueco

Todo descarte, venga de donde venga, termina en lo mismo: el hueco anotado vuelve a ser nulo y ifLet cancela los efectos del hijo. Lo que cambia es quién lo inicia y qué queda registrado.

Origen Qué ocurre en el ciclo Cuándo es el adecuado
Gesto del sistema el binding envía PresentationAction.dismiss descartes sin consecuencias | arrastrar la hoja | tocar fuera
El padre asigna nil mutación directa dentro del reducer padre el padre reacciona a un hecho | el padre impone una política
La dependencia dismiss efecto asíncrono que envía dismiss hacia arriba el hijo ha terminado y el cierre no lleva información

Los tres son válidos y coexisten en cualquier app seria. El error no consiste en elegir mal entre ellos, sino en inventar un cuarto que no existe: que el hijo ordene al padre cerrarlo mediante un caso delegate con nombre de imperativo. Ese cuarto camino compila, funciona y destruye la propiedad que hace reutilizable a una feature.

El padre decide, el hijo informa

El patrón por defecto, y el que deberías escribir salvo que tengas una razón para no hacerlo, reparte el trabajo según quién sabe qué. El hijo sabe que ocurrió un hecho del dominio y lo publica. El padre sabe qué significa ese hecho en su contexto y decide, entre otras cosas, si implica cerrar.

@Reducer
struct Editor {
  enum Action {
    case delegate(Delegate)
    case guardarPulsado
    case guardadoConExito(Articulo)

    @CasePathable
    enum Delegate: Equatable {
      case articuloGuardado(Articulo)      // un hecho, no una orden
    }
  }
  // ...
}
// En el padre
case let .destino(.presented(.editar(.delegate(.articuloGuardado(articulo))))):
  state.articulos[id: articulo.id] = articulo
  state.destino = nil                      // cerrar es decisión del padre
  return .none

La ventaja se ve al imaginar un segundo padre. Una pantalla de creación rápida podría querer que el editor siguiera abierto tras guardar, listo para el siguiente artículo; una pantalla de importación podría querer encadenar tres editores; una vista dividida de iPad podría no tener nada que cerrar porque el editor está siempre visible. Con el hijo publicando un hecho, los tres contextos reutilizan el mismo editor sin tocarlo. Con el hijo emitiendo cierrame, el editor ha decidido por adelantado la política de todos sus futuros padres, y el segundo contexto exige o modificar al hijo o ignorar deliberadamente su orden, que es la clase de contrato que nadie recuerda seis meses después.

💡
Cómo distinguir un `delegate` legítimo de uno imperativo

Lee el nombre del caso en voz alta y pregúntate si describe algo que pasó o algo que alguien debe hacer. articuloGuardado, sesionCaducada, pagoCompletado describen hechos y sobreviven a cualquier rediseño. cierraElEditor, vuelveAtras, navegaAAjustes describen órdenes y llevan dentro una suposición sobre la jerarquía de pantallas. Hay un caso frontera que confunde a mucha gente: cancelado. Parece una orden de cerrar, pero no lo es —es el hecho de que el usuario abandonó la tarea— y es perfectamente legítimo, siempre que el padre siga siendo quien decide qué hacer con esa información. Aun así, si el cierre es lo único que va a ocurrir, la dependencia dismiss de la sección siguiente lo dice mejor y con menos ceremonia.

Cerrarse sin nombrar a nadie

Hay un caso frecuente en que el patrón anterior resulta desproporcionado: el hijo no tiene ningún hecho que comunicar y solo quiere terminar. Un botón de cerrar, una pantalla informativa que se agota, un onboarding que llegó a su última página. Declarar un enum Delegate con un único caso vacío para que el padre lo traduzca a nil es ceremonia pura. Para eso existe la dependencia dismiss.

@Reducer
struct Bienvenida {
  @Dependency(\.dismiss) var dismiss

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .cerrarPulsado, .ultimaPaginaVista:
        return .run { _ in await self.dismiss() }

      // ...
      }
    }
  }
}

Tres propiedades de ese código merecen comentario. La primera: dismiss es asíncrona y por tanto solo puede invocarse dentro de un efecto, nunca en el cuerpo del reducer; la restricción no es un capricho del diseño de la API sino la consecuencia de que el reducer es una función pura que no puede provocar cambios por sí misma, ni siquiera en su propia presentación. La segunda: el hijo no menciona al padre ni sabe si está en una hoja, en un popover o empujado en una pila; simplemente pide terminar, y quien resuelva esa petición dependerá de cómo lo hayan presentado. La tercera, y la más fácil de olvidar: el efecto que llama a dismiss está sujeto a la cancelación automática del hijo, así que si algo cierra la pantalla mientras ese efecto espera, la llamada nunca llega y no pasa nada, que es exactamente el comportamiento correcto.

Existe además una dependencia hermana, isPresented, que permite al hijo saber si está presentado por alguien, útil para features que sirven tanto de raíz como de pantalla modal. Usarla para bifurcar comportamiento es lícito; usarla para averiguar quién es el padre no lo es, porque el hijo no puede averiguarlo y la pregunta ya está mal formulada.

flowchart TD
H[Feature hija] -->|hecho del dominio| DEL[Accion delegate]
H -->|solo quiere terminar| DIS[Dependencia dismiss dentro de un efecto]
DEL --> P[Reducer padre decide]
P -->|asigna nil| VAC[Hueco vacio]
DIS -->|PresentationAction dismiss| IL[ifLet]
GES[Gesto del usuario] -->|el binding escribe nil| IL
IL --> VAC
VAC --> CAN[Efectos del hijo cancelados]
style DEL fill:#a6e3a1,color:#11111b
style DIS fill:#89b4fa,color:#11111b

La regla de decisión, ya con los tres caminos a la vista, cabe en dos frases. Si el cierre transporta información que el padre necesita, el hijo publica el hecho con un caso delegate y el padre cierra; si el cierre no transporta nada y es simplemente el final de la tarea, el hijo llama a dismiss y nadie más se entera.

Queda un cuarto supuesto que la intuición pide y que conviene resolver bien: la pantalla que quiere impedir su propio cierre. Un formulario con cambios sin guardar no debería desaparecer al primer toque, y el sitio correcto para esa política no es el padre —que no sabe si hay borrador pendiente— sino el propio hijo, que retrasa su petición hasta tener el permiso del usuario.

case .cerrarPulsado:
  guard state.hayCambiosSinGuardar else {
    return .run { _ in await self.dismiss() }
  }
  state.alerta = AlertState {
    TextState("Descartar los cambios")
  } actions: {
    ButtonState(role: .destructive, action: .descartar) { TextState("Descartar") }
    ButtonState(role: .cancel) { TextState("Seguir editando") }
  }
  return .none

case .alerta(.presented(.descartar)):
  return .run { _ in await self.dismiss() }

Nota que el hijo nunca bloquea nada: simplemente no pide cerrarse hasta que corresponde. El único descarte que se le escapa a esa disciplina es el gesto interactivo del sistema, que ocurre antes de que ninguna acción llegue al reducer; si tu formulario no puede permitírselo, díselo a SwiftUI con interactiveDismissDisabled en la vista del hijo y todos los cierres volverán a pasar por el ciclo.

📤

El cierre lleva un dato

El hijo publica un caso delegate con el hecho y el padre decide, entre otras cosas, si vaciar el hueco.

🚪

El cierre no lleva nada

El hijo invoca dismiss desde un efecto y termina sin nombrar a nadie ni declarar contrato alguno.

🛡️

El cierre debe confirmarse

El hijo presenta su propia alerta y solo pide dismiss cuando el usuario confirma; el gesto se ata con interactiveDismissDisabled.

🧭

Varios padres, varias políticas

El mismo hijo publica siempre el mismo hecho y cada padre decide si eso significa cerrar, encadenar o no hacer nada.

El hijo que se cierra a sí mismo sin saber dónde está: inversión de dependencia hecha con el entorno, no con un puntero

Merece la pena preguntarse qué es realmente dismiss, porque a primera vista parece violar todo lo que este track viene sosteniendo. Una feature hija ejecuta una función y, como consecuencia, deja de existir dentro del estado de otra feature a la que jamás ha nombrado. Si eso fuera una llamada a un objeto padre, sería exactamente el acoplamiento que llevamos catorce niveles evitando. No lo es, y el mecanismo que lo evita es el mismo que ya viste con las dependencias del sistema: el hijo no invoca a nadie concreto, declara una capacidad abstracta que su entorno de ejecución debe proveerle. Quien la provee es la maquinaria de presentación: cuando ifLet monta al hijo, inyecta en su ámbito un valor de dismiss que sabe enviar la acción correcta al hueco correcto del padre correcto, y esa información vive fuera del hijo, en el punto donde se decidió presentarlo. La feature sigue sin saber dónde está; el conocimiento lo tiene el sitio que sí lo sabe, que es quien la presentó. Lo elegante del arreglo es que la relación jerárquica —quién presenta a quién— queda expresada una sola vez, en el ifLet del padre, y desde ahí alimenta tanto el descenso de las acciones como el ascenso de las peticiones de cierre, sin que aparezca ni un puntero ni un protocolo ni un registro de delegados. Y hay una consecuencia práctica que revela lo bien puesta que está la frontera: el mismo hijo, con la misma llamada a dismiss, funciona en una hoja, en un popover, empujado en una pila o presentado desde otro hijo a tres niveles de profundidad, porque la pregunta de qué significa cerrarse la responde el entorno en cada montaje y no el código de la feature. La autonomía del hijo y la autoridad del padre dejan de ser objetivos en conflicto: son dos caras de un mismo dato, la relación de presentación, escrito en un solo sitio y leído desde los dos lados.

⚔️ Audita los cierres de tu app y reasigna la autoridad
  1. Lista todos los puntos donde una pantalla se cierra en tu app y clasifica cada uno según los tres orígenes de la tabla. Anota cuántos no encajan en ninguno.
  2. Busca casos delegate con nombre imperativo —cierra, vuelve, navega— y renómbralos al hecho del dominio que los provoca, moviendo la decisión de cerrar al padre.
  3. Localiza un hijo que solo publique un delegate para poder cerrarse y sustitúyelo por @Dependency(\.dismiss) invocado desde un efecto. Comprueba que el enum Delegate puede desaparecer entero.
  4. Presenta esa misma feature desde un segundo padre con otra política de cierre. Si no has tenido que tocar al hijo, la autoridad está bien repartida.
  5. Escribe el caso difícil: un formulario con cambios sin guardar que, al recibir dismiss, no se cierra sino que presenta una alerta de confirmación. Verifica en un test que la primera petición de cierre no vacía el hueco.