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 propiedadwrappedValue
para acceder a su valorBool
:isVisible.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 valorBinding
(que ya existe cuando se declara una variable de tipoState
). Para obtener este valor cuando declaramos la variable como@State
usamos el símbolo$
:$isVisible
. De la otra forma debemos usar la propiedadprojectedValue
:isVisible.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 Toggle
, TextField
, DatePicker
, 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 clarofont
: Indica la fuente global de la aplicacióneditMode
: Indica si la aplicación está en modo edición o nohorizontalSizeClass
: Indica el modo delsize class
horizontal de la aplicaciónverticalSizeClass
: Indica el modo delsize class
vertical de la aplicaciónpresentationMode
: 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 modificadorenvironmentObject
deView
(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