Combine #15: Usando Combine desde SwiftUI (1)

En SwiftUI, la interfaz de usuario pintada en pantalla es una función del (o se deriva del) estado, el cual existe en un único punto conocido como “fuente de verdad”. El Publisher de Combine se conecta como fuente de datos de las vistas de SwiftUI.

Cada vez que hay un cambio del estado (o modelo de datos), SwiftUI recalcula solo las vistas afectadas por el modelo. - Lo cual lo convierte en un mecanismo óptimo para pintar en pantalla.

Administración de recursos

La idea de no duplicar datos

En UIKit podría tener una variable conditions de tipo String en el modelo de datos, y un UILabel con su atributo text donde se va a pintar la información de la variable conditions. En cierto punto, text va a ser una copia de conditions; además, es necesario actualizar el valor de text de forma deliberada cada vez que haya un cambio en conditions.

SwiftUI puede usar directamente la información del modelo de datos.

*** Nota**: No sé hasta qué punto acoplarse directamente a la información de otra capa sea bueno. Los conceptos de inmutabilidad, variaciones protegidas y desacoplamiento de capas convienen mucho para tener un sistema flexible. Sin duda reconozco que no duplicar información es muy importante y trabajar de forma reactiva es muy cómodo, sin embargo, no creo que eso necesariamente sea una motivación para acoplar las capas de mi sistema. Por otro lado, ¿Quién dijo que tengo que duplicar datos obligatoriamente en UIKit?

Menor necesidad de “control” en las vistas

Se reduce el código del ViewController porque no hay código para conectar las capas.

** * Nota**: Creo que eso es muy discutible. No creo que eso sea una característica de SwiftUI, sino de la forma como se implementa la arquitectura de la aplicación.

Manejando el estado de una vista

La siguiente vista de SwiftUI pinta el texto “Hola” en el centro de la pantalla y tiene un botón en la esquina superior derecha que dice “Settings” que va a ser usado para presentar otra pantalla encima.

struct PoCView: View {
  // var sheetVisible = false
  var body: some View {
    NavigationView {
      VStack {
        Text("Hola")
      }
      .navigationBarItems(trailing:
        Button("Settings") {
          // sheetVisible = true
        }
      )
    }
  }
}

Al agregar el atributo sheetVisible y cambiándolo a true dentro del bloque de acción del botón (i.e. agregando el código comentado) aparece el error que dice: “Cannot assign to property: ‘self’ is immutable”. Esto se debe a que body es una variable computada que no puede cambiar el estado almacenado de la estructura, debido a su naturaleza inmutable. Quizás podría funcionar usar mutating, pero este modificador solo sirve para métodos (func). Literalmente es imposible modificar el estado almacenado de la estructura desde una variable computada.

¿Qué hacer entonces?

Para solucionar esto, vamos a almacenar el estado fuera de la vista PoCView, en otra estructura de tipo State<Bool>, que tiene un único atributo: wrappedValue. De esta forma, desde la variable computada podemos modificar un estado que está almacenado en otro lado.

struct PoCView: View {
  var sheetVisible = State<Bool>(initialValue: false)
  var body: some View {
    NavigationView {
      VStack {
        Text("Hola")
      }
      .navigationBarItems(trailing:
        Button("Settings") {
          sheetVisible.wrappedValue = true
        }
      )
    }
  }
}

Luego, queremos mostrar otra pantalla encima con el mensaje “Chao”, cada vez que el valor almacenado en sheetVisible sea true. Aquí podríamos leer de forma pasiva el valor wrappedValue de sheetVisible, sin embargo, no podríamos detectar sus cambios, a no ser que usemos un Publisher. Ante esta problemática, State<Bool> tiene un Publisher llamado projectedValue que emite los valores de wrappedValue, cada vez que hay un cambio.

struct PoCView: View {
  var sheetVisible = State<Bool>(initialValue: false)
  var body: some View {
    NavigationView {
      VStack {
        Text("Hola")
      }
      .sheet(isPresented: self.sheetVisible.projectedValue, content: {
        Text("Chao")
      })
      .navigationBarItems(trailing:
        Button("Settings") {
          sheetVisible.wrappedValue = true
        }
      )
    }
  }
}

Debido a que esta implementación es muy común en SwiftUI, se creó el “property-wrapper” @State, que nos permite acceder al projectedValue con el símbolo $ (e.g. $sheetVisible) , y al wrappedValue con el nombre del “property-wrapper” (e.g. sheetVisible). En caso de que se necesite hacer referencia directamente a la instancia de tipo State<Bool> hay que usar el prefijo _ (e.g. _sheetVisible). Es decir, por ejemplo, que también podríamos acceder al wrappedValue a través de _sheetVisible.wrappedValue.

struct PoCView: View {
  @State var sheetVisible = false
  var body: some View {
    NavigationView {
      VStack {
        Text("Hola")
      }
      .sheet(isPresented: self.$sheetVisible, content: {
        Text("Chao")
      })
      .navigationBarItems(trailing:
        Button("Settings") {
          sheetVisible = true
        }
      )
    }
  }
}

Descargando la última de las historias

Para descargar el contenido se llama el siguiente código donde se crea el Publisher con api.stories(), se indica que se quiere recibir en el hilo principal y luego se crea la suscripción con sink, almacenándola en subscriptions.

api.stories()
  .receive(on: DispatchQueue.main)
  .sink { completion in
    if case .failure(let error) = completion {
      self.error = error
    }
  } receiveValue: { stories in
    self.allStories = stories
    self.error = nil
  }
  .store(in: &subscriptions)

Usando ObservableObject para modelo de datos

ObservableObject emite a través de su propiedad objectWillChange, cada vez que uno de sus @Published emite un nuevo valor. Esto le permite a una vista de SwiftUI saber cuándo la información de su modelo ha cambiado para repintarse.

Luego, el cliente de ese ObservableObject tendrá que marcar con @ObservedObject al modelo. @ObservedObject elimina el almacenamiento de la vista y usa un binding al modelo. También agrega un Publisher a la propiedad para poder suscribirse a ella y/o hacer binding dentro de la jerarquía de vistas.

class ReaderViewModel: ObservableObject {   
  @Published var error: API.Error? = nil
  @Published private var allStories = [Story]()
  // ...
}
struct ReaderView: View {
  @ObservedObject var model: ReaderViewModel
  // ...
}

Mostrando errores

Necesito mostrar una alerta cuando el atributo error de ReaderViewModel sea distinto de nulo. Para ello puedo usar el siguiente código (que está deprecado) que muestra una alerta cuando self.$model.error es distinto de nulo.

struct ReaderView: View {
  var body: some View {
    // ...
    .alert(item: self.$model.error) { error in
      Alert(
        title: Text("Network error"),
        message: Text(error.localizedDescription),
        dismissButton: .cancel()
      )
    }
  }
}

Suscribiéndose a un Publisher externo

No tengo que usar necesariamente la pareja ObservableObject/ObservedObject. También puedo suscribir mi vista de SwiftUI a un Publisher cualquiera por medio del modificador onReceive(_:perform:) que recibe el Publisher al que me quiero suscribir, seguido de la acción a ejecutar cuando emita algún valor.

En la vista de la aplicación de ejemplo se creó un Timer que emite valores tipo Date:

private let timer = Timer.publish(every: 10, on: .main, in: .common)
  .autoconnect()
  .eraseToAnyPublisher()

Al que me suscribí para modificar el atributo currentDate:

.onReceive(timer) {
  self.currentDate = $0
}

Debido a que estoy mutando el estado de la vista, que es una estructura, debo marcar el atributo currentDate con el property-wrapper @State.

@State var currentDate = Date()

Como ReaderView tiene una subvista PostedBy que depende de currentDate, esta última se vuelve a pintar cada vez que Timer emite un evento de temporizador.

struct ReaderView: View {
  var body: some View {
    // ...
    PostedBy(time: story.time, user: story.by, currentDate: self.currentDate)
    // ...
  }
}

Inicializando la configuración de la aplicación

Identifiable requiere una propiedad id que identifica unívocamente cada instancia.

Settings almacena unas palabras clave para filtrar los artículos en keywords. Cuando modifique el arreglo, necesito emitir un evento. Para ello, Settings debe ser un ObservableObject, y keywords debe tener un Publisher.

final class Settings: ObservableObject {
  @Published var keywords = [FilterKeyword]()
}

La vista ReaderView no tiene una referencia a Settings, sino solo a su respectivo ReaderViewModel. Cada vez que haya un cambio en Settings.keywords necesito modificar ReaderViewModel.filter para pintar los cambios en ReaderView. Por esta razón:

  1. Voy a suscribir ReaderViewModel.filter al Publisher de Settings.keywords.
  2. Voy a hacer que ReaderViewModel.filter también tenga un Publisher para actualizar la vista ReaderView cada vez que haya un cambio.
struct HNReader: App {
  let viewModel = ReaderViewModel()
  let userSettings = Settings()
  private var subscriptions = Set<AnyCancellable>()
  init() {
    // Creando suscripción de filter a keywords
    userSettings.$keywords
      .map { $0.map { $0.value } }
      .assign(to: .filter, on: viewModel)
      .store(in: &subscriptions)
  }
  //...
}
class ReaderViewModel: ObservableObject {
  // Agregando Publisher al filtro
  @Published var filter = [String]()
  // ...
}

Editando la lista de palabras clave

El Environment de SwiftUI es un “pool” de Publishers (e.g. calendario actual, dirección de presentación, “locale”, huso horario y otros) que se inyectan automáticamente en la jerarquía de vistas. Si declaro una dependencia hacia ellos o los incluyo en mi estado, la vista se va a repintar cada vez que la dependencia cambia.

En la aplicación de ejemplo, se creó una suscripción al esquema de colores del Environment para cambiar el color del enlace de la noticia con base en el esquema de colores de la aplicación (i.e. ColorScheme.light y ColorScheme.dark). Para ello:
Se declaró el atributo colorScheme suscrito a la llave .colorScheme y se puso la anotación @Environment que recibe la llave del environment que se va a conectar.
Se definió el color de la letra de forma condicional con el modificador .foregroundStyle.

class ReaderViewModel: ObservableObject {
  @Environment(.colorScheme) var colorScheme: ColorScheme
  // ...
    .foregroundStyle(colorScheme == .light ? .blue : .orange)
  // ...
}

** * Nota:** Para poder ver los cambios se usó la opción del menú: Debug ▶ View Debugging ▶ Configure Environment Overrides.

Objetos de ambiente personalizados

Se puede usar @EnvironmentObject para inyectar cualquier objeto a lo largo de TODA la jerarquía de vistas, lo que significa que es visible para vista a la que se pasó directamente y también a todos sus hijos. La propiedad marcada con este property-wrapper tomará el último valor asignado desde el “environment”.

WindowGroup {
  ContentView()
    .environmentObject(Settings(theme: "Light"))
    // El segundo reemplaza al primero
    .environmentObject(Settings(theme: "Dark"))
}

No es necesario especificar un key-path como en el “environment” del sistema, sino que @EnvironmentObject buscara una coincidencia de tipo entre los objetos almacenados en el “environment”.

No se puede tener dos EnvironmentObject del mismo tipo. Ante esta problemática se puede usar:

  1. tipos diferentes
  2. EnvironmentKey personalizados.

En el primer caso:

// Se crean dos configuraciones
final class UserSettings: ObservableObject { ... }
final class AdminSettings: ObservableObject { ... }
// Se inyectan los dos objetos diferentes
.environmentObject(UserSettings())
.environmentObject(AdminSettings())
// En las vistas:
@EnvironmentObject var userSettings: UserSettings
@EnvironmentObject var adminSettings: AdminSettings

Las listas con reordenamiento de SwiftUI tienen un método onMove(perform:) que permite intercambiar dos elementos de un arreglo. Si arrastro un valor para el final, el índice de destino será mayor que el tamaño del arreglo queriendo indicar que está “después del último elemento”, representando el siguiente índice libre.

En esta situación se puede usar el método move(fromOffsets:toOffset:) para desplazar el contenido de los índices preservando el orden.

.onMove { (indices: IndexSet, newOffset: Int) in
  fruits.move(fromOffsets: indices, toOffset: newOffset)
}

Para conseguir el mismo resultado de move(fromOffsets:toOffset:) usando swapAt(_:_:), hay que sacar un elemento e insertarlo al final cuando el destino es mayor que el contenido de elementos del arreglo (después de haber sacado el elemento).

let source = 0
let destination = 3 // viene de SwiftUI
let element = fruits.remove(at: source)
if destination > fruits.count {
    fruits.append(element)
} else {
    fruits.insert(element, at: destination)
}

Cuestionario

1. ¿Qué representa el concepto de “fuente de verdad” en SwiftUI?

  • [ ] La vista raíz de la jerarquía de vistas.
  • [ ] El único punto donde se almacena y actualiza el estado del sistema. 
  • [ ] El primer Publisher que se conecta a la vista.
  • [ ] El ViewModel principal de la aplicación.

2. ¿Por qué en SwiftUI no se debe modificar directamente una propiedad dentro de body?

  • [ ] Porque body es un método de clase y no puede acceder a variables de instancia.
  • [ ] Porque body es una variable computada y las estructuras son inmutables. 
  • [ ] Porque body pertenece al hilo principal.
  • [ ] Porque el compilador no permite usar closures dentro de body.

3. ¿Qué propósito cumple el property-wrapper @State en SwiftUI?

  • [ ] Convertir una propiedad en un Publisher global.
  • [ ] Crear una referencia compartida entre vistas hijas.
  • [ ] Permitir modificar un valor almacenado fuera de la vista, reaccionando a sus cambios. 
  • [ ] Transformar una variable en una constante inmutable.

4. En la arquitectura de Combine y SwiftUI, ¿cuál es la función principal de ObservableObject?

  • [ ] Renderizar vistas en segundo plano.
  • [ ] Emitir eventos cuando las propiedades marcadas con @Published cambian. 
  • [ ] Encapsular las dependencias del entorno de SwiftUI.
  • [ ] Sincronizar las actualizaciones entre varios @State.

5. ¿Cuál es la diferencia entre @ObservedObject y @State en SwiftUI?

  • [ ] @ObservedObject conserva su valor al recrearse la vista, mientras que @State no. 
  • [ ] @State observa modelos externos, mientras que @ObservedObject mantiene su propio estado.
  • [ ] @ObservedObject observa modelos externos (como un ViewModel), mientras que @State maneja el estado interno de la vista. 
  • [ ] Son equivalentes; ambos generan Publishers automáticos.

6. ¿Qué ocurre si se inyectan dos @EnvironmentObject del mismo tipo en la jerarquía de vistas?

  • [ ] SwiftUI crea una copia para cada vista hija.
  • [ ] SwiftUI usa el último que encuentre en el árbol. 
  • [ ] SwiftUI lanza un error porque no permite duplicados del mismo tipo. 
  • [ ] SwiftUI fusiona ambos objetos en un solo Publisher.

7. ¿Para qué se usa el método move(fromOffsets:toOffset:) en una lista de SwiftUI?

  • [ ] Para eliminar un elemento del arreglo.
  • [ ] Para intercambiar elementos preservando el orden del arreglo. 
  • [ ] Para clonar una sección del arreglo en una nueva vista.
  • [ ] Para vincular el orden de la lista con un Publisher externo.

Solución

1. ¿Qué representa el concepto de “fuente de verdad” en SwiftUI?

  • [✅] El único punto donde se almacena y actualiza el estado del sistema.

2. ¿Por qué en SwiftUI no se debe modificar directamente una propiedad dentro de body?

  • [✅] Porque body es una variable computada y las estructuras son inmutables.

3. ¿Qué propósito cumple el property-wrapper @State en SwiftUI?

  • [✅] Permitir modificar un valor almacenado fuera de la vista, reaccionando a sus cambios.

4. En la arquitectura de Combine y SwiftUI, ¿cuál es la función principal de ObservableObject?

  • [✅] Emitir eventos cuando las propiedades marcadas con @Published cambian.

5. ¿Cuál es la diferencia entre @ObservedObject y @State en SwiftUI?

  • [✅] @ObservedObject observa modelos externos (como un ViewModel), mientras que @State maneja el estado interno de la vista.

6. ¿Qué ocurre si se inyectan dos @EnvironmentObject del mismo tipo en la jerarquía de vistas?

  • [✅] SwiftUI usa el último que encuentre en el árbol.

7. ¿Para qué se usa el método move(fromOffsets:toOffset:) en una lista de SwiftUI?

  • [✅] Para intercambiar elementos preservando el orden del arreglo.

Similar Posts