Saltar al contenido
K
iOS con SwiftUIPaso a paso33 min de lectura · 15 min de máquina Intermedio

@EnvironmentObject: los ajustes para toda la app

Al terminar: Compartir los ajustes con toda la app mediante @EnvironmentObject.

Al terminar esta lección, la duración del partido (90 o 60 minutos) se elige en la hoja con un selector, y ese valor llega a la hoja sin pasar por ningún constructor: la raíz de la app es la dueña de los ajustes y los deja en el entorno, y la pantalla del partido los recoge de ahí. La hoja tendrá además un contador de las veces que la app pasó a segundo plano.

Importa porque pasar un dato por el constructor de cada vista, una detrás de otra, es incómodo cuando lo necesitan muchas pantallas (la duración, el tema, la sesión del usuario). @EnvironmentObject lo reparte a todo un árbol de vistas de una vez.

@EnvironmentObject: el tercero, sin pasar por el constructor

Con @StateObject y @ObservedObject, el objeto viaja de padre a hija por el constructor: la dueña se lo pasa a quien lo necesita, y esa se lo pasa a la siguiente. Si la vista que lo necesita está tres niveles más abajo, las dos del medio tendrían que pasarlo sin usarlo.

@EnvironmentObject evita ese relevo. Un ancestro pone el objeto en el entorno con .environmentObject(…), y cualquier vista de debajo lo recoge por su tipo, sin recibirlo por el constructor. Es como un tablón en la entrada del edificio: el que lo cuelga lo hace una vez, y cualquier vecino que pase puede mirarlo.

AtributoLa vista…El objeto lo crea…Si se recrea el struct de la vista
@StateObjectes su dueñala propia vistaSwiftUI conserva el mismo objeto
@ObservedObjectsolo observaotro, que lo mantiene vivoRecibe otra vez el que le pasan
@EnvironmentObjectlo recoge sin recibirlo por el constructorun ancestro, con .environmentObject(…)Sigue leyendo el del ancestro

Los tres, juntos, en un ejemplo mínimo que puedes leer sin escribirlo:

ejemplo mínimo: dueño, observador y entorno (con comentarios)
import Combine
import SwiftUI
 
final class Modelo: ObservableObject {
    @Published var minuto = 0
}
 
struct Padre: View {
    @StateObject private var modelo = Modelo()      // dueño: se crea una vez
    var body: some View {
        Hija(modelo: modelo)                        // se lo pasa a quien solo observa
            .environmentObject(modelo)              // y lo deja en el entorno para todos los descendientes
    }
}
 
struct Hija: View {
    @ObservedObject var modelo: Modelo              // sin valor inicial: viene de fuera
    var body: some View {
        VStack {
            Text("Minuto \(modelo.minuto)'")
            Nieta()                                 // la hija no le pasa nada a la nieta
        }
    }
}
 
struct Nieta: View {
    @EnvironmentObject var modelo: Modelo           // lo puso un ancestro: lo recoge por su tipo
    var body: some View { Text("\(modelo.minuto)") }
}

La Nieta no recibe el modelo por el constructor y aun así lo encuentra: sube por el árbol hasta dar con el .environmentObject(modelo) del Padre. Si no lo hubiera, la app compilaría pero se detendría al abrir esa pantalla.

En Android, @EnvironmentObject equivale a leer un CompositionLocal: el valor lo deja disponible un ancestro y se recoge sin pasarlo como parámetro.

No lo confundas con @Environment, que ya usaste con \.scenePhase en La app, sus escenas y el paquete Torneo. Los dos leen del entorno, pero de cosas distintas:

AtributoLee…Quién lo pusoEjemplo
@Environment(\.scenePhase)un dato del sistema, que se elige con una clave (\.scenePhase)iOS@Environment(\.scenePhase) private var fase
@EnvironmentObjecttu propio objeto, que se elige por su tipoun ancestro, con .environmentObject(…)@EnvironmentObject private var ajustes: AjustesModelo

Una última pieza antes del ejercicio, que verás también en el paso 2. Un objeto observable tiene un detalle que @State no tenía: para que un control (un Toggle, un Picker) cambie una propiedad del objeto, se le pasa un enlace con $objeto.propiedad (el $ de @Binding, ahora delante del objeto: $preferencias.letraGrande). Y eso exige que la propiedad se pueda escribir desde fuera: en este ejercicio y en el paso 2 es un @Published var sin private(set), a diferencia de los modelos de la lección anterior, donde solo la clase escribía.

Ejercicio: el lector y su letra

Reto · Ejercicio: el lector y su letra

Crea la subcarpeta Lector dentro de Ejercicios (recuerda: ningún tipo puede llamarse igual que otro del proyecto). En PreferenciasDeLectura.swift, una clase observable con un solo ajuste, si la letra es grande (empieza en no), sin private(set) para que el interruptor pueda cambiarlo a través de $preferencias.letraGrande. En LectorView.swift, tres vistas, y ninguna recibe las preferencias por el constructor:

  • LectorView es la dueña de las preferencias y las deja en el entorno de lo que contiene: una página y sus ajustes, en columna.
  • PaginaDeLibro enseña la frase «Érase una vez un balón que no quería rodar.» (en letra de título si la letra es grande y normal si no: .font(condicion ? .title : .body) elige entre dos valores según la condición) y, debajo, «Letra: normal» o «Letra: grande».
  • AjustesDeLectura tiene un interruptor, «Letra grande», que cambia el ajuste.

Añade una vista previa de LectorView. Al principio sale «Letra: normal»; enciende el interruptor y debe verse:

Letra: grande

(y la frase, más grande).

Ver solución
solución: PreferenciasDeLectura.swift
import Combine
import Foundation
 
// Las preferencias del lector, compartidas por todas las vistas del libro.
final class PreferenciasDeLectura: ObservableObject {
    @Published var letraGrande = false
}
solución: LectorView.swift
import SwiftUI
 
// La raíz del lector: es la DUEÑA de las preferencias y las deja en el entorno.
struct LectorView: View {
    @StateObject private var preferencias = PreferenciasDeLectura()
 
    var body: some View {
        VStack(spacing: 16) {
            PaginaDeLibro()
            AjustesDeLectura()
        }
        .padding()
        .environmentObject(preferencias)      // todas las vistas de dentro pueden recogerlas
    }
}
 
// Lee las preferencias sin recibirlas por el constructor.
struct PaginaDeLibro: View {
    @EnvironmentObject private var preferencias: PreferenciasDeLectura
 
    var body: some View {
        VStack(spacing: 8) {
            Text("Érase una vez un balón que no quería rodar.")
                .font(preferencias.letraGrande ? .title : .body)
            Text(preferencias.letraGrande ? "Letra: grande" : "Letra: normal")
        }
    }
}
 
// También las recoge del entorno, y además las CAMBIA con un enlace ($).
struct AjustesDeLectura: View {
    @EnvironmentObject private var preferencias: PreferenciasDeLectura
 
    var body: some View {
        Toggle("Letra grande", isOn: $preferencias.letraGrande)
    }
}
 
#Preview {
    LectorView()
}

Sin .environmentObject(preferencias), el proyecto compila, pero la app se detiene al abrir la pantalla con este mensaje:

SwiftUICore/EnvironmentObject.swift:92: Fatal error: No ObservableObject of type PreferenciasDeLectura found. A View.environmentObject(_:) for PreferenciasDeLectura may be missing as an ancestor of this view.

Es un error de ejecución, no de compilación: el compilador no puede saber quién puso qué en el entorno.

Paso 1 · Los ajustes: tu primer ObservableObject compartido

Objetivo. Un fichero nuevo, AjustesModelo.swift, con la duración del partido (90 o 60 minutos). Compila, y todavía no lo usa nadie.

MarcadorApp (proyecto)
MarcadorApp
Marcador
Data
AjustesModelo.swiftse crea
Lo que tocas en este paso, en el Project navigator (Cmd+1).

Trabajas en ~/Marcador-Swift/MarcadorApp. Los ajustes son otra clase observable, parecida a PartidoEnVivoModelo: por eso lleva import Combine, por lo mismo que en ObservableObject y @StateObject.

Aunque se parezcan, no van en la misma carpeta: PartidoEnVivoModelo es lo que la pantalla lee y mueve (UI), y AjustesModelo guarda los ajustes del usuario, es decir, de dónde salen los datos, así que va en una carpeta nueva, Data, dentro de Marcador: clic derecho sobre la carpeta Marcador → New Folder, de nombre Data. Después, clic derecho sobre Data → New File from Template… → Swift File → Next, nombre AjustesModelo, Create. Desde la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Marcador/Data && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Marcador/Data/AjustesModelo.swift.

Intenta escribirlo tú: una clase observable y final, AjustesModelo, con una lista estática de duraciones en minutos (90 y 60) y una propiedad duracion que avisa al cambiar y arranca en 90. Cuando termines, abre el bloque.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/Data/AjustesModelo.swift
import Combine      // `ObservableObject` y `@Published` viven en Combine, no en Foundation
import Foundation
 
// Los ajustes del usuario, compartidos por toda la app. Por ahora, uno solo: la duración del partido.
// `ObservableObject`: una clase que avisa a SwiftUI cuando cambia algo marcado con `@Published`.
final class AjustesModelo: ObservableObject {
    // Las duraciones que se ofrecen, en minutos. `static`: pertenece a la clase, no a cada objeto.
    static let duraciones = [90, 60]
 
    // `@Published`: cuando `duracion` cambia, las vistas que observan este objeto se recalculan.
    @Published var duracion = 90
}

Compila con Cmd+B: debe terminar con Build Succeeded.

Paso 2 · Los ajustes llegan por el entorno

Objetivo. La duración del partido se elige en la hoja con un selector, y el valor llega a la hoja sin pasar por ningún constructor: la raíz de la app es la dueña de los ajustes y los deja en el entorno.

MarcadorApp (proyecto)
MarcadorApp
MarcadorApp.swiftse modifica
Marcador
UI
PartidoEnDirectoView.swiftse modifica
Dos ficheros se modifican.

Piezas nuevas de este paso.

  • @StateObject en la raíz y .environmentObject(ajustes): la app es la dueña de los ajustes y los deja disponibles para todas las vistas.
  • @EnvironmentObject private var ajustes: AjustesModelo: recoge el objeto por su tipo.
  • $ajustes.duracion: el $ de un objeto observable da un enlace a una de sus propiedades, como en @State (@Binding).
  • Picker("Duración", selection: $ajustes.duracion) con .pickerStyle(.segmented) y .tag(minutos) en cada opción: un selector de opciones en fila, y tag es el valor que se guarda al elegir una.
  • @Environment(\.scenePhase) con .onChange(of: fase) { anterior, nueva in … }: ejecuta un bloque cada vez que cambia la fase de la app (La app, sus escenas y el paquete Torneo).

La raíz. Abre MarcadorApp.swift. Intenta escribirlo tú: la app pasa a ser la dueña de unos ajustes, ajustes, que nacen una vez y viven lo que la app, y los deja en el entorno de ContentView para que cualquier vista de debajo los recoja.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp.swift: dos cambios
// …
    @Environment(\.scenePhase) private var fase
 
    // La app es la DUEÑA de los ajustes: nacen una vez y viven mientras viva la app.
    @StateObject private var ajustes = AjustesModelo()
 
    var body: some Scene {
        WindowGroup {
            ContentView()
                // Los deja en el entorno: cualquier vista descendiente puede recogerlos.
                .environmentObject(ajustes)
        }
// …

La vista. En PartidoEnDirectoView.swift hay cuatro cambios pequeños. Intenta hacerlos tú:

  1. Tres propiedades nuevas debajo del @StateObject: los ajustes del entorno (con @EnvironmentObject; no los crea esta vista), un contador pausas con @State (cuántas veces la app pasó a segundo plano) y la fase de la escena (@Environment(\.scenePhase)). Son dos tipos de estado conviviendo en la misma vista.
  2. El 90 fijo pasa a ser ajustes.duracion, en las dos partes donde aparecía: la llamada a empezar y textoDelBoton.
  3. Dos bloques nuevos: el texto «Veces que la pantalla pasó a segundo plano: n» justo después del «Último aviso», dentro del VStack del minuto, y el selector de duración, un VStack nuevo, el cuarto del VStack principal (los tres anteriores son la tarjeta, los botones de gol y el minuto), con el título «Duración del partido» y un Picker ligado a $ajustes.duracion, gris mientras el partido corre o ya empezó.
  4. Un .onChange(of: fase) que suma una pausa cuando la fase pasa a .background. Cuelga del ScrollView, justo tras la llave que lo cierra (el .padding(16) es del VStack de dentro).

Compara con PartidoEnDirectoView, la vista entera con los cuatro cambios señalados. Las otras dos vistas del fichero, BotonesDeGolConAccion y PartidoContenedor, no cambian: déjalas como están debajo de esta.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoEnDirectoView.swift: solo PartidoEnDirectoView, con los cuatro cambios
import SwiftUI
import Torneo
 
// La pantalla del partido en directo, con los cuatro cambios de este paso.
struct PartidoEnDirectoView: View {
    // La vista es la DUEÑA del modelo.
    @StateObject private var modelo: PartidoEnVivoModelo
 
    // Cambio 1a: los ajustes NO los crea esta vista: los puso la raíz con `.environmentObject`.
    @EnvironmentObject private var ajustes: AjustesModelo
    // Cambio 1b: estado propio de la vista, y la fase de la escena.
    @State private var pausas = 0
    @Environment(\.scenePhase) private var fase
 
    init(partido: Partido) {
        _modelo = StateObject(wrappedValue: PartidoEnVivoModelo(partido: partido))
    }
 
    var body: some View {
        ScrollView {
            VStack(spacing: 24) {
                TarjetaDePartido(partido: modelo.partido, marcador: modelo.marcador)
 
                BotonesDeGolConAccion(partido: modelo.partido) { lado in
                    modelo.golAMano(lado)
                }
 
                // Bloque 3 del VStack: el minuto, el botón y los avisos.
                VStack(alignment: .leading, spacing: 8) {
                    Text("Minuto \(modelo.minuto)'")
                        .font(.title)
                        .monospacedDigit()
                        .accessibilityIdentifier("minuto")
                    // Cambio 2: `ajustes.duracion` en lugar del 90 fijo.
                    Button(textoDelBoton) { modelo.empezar(duracion: ajustes.duracion) }
                        .buttonStyle(.borderedProminent)
                        .disabled(modelo.corriendo || modelo.minuto > 0)
                    Text("Último aviso (cada 15'): \(modelo.ultimoAviso)")
                    // Cambio 3a: el texto de las pausas, tras el último aviso.
                    Text("Veces que la pantalla pasó a segundo plano: \(pausas)")
                }
                .frame(maxWidth: .infinity, alignment: .leading)
 
                // Cambio 3b: el selector es el bloque 4, justo debajo del VStack del minuto.
                VStack(alignment: .leading, spacing: 8) {
                    Text("Duración del partido")
                    Picker("Duración", selection: $ajustes.duracion) {
                        ForEach(AjustesModelo.duraciones, id: \.self) { minutos in
                            Text("\(minutos) min").tag(minutos)
                        }
                    }
                    .pickerStyle(.segmented)
                    .disabled(modelo.corriendo || modelo.minuto > 0)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .padding(16)
        }
        // Cambio 4: el `.onChange` cuelga del ScrollView, justo tras su llave de cierre.
        .onChange(of: fase) { _, nueva in
            if nueva == .background { pausas += 1 }
        }
    }
 
    private var textoDelBoton: String {
        // Cambio 2: `ajustes.duracion` también aquí.
        if modelo.minuto >= ajustes.duracion { "Partido terminado" }
        else if modelo.corriendo { "En juego" }
        else { "Empezar partido" }
    }
}

Ejecuta con Cmd+R: la hoja tiene ahora un selector «90 min / 60 min» y un contador de pausas. Con @EnvironmentObject ninguna vista recibe los ajustes por el constructor, y aun así los encuentra.

Punto de control

  • Tocar una fila de «Partidos» abre la hoja; «Empezar partido» hace avanzar el minuto y el botón pasa a «En juego». El selector de duración funciona y se desactiva con el partido en marcha.
  • Con @StateObject, «Redibujar el padre» no cambia el minuto ni el marcador, y la consola de Xcode (ejecutando con Cmd+R) muestra un solo modelo del partido CREADO, el de abrir la hoja.
  • El esquema de Xcode no lleva el argumento -msPorMinuto 40 (lo quitaste en la lección anterior): un minuto de partido dura 250 ms.
  • Cmd+U termina en ** TEST SUCCEEDED ** con 11 pruebas: 3 unitarias y 8 de interfaz (7 de MarcadorAppUITests y testLaunch).

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Qué tiene que hacer un ancestro para que una vista pueda usar @EnvironmentObject var ajustes: AjustesModelo?

  2. 2.Una vista usa @EnvironmentObject, pero ningún ancestro llamó a .environmentObject(…). ¿Cuándo te enteras?

  3. 3.Con @EnvironmentObject private var ajustes: AjustesModelo, ¿qué da $ajustes.duracion?

  4. 4.Los ajustes los necesitan varias pantallas. ¿Quién debe crearlos, y con qué atributo, para que vivan lo que la app?

Faltan 4 de 4