Combine #15: Usando Combine desde SwiftUI (1)

Published: (January 9, 2026 at 11:42 AM EST)
8 min read
Source: Dev.to

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.text es una copia de conditions.
  • Es necesario actualizar label.text de forma deliberada cada vez que cambie conditions.

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”?

  • body es una propiedad computada que no puede cambiar el estado almacenado de la estructura, debido a su naturaleza inmutable.
  • Usar mutating solo 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 Publisher con api.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 subscriptions para 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))
        }
    }
}
  • @ObservedObject elimina el almacenamiento interno de la vista y crea un binding al modelo.
  • Además, agrega un Publisher a la propiedad para poder suscribirse o hacer binding dentro de la jerarquía de vistas.

Resumen

ConceptoQué haceCómo se usa en SwiftUI
EstadoFuente de verdad única@State var isOn = false
@StateWrapper que provee wrappedValue y projectedValue$isOn para binding, isOn para lectura/escritura
ObservableObjectModelo de datos que notifica cambios@ObservedObject var vm = MyViewModel()
CombineManejo reactivo de streamsapi.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: PostedBy se vuelve a dibujar cada vez que el Timer emite 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

  1. Suscribir ReaderViewModel.filter al Publisher de Settings.keywords.
  2. Exponer filter como @Published para 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 EnvironmentObject del 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, newOffset será 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 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.

@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 @Published cambian.
  • Encapsular las dependencias del entorno de SwiftUI.
  • Sincronizar las actualizaciones entre varios @State.

@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.

@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)
Back to Blog

Related posts

Read more »

Combine #13: Manejo de Recursos

share y multicast_: share La mayoría de los Publishers de Combine son struct que solo describen un pipeline, sin guardar un estado compartido. No se crea una i...

SwiftUI #25: Estado (@State)

El paradigma declarativo No solo se trata de cómo organizar las vistas, sino de que cada vez que cambia el estado de la aplicación, las vistas deben actualizar...