SwiftUI – Flujo de datos

Funcionamiento de SwiftUI

Como ya hemos visto, SwiftUI proporciona una programación declarativa para componer las vistas. A su vez, hay que indicar los datos necesarios para crear estas vistas (como un listado de elementos a pintar, unas variables bool de control, etc) y en muchas ocasiones los datos que encontramos en las vistas son susceptibles a recibir modificaciones, bien por un evento externo o por una acción directa del usuario. Estás modificaciones del flujo de datos no son posibles usando Swift directamente, ya que las vistas en SwiftUI son struct y en Swift no podemos modificar los valores de un struct.

Para solucionar este problema, SwiftUI proporciona todo lo necesario para conectar las diferentes fuentes de datos (local o externa) con la interfaz, permitiendo que tengamos variables en nuestras vistas que puedan ser modificadas y haciendo que cualquier modificación de alguna de estas variables provoque que la vista se refresque automáticamente sin necesidad de que tengamos que hacer nada más. Este tipo de funcionamiento es esencial a la hora de crear nuestra aplicación y nos obliga a separar la lógica de las propias pantallas, lo cual es muy positivo para la ordenación del código.

Gran parte de este flujo de datos se encarga de manejarlo los @propertyWrapper que implementan el protocolo DynamicProperty.

  • @propertyWrapper es un modificador de datos que permite que usemos el tipo definido como un atributo más de las propiedades que definamos.
  • DynamicProperty añade la funcionalidad de actualizar la vista a las variables almacenadas.

Estos nuevos tipos son conocidos como tipos de gestión de datos y la encontramos en nuevos struct o class que vienen ya definidos en SwiftUI.

Cómo usar un property wrapper

Para usar un property wrapper debemos añadir un nuevo atributo con el propio nombre del struct o class del wrapper, precedido de @ en la declaración de la variable. A continuación vamos a usar State definido en SwiftUI:

@State private var isVisible = true

Al realizar esta declaración la propiedad isVisible deja de ser de tipo Bool y realmente pasaría a ser de tipo State. Es lo mismo que si declaráramos la propiedad de la siguiente forma:

private var isVisible: State = State(initialValue: true)

Las dos declaraciones dan como resultado el mismo tipo de variable, pero al usar la primera sintaxis tenemos una serie de ventajas, ya que el propio compilador se encargará de acceder al valor Bool de la variable de forma directa, obviando que la variable realmente no es de ese tipo. Vamos a ver un ejemplo donde usaremos los dos tipos de declaración y compararemos las diferencias al usar la variable:

Con property wrapper:

struct ContentView: View {
    @State private var isVisible = false
    
    var body: some View {
        NavigationView {
            VStack(spacing: 15) {
                NavigationLink("Go to Detail", destination: ContentDetailView(isVisible: $isVisible))
                Divider()
                Button("Change isVisible") {
                    isVisible.toggle()
                }
                if isVisible {
                    Text("Hello, World!")
                }
            }
        }
        .navigationBarTitle("Main", displayMode: .inline)
    }
}

struct ContentDetailView: View {
    @Binding var isVisible: Bool
    
    var body: some View {
        VStack(spacing: 15) {
            Button("Change isVisible") {
                isVisible.toggle()
            }
            if isVisible {
                Text("Hello, World!")
            }
        }
        .navigationBarTitle("Detail", displayMode: .inline)
    }
}

Sin property wrapper:

struct ContentView: View {
    private var isVisible: State = State(initialValue: true)
    
    var body: some View {
        NavigationView {
            VStack(spacing: 15) {
                NavigationLink("Go to Detail", destination: ContentDetailView(isVisible: isVisible.projectedValue))
                Divider()
                Button("Change isVisible") {
                    isVisible.wrappedValue.toggle()
                }
                if isVisible.wrappedValue {
                    Text("Hello, World!")
                }
            }
        }
        .navigationBarTitle("Main", displayMode: .inline)
    }
}

struct ContentDetailView: View {
    var isVisible: Binding
    
    var body: some View {
        VStack(spacing: 15) {
            Button("Change isVisible") {
                isVisible.wrappedValue.toggle()
            }
            if isVisible.wrappedValue {
                Text("Hello, World!")
            }
        }
        .navigationBarTitle("Detail", displayMode: .inline)
    }
}

El funcionamiento de este ejemplo es el mismo en los dos casos: tenemos un texto que mostramos u ocultamos al pulsar un botón que cambia el boleano isVisible. También tenemos una pantalla de detalle que modifica la misma variable isVisible que se le pasa en la inicialización.

La única diferencia entre los dos códigos es la forma en la que se acceder a la variable isVisible.

  • Cuando declaramos la variable como @State podemos acceder a su valor como lo haríamos normalmente: isVisible. De la otra forma debemos usar la propiedad wrappedValue para acceder a su valor BoolisVisible.wrappedValue.
@State private var isVisible = false

...

if isVisible {
    Text("Hello, World!")
}

//================================

private var isVisible: State = State(initialValue: true)

...

if isVisible.wrappedValue {
    Text("Hello, World!")
}
  • Cuando hemos pasado la variable isVisible a la pantalla de detalle necesitamos su valor Binding (que ya existe cuando se declara una variable de tipo State). Para obtener este valor cuando declaramos la variable como @State usamos el símbolo $$isVisible. De la otra forma debemos usar la propiedad projectedValueisVisible.projectedValue.
@State private var isVisible = false

...

NavigationLink("Go to Detail", 
            destination: ContentDetailView(isVisible: $isVisible))

//================================

private var isVisible: State = State(initialValue: true)

...

NavigationLink("Go to Detail", 
            destination: ContentDetailView(isVisible: isVisible.projectedValue))

Como se observa estos property wrappers proporcionan nuevas funcionalidades sobre las variables pero a su vez consiguen que tenga el menor impacto a la hora de usarlos en código, consiguiendo que el uso de estas variables sea tan sencillo como si trabajáramos con la variable del tipo indicado.

Manejo de datos en SwiftUI

SwiftUI proporciona los property wrappers que implementan DynamicProperty y otros protocolos para el manejo de datos. En su documentación oficial podemos ver todos los mecanismos que existen para el manejo de datos. Nosotros vamos a ver los más comunes, exponiendo ejemplos de uso de cada uno de ellos.

Concepto Two-way binding

Antes de continuar tenemos que saber qué es esto. Este concepto explica el modo de funcionamiento de muchos de los componentes de SwiftUI.

Two-way binding indica que con una única variable podemos indicar el valor de un componente y este a su vez modificará esa variable con los nuevos valores que se le asignen. Por lo tanto, estamos ante una variable de lectura y escritura para los componentes de SwiftUI.

struct ContentView: View {
    @State private var textValue: String = ""
    
    var body: some View {
        TextField("Write something", text: $textValue)
    }
}

En el ejemplo anterior, este concepto aparece cuando la variable textValue que usamos para indicar el texto del TextField también sirve para recibir las modificaciones que el usuario realiza sobre el propio TextField. Esto se consigue al pasar una variable Binding. En nuestro caso, obtenemos esa variable invocando a la variable como $textValue. Este modificador $ es un modo de acceso especial de las variables State.

Truco: Durante el desarrollo podemos mockear una variable de tipo Binding usando la función .constant(_ value: Value) que permite crear un Binding inmutable: TextField("Write something", text: .constant(""))

Property wrapper @State

@State crear un tipo de dato de lectura y escritura que permite actualizar las vistas de SwiftUI. Cuando una variable de este tipo cambia se invalida la vista y se vuelve a recargar el body. Este concepto facilita mucho los desarrollos de SwiftUI, ya que nos indica que para crear una vista sólo tenemos que tener en cuenta el estado de estas variables para mostrar u ocultar los componentes necesarios dependiendo de su información.

Este property wrapper es uno de los más usados. Se podrá usar cada vez que tengamos un componente de SwiftUI en el que necesitemos almacenar las modificaciones realizadas por el usuario, como un Toggle o un TextField.

Por ejemplo, podemos crear una variable @State para almacenar el valor de un TextField y además vamos a mostrar un Text con el texto escrito por el usuario.

struct ContentView: View {
    @State private var text: String = ""
    
    var body: some View {
        VStack(spacing: 15) {
            TextField("Write something", text: $text)
            if !text.isEmpty {
                Text("You write (text)")
            }
        }
    }
}

También podemos deshabilitar un botón hasta que los campos de texto no tengan contenido, como en un formulario de login:

struct ContentView: View {
    @State private var user: String = ""
    @State private var pass: String = ""
    
    var body: some View {
        VStack(spacing: 15) {
            TextField("User", text: $user)
            SecureField("Password", text: $pass)
            Button("Login") {
                //Do Login
            }
            .disabled(user.isEmpty || pass.isEmpty)
        }
        
    }
}

Estas variables son de ámbito local y nunca deben propagarse a otras vistas, ya que al hacerlo estamos creando una copia de la misma y perderemos la referencia a la variable original. Si queremos usar una variable compartida con otras vistas probablemente deberíamos usar @ObservedObject en su lugar.

Property wrapper @Binding

@Binding crea un tipo de dato de lectura y escritura cuyo valor es proporcionado desde otra punto de la aplicación y manteniendo la misma referencia a ese valor. Este tipo de datos es el que nos podemos encontrar en casi cualquier componente de SwiftUI como un ToggleTextFieldDatePicker, etc, cuando tenemos que pasarlo en su inicialización.

Un ejemplo común de su uso lo podemos ver al presentar una vista con el modificador sheet.

struct ContentView: View {
    @State private var isPresentDetail = false
    
    var body: some View {
        VStack(spacing: 15) {
            Text("First View")
            Button("Go to Second") {
                isPresentDetail.toggle()
            }
        }
        .sheet(isPresented: $isPresentDetail) {
            ContentDetailView(isPresentDetail: $isPresentDetail)
        }
    }
}

struct ContentDetailView: View {
    @Binding var isPresentDetail: Bool
    
    var body: some View {
        VStack(spacing: 15) {
            Text("Second View")
            Button("Dismiss") {
                isPresentDetail.toggle()
            }
        }
    }
}

Para presentar ContentDetailView tenemos que setear el valor de la variable isPresentDetail a true, pero ¿cómo volvemos atrás?. Para poder volver atrás desde la segunda pantalla necesitamos modificar la misma variable isPresentDetail a false, pero esa variable es una variable @State que no podemos pasar a otra vista. Para solucionar este problema tenemos que usar una variable proyectada que posee todo @State. Esta variable proyectada es tipo Binding que sí podemos propagar a otras vistas y nos permite modificar el valor real de la variable @State fuera del ámbito donde se declaró. Para acceder a la variable Binding tenemos que acceder a la misma con el modificador $ delante: $isPresentDetail.

Truco: Durante el desarrollo podemos mockear una variable de tipo Binding usando la función .constant(_ value: Value) que permite crear un Binding inmutable

Property wrapper @Published, @ObservedObject y protocolo ObservableObject

Esta combinación es uno de los conceptos más usados en SwiftUI y es esencial para el manejo de datos de una aplicación. Estos dos property wrapper y este protocolo nos permite crear objetos complejos que notifiquen cambios a las vistas de SwiftUI, de forma que éstas se recarguen cuando algunas de sus propiedades sea modificada. Es muy común usarlos para cargar los datos de un servicio web, cuya carga es asíncrona. Para explicar su uso vamos a dividir el desarrollo en dos partes.

Creando un ObservableObject con propiedades @Published

Lo primero de todos necesitaríamos crearnos una clase que implemente el protocolo ObservableObject:

class ColorModel: ObservableObject {
    var colors: [Color] = [.red, .blue, .orange]
}

Este protocolo solo está disponible para los tipos class. También nos proporciona una nueva variable objectWillChange que podemos usar para indicar que nuestra clase ha cambiado y se debe notificar a la vista que lo contiene. En nuestro ejemplo no será necesario usarlo porque vamos a usar @Published.

Nuestra clase ColorModel se encargará de almacenar un listado de colores para poder usarlos posteriormente en la vista y mostrarlos en un listado. Como estos colores podrán ser modificados por alguien externo a la clase ColorModel, quiere decir que la vista debe ser notificada cada vez que esto ocurra, por lo que debemos añadirle el property wrapper @Published.

class ColorModel: ObservableObject {
    @Published var colors: [Color] = [.red, .blue, .orange]
}

Al añadir @Published sobre la variable colors lo que hace es que internamente la variable pasa a ser de tipo Published<[Color]> (similar al caso de @State) y esta nueva clase se encarga de notificar a la vista cada vez que ocurre un cambio sobre este array.

Usando un ObservableObject en una vista con @ObservedObject

Una vez definida nuestra clase ColorModel debemos usarla en nuestra vista. El objetivo será mostrar un listado con los colores que tiene la propiedad colors. Para ello necesitamos crear una propiedad de tipo ColorModel en nuestra vista, indicando el property wrapper @ObservedObject para indicar que SwiftUI debe manejar los cambios que ColorModel  notifique.

struct ContentView: View {
    @ObservedObject private var colorModel = ColorModel()
    
    var body: some View {
        List {
            ForEach(colorModel.colors, id: .self) {
                cell($0)
            }
        }
    }
    
    func cell(_ color: Color) -> some View {
        HStack {
            Text("Color")
            Spacer()
            Rectangle()
                .fill(color)
        }
    }
}

Pero para ver el funcionamiento completo debemos modificar el array colors añadiendo nuevos colores, así que vamos a añadir un ColorPicker junto a un Button para que vayamos añadiendo nuevos colores. De esta forma cada vez que pulsemos el botón Add se añadirá un color al array colors. Esta variable al ser @Published notificará de su cambio, el cual recibiremos por tener declarado la variable colorModel de tipo @ObservedObject.

struct ContentView: View {
    @ObservedObject private var colorModel = ColorModel()
    @State private var newColor = Color.red
    
    var body: some View {
        VStack {
            HStack {
                ColorPicker("Select new color", selection: $newColor)
                Spacer()
                Button("Add") {
                    colorModel.colors.append(newColor)
                }
            }
            .padding()
            List {
                ForEach(colorModel.colors, id: .self) {
                    cell($0)
                }
            }
        }
    }
    
    func cell(_ color: Color) -> some View {
        HStack {
            Text("Color")
            Spacer()
            Rectangle()
                .fill(color)
        }
    }
}

Cómo pasar un ObservableObject a otra vista

El protocolo ObservableObject obliga a quien lo implementa sea un class. Los class, a diferencia de otros tipos, son tipos de datos por referencia, lo que quiere decir que cuando pasamos una instancia de este tipo como parámetros de una función lo que se pasa es la referencia del mismo y no una copia como cuando trabajamos con struct.

Como resultado, para pasar un ObservableObject a otra vista es tan sencillo como pasarlo por parámetros durante su inicialización, pudiendo realizar modificaciones internas en él que se verían reflejados en las dos pantallas.

class ColorModel: ObservableObject {
    @Published var colors: [Color] = [.red, .blue, .orange]
}

struct ContentView: View {
    @ObservedObject var colorModel: ColorModel
    @State private var newColor = Color.red
    @State var showDetail = false
    
    init(_ colorModel: ColorModel = ColorModel()) {
        self.colorModel = colorModel
    }
    
    var body: some View {
        VStack {
            HStack {
                ColorPicker("Select new color", selection: $newColor)
                Spacer()
                Button("Add") {
                    colorModel.colors.append(newColor)
                }
            }
            .padding()
            List {
                ForEach(colorModel.colors, id: .self) {
                    cell($0)
                }
            }
            Button("New Screen") {
                showDetail.toggle()
            }
            .padding()
        }
        .sheet(isPresented: $showDetail) {
            ContentView(colorModel)
        }
    }
    
    func cell(_ color: Color) -> some View {
        HStack {
            Text("Color")
            Spacer()
            Rectangle()
                .fill(color)
        }
    }
}

Property wrapper @Environment

@Environment es un property wrapper que nos permite consultar variables globales predefinidas por el sistema para la aplicación. Estas propiedades son las que están definidas en EnvironmentValues y puedes ver la definición completa en el siguiente enlace.

Para consultar alguna de estas propiedades hay que usar @Environment seguido de la variable que queremos obtener.

@Environment(.horizontalSizeClass) var horizontalSizeClass

Esta variable no es necesaria inicializarla ya que será el propio sistema el encargado de asignar el valor. También se puede observar que no es necesario indicar el tipo de la variable, ya que la propiedad que indicamos en @Environment infiere el tipo de la variable que estamos creamos.

En EnvironmentValues podemos encontrar diferentes tipos de variables con información global de nuestra aplicación, como puede ser:

  • colorScheme: Indica si la aplicación está en modo oscuro o claro
  • font: Indica la fuente global de la aplicación
  • editMode: Indica si la aplicación está en modo edición o no
  • horizontalSizeClass: Indica el modo del size class horizontal de la aplicación
  • verticalSizeClass: Indica el modo del size class vertical de la aplicación
  • presentationMode: Permite conocer si la vista está siendo presentada modalmente
  • Y muchos más…

Estos valores afectan a toda la aplicación y en primera instancia son definidos por el sistema. Existe el modificador environment en el protocolo View, que nos permite personalizar estos valores globales. Al personalizarlo, todas las vistas que estén dentro de la que aplicamos la personalización se verán afectadas por el nuevo valor.

Un ejemplo de personalización de variable environment lo encontramos cuando queremos poner el componente List en modo edición:

struct ContentView : View {
    @State var isEditMode: EditMode = .inactive
    @State private var items = Film.mockFilms
    
    var body: some View {
        VStack(spacing: 15) {
            List {
                ForEach(items) {
                    Text($0.name)
                }
                .onDelete {
                    items.remove(atOffsets: $0)
                }
                .onMove {
                    items.move(fromOffsets: $0, toOffset: $1)
                }
            }
            Group {
                if isEditMode == .inactive {
                    Button("Start Edit") {
                        withAnimation {
                            isEditMode = .active
                        }
                    }
                    .padding()
                    .background(Color.green.opacity(0.3))
                } else {
                    Button("End Edit") {
                        withAnimation {
                            isEditMode = .inactive
                        }
                    }
                    .padding()
                    .background(Color.red.opacity(0.3))
                }
            }
            .padding()
        }
        .environment(.editMode, $isEditMode)
        .navigationBarTitle("Edit", displayMode: .inline)
        .navigationColor(background: UIColor(red: 31/255, green: 155/255, blue: 222/255, alpha: 1), title: .white)
    }
    
    
    struct Film: Identifiable {
        let id = UUID()
        
        var name: String
        
        static let mockFilms = [Film(name: "Iron Man"),
                                Film(name: "The Incredible Hulk"),
                                Film(name: "Iron Man 2"),
                                Film(name: "Thor"),
                                Film(name: "Captain America"),
                                Film(name: "Marvel's The Avengers")]
    }
}

A través de la línea .environment(.editMode, $isEditMode) modificamos la variable de entorno editMode para indicar si el List está en modo edición o no, dependiendo de una variable de estado local. Este cambio de estado afectaría únicamente a todos los componentes que se encuentren dentro del VStack al que modifica se está añadiendo el modificador .environment.

También podemos usar la variable @Environment(.presentationMode) para hacer un dismiss de una vista sin necesidad de propagar la variable de estado que inició la presentación:

struct ContentView: View {
    @State var showingDetail = false
    
    var body: some View {
        VStack(spacing: 15) {
            Text("Main Screen")
            Button(action: {
                showingDetail.toggle()
            }) {
                Text("Show Detail")
            }.sheet(isPresented: $showingDetail) {
                SheetDetailView()
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color.yellow.opacity(0.3))
    }
}

struct SheetDetailView: View {
    @Environment(.presentationMode) var presentationMode
    
    var body: some View {
        VStack(spacing: 15) {
            Text("Detail Screen")
            Button(action: {
                if presentationMode.wrappedValue.isPresented {
                    presentationMode.wrappedValue.dismiss()
                }
            }) {
                Text("Back")
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color.red.opacity(0.3))
    }
}

Property wrapper @EnvironmentObject

@EnvironmentObject es un property wrapper que nos permite consultar variables de entorno creadas por nosotros mismos y que queremos compartir con otras partes de la aplicación, como por ejemplo los datos del usuario que podemos usar en cualquier parte de la aplicación.

Las variables que se pueden usar en un @EnvironmentObject debe ser de tipo ObservableObject.

class ColorModel: ObservableObject {
    @Published var colors: [Color] = [.red, .blue, .orange]
}

Para consultar un @EnvironmentObject debemos declarar la variable en nuestra vista indicando el tipo que queremos recuperar:

@EnvironmentObject var colorModel: ColorModel

Antes de poder usar un @EnvironmentObject es obligatorio haberlo registrado previamente a través del modificador environmentObject que contiene el protocolo de View.

struct ContentView: View {
    var body: some View {
        Group {
            //Your view here
        }
        .environmentObject(ColorModel())
    }
}

Importante: Si intentamos recuperar un @EnvironmentObject que no hemos registrado previamente la aplicación crasheará porque no puede encontrar el @EnvironmentObject que estemos intentando acceder. En este caso el compilador se comporta como si estuviera haciendo un desempaquetado forzado (!)

Para este caso, la variable ColorModel estaría accesible como EnvironmentObject para todas las vistas que incluyamos en el Group donde está definido. Fuera de ese ámbito esta variable no existe, por lo que no podremos recuperarla.

Si quisiéramos crear un EnvironmentObject que estuviera disponible para toda la aplicación tendríamos que crearla en la primera vista de la aplicación.

@main
struct SwiftUITestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(ColorModel())
        }
    }
}

El modificador environmentObject que usamos para registrar las variables sólo recibe una instancia de un ObservableObject. Esto nos limita a que sólo podamos registrar una instancia de un mismo tipo como EnvironmentObject

En resumen, el funcionamiento general de EnvironmentObject es el siguiente:

  • Tenemos que registrar la instancia de una clase que implementa ObservableObject con el modificador environmentObject de View (solo podemos registrar una instancia por tipo).
  • Recuperamos las variables de entorno con el modificador @EnvironmentObject. Es obligatorio indicar el tipo para que el compilador busque entre las variables de entorno la que coincida con el solicitado.

Un gran poder conlleva una gran responsabilidad

El @EnvironmentObject nos proporciona un mecanismo muy útil para compartir información en diferentes puntos de la aplicación pero también es uno de los puntos donde podemos tener más incidencias, ya que si la variable que solicitamos no existe la aplicación se cerrará inmediatamente.

… y cuidado con el Preview

También tenemos que tener en cuenta los PreviewProvider que usamos para previsualizar las pantallas que desarrollamos. Por lo general, estas implementaciones se limitan a ejecutar la pantalla donde nos encontremos sin tener en cuenta toda la jerarquía que tiene detrás.

¿Qué quiere decir esto? Pues que si desarrollamos una vista que usa una variable de entorno que tiene que definir alguna otra vista anterior, nosotros tenemos que pasar una variable de entorno que cumpla con lo que la vista realmente espera en el preview, ya que al probar una pantalla con el PreviewProvider solo se carga lo que se indica en ella.

class ColorModel: ObservableObject {
    @Published var colors: [Color] = [.red, .blue, .orange]
}

struct ContentView: View {
    @EnvironmentObject var colorModel: ColorModel
    @State private var newColor = Color.red
    @State var showDetail = false
    
    var body: some View {
        VStack {
            HStack {
                ColorPicker("Select new color", selection: $newColor)
                Spacer()
                Button("Add") {
                    colorModel.colors.append(newColor)
                }
            }
            .padding()
            List {
                ForEach(colorModel.colors, id: .self) {
                    cell($0)
                }
            }
            Button("New Screen") {
                showDetail.toggle()
            }
            .padding()
        }
        .sheet(isPresented: $showDetail) {
            ContentView()
        }
    }
    
    func cell(_ color: Color) -> some View {
        HStack {
            Text("Color")
            Spacer()
            Rectangle()
                .fill(color)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(ColorModel())
    }
}

Una alternativa al uso de @EnvironmentObject

Estamos de acuerdo que en muchos casos usar @EnvironmentObject puede resultar muy útil, pero tenemos que ser conscientes que usarlo indebidamente nos deja el peor escenario posible para un desarrollador: el cierre inesperado de la app.

Como el compilador trata estos tipos de datos como un desempaquetado forzado (!) es normal que se pueda dar esta circunstancia, pero nuestra misión cuando desarrollamos una aplicación es asegurar que la aplicación nunca tenga un cierre inesperado (y que todo funcione correctamente claro).

Por ello podemos conseguir este mismo comportamiento de forma más segura pero usando otro de los property wrapper que ya hemos visto anteriormente: @ObservedObject. Para conseguir un funcionamiento similar al que nos proporciona @EnvironmentObject lo que tenemos que hacer es iniciar el @ObservedObject que queramos con una instancia singleton, de forma que la responsabilidad de trabajar sobre la misma instancia siembre recaerá sobre el objeto ObservableObject.

class ColorModel: ObservableObject {
    @Published var colors: [Color] = [.red, .blue, .orange]
    
    static let shared = ColorModel()
    
    private init() { }
}

struct ContentView: View {
    @ObservedObject var colorModel = ColorModel.shared
    @State private var newColor = Color.red
    @State var showDetail = false
    
    var body: some View {
        VStack {
            HStack {
                ColorPicker("Select new color", selection: $newColor)
                Spacer()
                Button("Add") {
                    colorModel.colors.append(newColor)
                }
            }
            .padding()
            List {
                ForEach(colorModel.colors, id: .self) {
                    cell($0)
                }
            }
            Button("New Screen") {
                showDetail.toggle()
            }
            .padding()
        }
        .sheet(isPresented: $showDetail) {
            ContentView()
        }
    }
    
    func cell(_ color: Color) -> some View {
        HStack {
            Text("Color")
            Spacer()
            Rectangle()
                .fill(color)
        }
    }
}

Rafael Fernández,
iOS Tech Lider