Combine #15: Usando Combine desde SwiftUI (1)
Source: Dev.to
Comparación con UIKit
En UIKit podrías tener:
// Modelo
var conditions: String
// Vista
let label = UILabel()
label.text = conditions // copia de `conditions`
label.textes una copia deconditions.- Es necesario actualizar
label.textde forma deliberada cada vez que cambieconditions.
En SwiftUI, la vista puede usar directamente la información del modelo de datos, sin necesidad de copiarla ni de actualizarla manualmente.
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?
Reducción de código en el ViewController
Al no haber código para conectar las capas, el ViewController puede ser más pequeño.
Nota: Esto es debatible. No creo que sea una característica exclusiva de SwiftUI, sino más bien una consecuencia de la arquitectura elegida.
Ejemplo básico de SwiftUI
La siguiente vista pinta el texto “Hola” en el centro de la pantalla y tiene un botón “Settings” en la esquina superior derecha que 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
}
)
}
}
}
¿Por qué aparece el error *“Cannot assign to property: ‘self’ is immutable”?
bodyes una propiedad computada que no puede cambiar el estado almacenado de la estructura, debido a su naturaleza inmutable.- Usar
mutatingsolo sirve para métodos (func). - Literalmente es imposible modificar el estado almacenado de la estructura desde una variable computada.
Solución: almacenar el estado fuera de la vista
Se crea una instancia de State (que tiene el atributo wrappedValue) y se modifica ese estado desde body.
struct PoCView: View {
var sheetVisible = State(initialValue: false)
var body: some View {
NavigationView {
VStack {
Text("Hola")
}
.navigationBarItems(trailing:
Button("Settings") {
sheetVisible.wrappedValue = true
}
)
}
}
}
Mostrar una hoja (sheet) cuando sheetVisible es true
Para detectar cambios en wrappedValue necesitamos un Publisher. State expone uno llamado projectedValue.
struct PoCView: View {
var sheetVisible = State(initialValue: false)
var body: some View {
NavigationView {
VStack {
Text("Hola")
}
.sheet(isPresented: self.sheetVisible.projectedValue) {
Text("Chao")
}
.navigationBarItems(trailing:
Button("Settings") {
sheetVisible.wrappedValue = true
}
)
}
}
}
El property‑wrapper @State
Esta implementación es tan común que SwiftUI introdujo el property‑wrapper @State.
- Acceso al
wrappedValue→ mediante el nombre de la propiedad (sheetVisible). - Acceso al
projectedValue(Publisher) → mediante el prefijo$($sheetVisible). - Acceso a la instancia completa → mediante el prefijo
_(_sheetVisible).
struct PoCView: View {
@State var sheetVisible = false
var body: some View {
NavigationView {
VStack {
Text("Hola")
}
.sheet(isPresented: self.$sheetVisible) {
Text("Chao")
}
.navigationBarItems(trailing:
Button("Settings") {
sheetVisible = true
}
)
}
}
}
Uso de Combine para descargar contenido
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)
- Se crea el
Publisherconapi.stories(). - Se indica que los valores deben recibirse en el hilo principal.
- Se suscribe con
sink, manejando tanto la finalización como los valores recibidos. - La suscripción se guarda en
subscriptionspara su posterior cancelación.
ObservableObject para el modelo de datos
ObservableObject emite a través de su propiedad objectWillChange cada vez que uno de sus @Published cambia. Esto permite que una vista de SwiftUI se repinte automáticamente cuando el modelo cambia.
class StoriesViewModel: ObservableObject {
@Published var allStories: [Story] = []
@Published var error: Error?
private var subscriptions = Set()
init() {
loadStories()
}
func loadStories() {
api.stories()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
if case .failure(let err) = completion {
self?.error = err
}
} receiveValue: { [weak self] stories in
self?.allStories = stories
self?.error = nil
}
.store(in: &subscriptions)
}
}
Consumir el ObservableObject en la vista
struct StoriesView: View {
@ObservedObject var viewModel = StoriesViewModel()
var body: some View {
List(viewModel.allStories) { story in
Text(story.title)
}
.alert(item: $viewModel.error) { error in
Alert(title: Text("Error"), message: Text(error.localizedDescription))
}
}
}
@ObservedObjectelimina el almacenamiento interno de la vista y crea un binding al modelo.- Además, agrega un
Publishera la propiedad para poder suscribirse o hacer binding dentro de la jerarquía de vistas.
Resumen
| Concepto | Qué hace | Cómo se usa en SwiftUI |
|---|---|---|
| Estado | Fuente de verdad única | @State var isOn = false |
@State | Wrapper que provee wrappedValue y projectedValue | $isOn para binding, isOn para lectura/escritura |
ObservableObject | Modelo de datos que notifica cambios | @ObservedObject var vm = MyViewModel() |
| Combine | Manejo reactivo de streams | api.stories().receive(on:...).sink{...} |
sheet(isPresented:) | Presenta una vista modal basada en un Bool | .sheet(isPresented: $showSheet) { … } |
Con estos conceptos puedes construir interfaces declarativas, reactivas y bien desacopladas en SwiftUI, aprovechando al máximo el ecosistema de Combine.
ReaderView – Manejo de errores, suscripciones y entorno en SwiftUI
Modelo y vista
class ReaderViewModel: ObservableObject {
@Published var error: API.Error? = nil
@Published private var allStories = [Story]()
// …
}
struct ReaderView: View {
@ObservedObject var model: ReaderViewModel
// …
}
Mostrar una alerta cuando error no sea nil
Código deprecado (pero funcional):
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()
)
}
}
}
Suscribirse a cualquier Publisher
No es obligatorio usar la pareja ObservableObject / ObservedObject.
Se puede suscribir la vista a un Publisher cualquiera mediante onReceive(_:perform:).
Ejemplo con Timer
private let timer = Timer.publish(every: 10, on: .main, in: .common)
.autoconnect()
.eraseToAnyPublisher()
@State var currentDate = Date() // Estado mutable de la vista
// En la vista:
.onReceive(timer) { self.currentDate = $0 }
struct ReaderView: View {
var body: some View {
// …
PostedBy(time: story.time,
user: story.by,
currentDate: self.currentDate)
// …
}
}
Nota:
PostedByse vuelve a dibujar cada vez que elTimeremite un evento.
Filtrado reactivo con Settings
Settings almacena palabras clave (keywords) que deben emitir eventos al modificarse.
final class Settings: ObservableObject {
@Published var keywords = [FilterKeyword]()
}
Conexión entre Settings y ReaderViewModel
- Suscribir
ReaderViewModel.filteralPublisherdeSettings.keywords. - Exponer
filtercomo@Publishedpara que la vista se actualice.
class ReaderViewModel: ObservableObject {
@Published var filter = [String]()
// …
}
struct HNReader: App {
let viewModel = ReaderViewModel()
let userSettings = Settings()
private var subscriptions = Set()
init() {
// Creando suscripción de filter a keywords
userSettings.$keywords
.map { $0.map { $0.value } }
.assign(to: \.filter, on: viewModel)
.store(in: &subscriptions)
}
// …
}
Uso del Environment de SwiftUI
El Environment es un pool de Publishers (p. ej. colorScheme, locale, zona horaria, etc.) que se inyectan automáticamente en la jerarquía de vistas.
Cambiar el color del enlace según el esquema de colores
class ReaderViewModel: ObservableObject {
@Environment(\.colorScheme) var colorScheme: ColorScheme
// …
.foregroundStyle(colorScheme == .light ? .blue : .orange)
}
Tip: Para ver los cambios en tiempo real, habilita
Debug ▶ View Debugging ▶ Configure Environment Overrides.
@EnvironmentObject – Inyección global de objetos
@EnvironmentObject permite inyectar cualquier objeto a lo largo de toda la jerarquía de vistas.
WindowGroup {
ContentView()
.environmentObject(Settings(theme: "Light")) // Primer objeto
.environmentObject(Settings(theme: "Dark")) // Reemplaza al anterior
}
- No se necesita especificar una key‑path; SwiftUI busca coincidencias por tipo.
- No se pueden tener dos
EnvironmentObjectdel mismo tipo.
Soluciones
1️⃣ Usar tipos diferentes
final class UserSettings: ObservableObject { … }
final class AdminSettings: ObservableObject { … }
.environmentObject(UserSettings())
.environmentObject(AdminSettings())
// En las vistas:
@EnvironmentObject var userSettings: UserSettings
@EnvironmentObject var adminSettings: AdminSettings
2️⃣ Definir EnvironmentKeys personalizados
(no se muestra aquí, pero es otra alternativa).
Listas con reordenamiento
SwiftUI ofrece onMove(perform:) para mover elementos dentro de un arreglo.
.onMove { (indices: IndexSet, newOffset: Int) in
fruits.move(fromOffsets: indices, toOffset: newOffset)
}
- Si arrastras un elemento al final,
newOffsetserá mayor que el tamaño del arreglo, indicando “después del último elemento”. move(fromOffsets:toOffset:)preserva el orden de los elementos restantes.
Código de ejemplo
// 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)
}
Preguntas (sin responder)
-
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.
¿body? -
Porque
bodyes un método de clase y no puede acceder a variables de instancia. -
Porque
bodyes una variable computada y las estructuras son inmutables. -
Porque
bodypertenece al hilo principal. -
Porque el compilador no permite usar closures dentro de
body.
@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.
ObservableObject?
- Renderizar vistas en segundo plano.
- Emitir eventos cuando las propiedades marcadas con
@Publishedcambian. - Encapsular las dependencias del entorno de SwiftUI.
- Sincronizar las actualizaciones entre varios
@State.
@ObservedObject y @State en SwiftUI?
-
@ObservedObjectconserva su valor al recrearse la vista, mientras que@Stateno. -
@Stateobserva modelos externos, mientras que@ObservedObjectmantiene su propio estado. -
@ObservedObjectobserva modelos externos (como un ViewModel), mientras que@Statemaneja el estado interno de la vista. - Son equivalentes; ambos generan Publishers automáticos.
@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… (texto incompleto en el original)