Presentar varias sheets en SwiftUI con un único enum
Arturo Rivas Arias
Cuando una pantalla de SwiftUI solo presenta una vista modal, un Bool parece la solución más natural. El problema aparece en cuanto esa misma vista necesita abrir un formulario de creación, un editor, una pantalla de filtros y alguna opción adicional. Cada destino incorpora su propio @State, su botón y su modificador .sheet, mientras la lógica de presentación queda repartida por todo el body.
struct TravelPlanView: View {
@State private var showsNewStop = false
@State private var showsFilters = false
@State private var stopToEdit: Stop?
var body: some View {
TripTimeline()
.sheet(isPresented: $showsNewStop) {
NewStopView()
}
.sheet(isPresented: $showsFilters) {
FilterStopsView()
}
.sheet(item: $stopToEdit) { stop in
EditStopView(stop: stop)
}
}
}
El código funciona, pero el número de variables y modificadores crece al mismo ritmo que los destinos. Además, el estado permite combinaciones que la interfaz no puede representar: showsNewStop y showsFilters podrían ser true al mismo tiempo. Aunque SwiftUI termine mostrando una única vista, el modelo ya no describe con precisión lo que ocurre en pantalla.
Una forma más escalable consiste en representar todos los destinos mediante un enum y almacenar únicamente un valor opcional. nil significa que no hay ninguna vista presentada; cualquier caso del enum identifica exactamente cuál debe aparecer. De este modo, los estados incompatibles dejan de ser una posibilidad.
sheet(item:) como base de la solución
SwiftUI ofrece dos variantes principales del modificador .sheet. La primera recibe un Binding<Bool> y solo expresa si existe una presentación. La segunda, sheet(item:onDismiss:content:), recibe un Binding<Item?>, exige que Item conforme Identifiable y entrega ese mismo valor al cierre que construye el contenido.
Esta segunda API combina en una sola fuente de verdad dos datos que a menudo se modelan por separado: si la vista debe mostrarse y qué contenido necesita. No hace falta activar primero un booleano y confiar en que otra propiedad ya contenga el valor correcto.
enum SheetDestination: String, Hashable, Identifiable {
case newStop
case filters
var id: Self { self }
}
struct TravelPlanView: View {
@State private var presentedSheet: SheetDestination?
var body: some View {
VStack {
Button("Añadir parada") {
presentedSheet = .newStop
}
Button("Filtrar itinerario") {
presentedSheet = .filters
}
}
.sheet(item: $presentedSheet) { destination in
switch destination {
case .newStop:
NewStopView()
case .filters:
FilterStopsView()
}
}
}
}
Al asignar un case, SwiftUI presenta la vista. Cuando la presentación termina, el binding vuelve a nil, por lo que tampoco es necesario mantener un booleano adicional. La transición de estado queda reducida a una única operación atómica: presentedSheet = .filters.
Hacer que el propio enum construya la vista
El switch anterior puede permanecer junto al modificador, pero también puede trasladarse al enum. Un enum puede conformar View igual que una estructura, y el @ViewBuilder asociado a body permite que cada rama devuelva un tipo de vista distinto sin recurrir a AnyView.
enum SheetDestination: String, Hashable, Identifiable, View {
case newStop
case filters
case tripSummary
var id: Self { self }
var body: some View {
switch self {
case .newStop:
NewStopView()
case .filters:
FilterStopsView()
case .tripSummary:
TripSummaryView()
}
}
}
La pantalla que presenta las vistas queda entonces centrada en los eventos de usuario y deja de conocer cómo se construye cada destino.
struct TravelPlanView: View {
@State private var presentedSheet: SheetDestination?
var body: some View {
TripTimeline(
onAddStop: { presentedSheet = .newStop },
onShowFilters: { presentedSheet = .filters },
onShowSummary: { presentedSheet = .tripSummary }
)
.sheet(item: $presentedSheet) { destination in
destination
}
}
}
Este patrón aprovecha la identidad estructural de SwiftUI. Apple explica en la sesión Demystify SwiftUI que los switch dentro de un view builder conservan la información estática de sus ramas. Frente a AnyView, el compilador sigue viendo la estructura concreta de la jerarquía, puede ofrecer mejores diagnósticos y no necesita borrar los tipos de las vistas.
Destinos con valores asociados
Los enum resultan todavía más útiles cuando una vistas necesita datos. El caso puede transportar el modelo que la vista presentada debe editar, haciendo que destino y contenido viajen juntos.
struct Stop: Identifiable {
let id: UUID
var name: String
var arrival: Date
}
enum SheetDestination: Identifiable, View {
case newStop
case editStop(Stop)
case filters
enum ID: Hashable {
case newStop
case editStop(UUID)
case filters
}
var id: ID {
switch self {
case .newStop:
.newStop
case let .editStop(stop):
.editStop(stop.id)
case .filters:
.filters
}
}
var body: some View {
switch self {
case .newStop:
NewStopView()
case let .editStop(stop):
EditStopView(stop: stop)
case .filters:
FilterStopsView()
}
}
}
Ahora la selección de una fila puede abrir directamente su editor sin mantener selectedStop y showsEditor como estados independientes.
ForEach(stops) { stop in
Button {
presentedSheet = .editStop(stop)
} label: {
StopRow(stop: stop)
}
}
La conformidad con Identifiable no es un trámite sintáctico. El identificador expresa qué entidad conceptual representa el valor a lo largo del tiempo. Por eso, en el ejemplo, el editor incorpora el identificador de la parada: editar dos paradas diferentes debe producir identidades distintas.
Una implementación como la siguiente es problemática:
var id: UUID { UUID() } // Incorrecto: cambia en cada lectura
SwiftUI interpreta un identificador nuevo como una entidad nueva. Un id calculado aleatoriamente puede acortar la vida de la vista presentada, reiniciar su estado local o producir transiciones inesperadas. Apple insiste en que los identificadores explícitos deben ser estables y únicos: un mismo destino conserva su identidad, mientras que dos destinos distintos no deben compartirla.
Para un enum sin valores asociados, var id: Self { self } es una solución sencilla siempre que el propio enum sea Hashable. Con valores asociados, un tipo ID específico suele comunicar mejor la intención y evita hacer Hashable modelos completos solo para poder presentar una vista.
Cerrar la vista desde su contenido
El enum decide qué presentar, pero no tiene que controlar también el cierre. La vista modal puede leer la acción dismiss del entorno y ejecutarla después de completar su trabajo.
struct NewStopView: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var arrival = Date()
var body: some View {
NavigationStack {
Form {
TextField("Nombre", text: $name)
DatePicker("Llegada", selection: $arrival)
}
.navigationTitle("Nueva parada")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancelar") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Guardar") {
saveStop()
dismiss()
}
.disabled(name.isEmpty)
}
}
}
}
private func saveStop() {
// Persistencia del nuevo elemento
}
}
La acción debe leerse dentro del contenido presentado. Si se obtiene desde la vista que aplica .sheet, dismiss() se refiere a ese contexto exterior y no necesariamente a la presentación que se quiere cerrar.
Cuándo no conviene que el enum conforme View
Hacer que el destino sea también una vista ofrece una sintaxis muy compacta, pero une dos responsabilidades: describir la navegación y construir la interfaz. En una pantalla pequeña esta proximidad suele ser una ventaja. En una aplicación modular puede convertirse en un acoplamiento incómodo, sobre todo cuando las vistas necesitan dependencias, bindings o acciones que pertenecen a una capa superior.
En esos casos, el enum puede limitarse a representar el estado y la vista presentadora puede resolver el contenido mediante una función @ViewBuilder.
enum SheetDestination: Identifiable {
case newStop
case editStop(Stop.ID)
case filters
enum ID: Hashable {
case newStop
case editStop(Stop.ID)
case filters
}
var id: ID {
switch self {
case .newStop:
.newStop
case let .editStop(id):
.editStop(id)
case .filters:
.filters
}
}
}
struct TravelPlanView: View {
@Environment(TripStore.self) private var store
@State private var presentedSheet: SheetDestination?
var body: some View {
TripTimeline()
.sheet(item: $presentedSheet) { destination in
sheetContent(for: destination)
}
}
@ViewBuilder
private func sheetContent(for destination: SheetDestination) -> some View {
switch destination {
case .newStop:
NewStopView(onSave: store.add)
case let .editStop(id):
if let stop = store.stop(withID: id) {
EditStopView(stop: stop, onSave: store.update)
} else {
ContentUnavailableView(
"Parada no disponible",
systemImage: "mappin.slash"
)
}
case .filters:
FilterStopsView(filters: store.filters)
}
}
}
Esta variante mantiene las ventajas esenciales —una única fuente de verdad y estados excluyentes— sin obligar al modelo de navegación a importar o conocer todas las vistas concretas. También facilita que el enum viva en una capa de dominio mientras la composición visual permanece en la de interfaz.
Un patrón reutilizable más allá de .sheet
La misma idea encaja con otras APIs de presentación basadas en un elemento identificable. SwiftUI dispone de variantes equivalentes para fullScreenCover(item:), popover(item:) y alert(item:). El enum puede actuar como un pequeño router local, aunque cada tipo de presentación debería mantener su propio estado si varias de ellas pueden coexistir de forma legítima.
No es necesario convertir todos los destinos de la aplicación en un enum global. Los routers pequeños y cercanos a cada pantalla suelen escalar mejor: delimitan qué presentaciones son válidas en ese contexto, permiten al compilador comprobar que el switch es exhaustivo y evitan que una nueva vista obligue a modificar zonas sin relación.
Conclusión
Modelar varias vistas con un enum no es únicamente una técnica para escribir menos .sheet. Es una mejora en la representación del estado: sustituye varios booleanos potencialmente contradictorios por un único valor que describe exactamente qué presentación está activa y qué datos necesita.
Cuando el contenido es sencillo, hacer que el enum conforme View produce una implementación breve y expresiva. Cuando existen dependencias o límites entre módulos, mantener el enum como estado puro y resolver las vistas con un @ViewBuilder conserva el desacoplamiento. En ambos casos, la calidad del patrón depende de una identidad estable, única y coherente con el destino que SwiftUI presenta.