Programación en Swift y SwiftUI para iOS Developers

Picker onChange en SwiftUI

Cualquier iOS Developer que haya dado el salto de UIKit a SwiftUI se ha topado con un cambio de paradigma fundamental: el paso de la programación imperativa a la declarativa. En el antiguo mundo de UIKit, usábamos delegados (UITextFieldDelegate, UIPickerViewDelegate) o el patrón Target-Action para saber cuándo un usuario interactuaba con un control.

En la programación Swift moderna, las vistas son una función de su estado. Pero, ¿qué ocurre cuando necesitamos ejecutar una lógica específica exactamente en el momento en que ese estado cambia? ¿Qué pasa si queremos guardar datos en disco, reproducir un sonido o hacer una llamada a una API cuando el usuario selecciona una nueva opción?

Aquí es donde entra en juego uno de los modificadores más cruciales de tu arsenal en Xcode: el modificador onChange.

En este tutorial de SwiftUI, vamos a explorar en profundidad qué es, cómo ha evolucionado en las últimas versiones de Swift, y cómo implementarlo correctamente, haciendo especial hincapié en casos de uso muy demandados como Picker onChange SwiftUI, adaptando nuestro código para iOS, macOS y watchOS.


¿Qué es el modificador onChange en SwiftUI?

En términos sencillos, .onChange es un modificador de vista (View Modifier) que escucha los cambios producidos en un valor específico (generalmente una variable @State, @Binding, o una propiedad de un objeto @Observable) y ejecuta un bloque de código (closure) cada vez que ese valor se actualiza.

El error común de los principiantes: @State y didSet

Muchos desarrolladores que se inician en la programación Swift con SwiftUI intentan reaccionar a los cambios de estado usando el observador de propiedades didSet dentro de una variable @State:

// ❌ ANTI-PATRÓN: Esto no funciona bien en SwiftUI
@State private var textoBusqueda = "" {
    didSet {
        print("El texto cambió a: \(textoBusqueda)")
    }
}

¿Por qué esto es una mala idea? Porque los Property Wrappers como @State destruyen y recrean la vista internamente para manejar su ciclo de vida. didSet a menudo no se dispara cuando esperas que lo haga, o lo hace de manera inconsistente.

La forma correcta, nativa y segura que Apple nos proporciona en Xcode es usar el modificador .onChange().


La Evolución de la API: De iOS 14 a iOS 17+

Como buen iOS Developer, debes saber que SwiftUI evoluciona rápidamente. El modificador onChange recibió una actualización masiva en iOS 17, macOS 14 y watchOS 10. Es vital conocer ambas versiones para mantener código heredado (legacy) y escribir código moderno.

La sintaxis antigua (iOS 14 – iOS 16)

La versión clásica tomaba el valor a observar y te devolvía únicamente el nuevo valor.

.onChange(of: textoBusqueda) { nuevoValor in
    print("El nuevo valor es \(nuevoValor)")
}

La sintaxis moderna (iOS 17+ / macOS 14+ / watchOS 10+)

Apple mejoró esta API de Swift para darnos más contexto. Ahora, el closure nos proporciona tanto el valor antiguo (old value) como el nuevo valor (new value). Además, introdujeron un parámetro initial para decidir si la acción debe ejecutarse cuando la vista se dibuja por primera vez.

.onChange(of: textoBusqueda, initial: true) { valorAntiguo, valorNuevo in
    print("Cambió de \(valorAntiguo) a \(valorNuevo)")
}

A lo largo de este tutorial, utilizaremos la sintaxis moderna recomendada para las últimas versiones de Xcode.


Caso de Uso 1: El Clásico TextField en iOS

Para ilustrar los conceptos básicos, vamos a crear una pantalla de registro simple en iOS. Queremos validar un nombre de usuario en tiempo real mientras el usuario escribe.

<pre class="wp-block-syntaxhighlighter-code">import SwiftUI
struct RegistroUsuarioView: View {
    @State private var nombreUsuario = ""
    @State private var mensajeError = ""
    
    var body: some View {
        NavigationStack {
            Form {
                Section(header: Text("Datos del perfil")) {
                    TextField("Nombre de usuario", text: $nombreUsuario)
                        .autocapitalization(.none)
                        // Implementamos onChange para validar en tiempo real
                        .onChange(of: nombreUsuario) { antiguo, nuevo in
                            validarNombreUsuario(nuevo)
                        }
                    
                    if !mensajeError.isEmpty {
                        Text(mensajeError)
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                }
            }
            .navigationTitle("Registro")
        }
    }
    
    // Función de lógica de negocio separada de la vista
    private func validarNombreUsuario(_ nombre: String) {
        if nombre.count < 4 {
            mensajeError = "El nombre debe tener al menos 4 caracteres."
        } else if nombre.contains(" ") {
            mensajeError = "El nombre no puede contener espacios."
        } else {
            mensajeError = "" // Válido
        }
    }
}</pre>

En este ejemplo, cada vez que el usuario pulsa una tecla, el estado nombreUsuario se actualiza. El modificador .onChange detecta este cambio y llama a nuestra función de validación, actualizando la interfaz inmediatamente de forma reactiva.


Caso de Uso 2: Dominando Picker onChange SwiftUI

Una de las búsquedas más comunes entre la comunidad de desarrollo es cómo manejar un Picker onChange SwiftUI. A diferencia de un TextField donde el usuario escribe libremente, un Picker representa una selección discreta de un conjunto de opciones predefinidas.

Imagina que estás desarrollando una aplicación de comercio electrónico y el usuario debe seleccionar un método de envío. Al cambiar el método de envío en el Picker, necesitamos recalcular el precio total inmediatamente.

Veamos cómo implementar esto de forma elegante usando un Enum (una de las características más potentes de la programación Swift).

import SwiftUI

// 1. Definimos nuestras opciones fuertemente tipadas
enum MetodoEnvio: String, CaseIterable, Identifiable {
    case estandar = "Estándar (5-7 días)"
    case express = "Express (24-48 horas)"
    case mismoDia = "Mismo Día"
    
    var id: Self { self }
    
    var precio: Double {
        switch self {
        case .estandar: return 4.99
        case .express: return 12.99
        case .mismoDia: return 24.99
        }
    }
}

struct CheckoutView: View {
    @State private var envioSeleccionado: MetodoEnvio = .estandar
    @State private var subtotal: Double = 100.00
    @State private var totalFinal: Double = 104.99
    
    var body: some View {
        NavigationStack {
            List {
                Section("Resumen del Pedido") {
                    Text("Subtotal: $\(subtotal, specifier: "%.2f")")
                }
                
                Section("Opciones de Envío") {
                    // 2. Creamos el Picker
                    Picker("Método de envío", selection: $envioSeleccionado) {
                        ForEach(MetodoEnvio.allCases) { metodo in
                            Text(metodo.rawValue).tag(metodo)
                        }
                    }
                    .pickerStyle(.navigationLink)
                    // 3. La magia de Picker onChange SwiftUI
                    .onChange(of: envioSeleccionado) { viejoMetodo, nuevoMetodo in
                        print("El usuario cambió de \(viejoMetodo.rawValue) a \(nuevoMetodo.rawValue)")
                        recalcularTotal(con: nuevoMetodo)
                    }
                }
                
                Section("Total a Pagar") {
                    Text("$\(totalFinal, specifier: "%.2f")")
                        .font(.title)
                        .bold()
                }
            }
            .navigationTitle("Checkout")
        }
    }
    
    private func recalcularTotal(con metodo: MetodoEnvio) {
        // En un caso real, aquí podríamos tener lógica compleja
        totalFinal = subtotal + metodo.precio
    }
}

¿Por qué esto es tan poderoso?

Como iOS Developer, implementar esto en UIKit requería conformar a UIPickerViewDelegate, crear un array de strings para las filas, leer el índice de la fila seleccionada, mapear ese índice de vuelta a tu modelo de datos y luego forzar una actualización del label del precio.

En SwiftUI, al combinar un Enum con @State y .onChange, el flujo de datos es unidireccional, predecible y requiere una fracción del código en Xcode.


Llevando onChange a macOS

La belleza de la programación Swift moderna es su naturaleza multiplataforma. Supongamos que estás creando una aplicación para Mac (macOS). En el escritorio, las interacciones suelen ser diferentes; usamos el ratón, ventanas y menús laterales.

Vamos a usar onChange para reaccionar a la selección de un elemento en una barra lateral (Sidebar) típica de macOS, y además, veremos cómo usar el parámetro initial introducido en macOS 14.

import SwiftUI

struct MacOSDashboardView: View {
    let categorias = ["General", "Cuentas", "Privacidad", "Avanzado"]
    @State private var categoriaSeleccionada: String? = "General"
    @State private var datosCargados: String = "Esperando datos..."
    
    var body: some View {
        NavigationSplitView {
            List(categorias, id: \.self, selection: $categoriaSeleccionada) { categoria in
                Text(categoria)
            }
            .navigationTitle("Ajustes")
            // Reaccionamos al cambio de selección en el Sidebar
            .onChange(of: categoriaSeleccionada, initial: true) { antigua, nueva in
                if let categoriaActiva = nueva {
                    cargarDatosParaMac(categoria: categoriaActiva)
                }
            }
        } detail: {
            VStack(spacing: 20) {
                Text("Viendo: \(categoriaSeleccionada ?? "Nada")")
                    .font(.largeTitle)
                
                Text(datosCargados)
                    .foregroundColor(.secondary)
            }
            .padding()
        }
    }
    
    private func cargarDatosParaMac(categoria: String) {
        // Simulamos una carga de datos
        datosCargados = "Se han cargado los parámetros para la sección: \(categoria)."
    }
}

La importancia del parámetro initial: true: Si compilas esto en Xcode, notarás que al abrir la app, cargarDatosParaMac se ejecuta inmediatamente con “General”. Si no usáramos initial: true, el panel de detalle mostraría “Esperando datos…” hasta que el usuario hiciera clic en otra opción. Esto ahorra tener que duplicar la llamada a la función en un modificador .onAppear.


Interacciones Físicas en watchOS

En el Apple Watch, los usuarios no tienen mucho espacio para navegar por listas complejas. Una interacción común es usar la Corona Digital (Digital Crown) mediante un Slider. Veamos cómo un iOS Developer puede adaptar sus conocimientos para reaccionar a los cambios de un slider en watchOS.

<pre class="wp-block-syntaxhighlighter-code">import SwiftUI
struct WatchOSControlView: View {
    @State private var volumen: Double = 50.0
    @State private var iconoVolumen: String = "speaker.wave.2.fill"
    
    var body: some View {
        VStack {
            Image(systemName: iconoVolumen)
                .font(.system(size: 40))
                .foregroundColor(.blue)
                .padding(.bottom)
            
            // Slider que se puede controlar con la pantalla o la Corona Digital
            Slider(value: $volumen, in: 0...100, step: 10)
                .tint(.blue)
                .onChange(of: volumen) { _, nuevoVolumen in
                    actualizarIcono(para: nuevoVolumen)
                    hacerVibrarReloj()
                }
            
            Text("\(Int(volumen))%")
        }
        .padding()
    }
    
    private func actualizarIcono(para nivel: Double) {
        switch nivel {
        case 0:
            iconoVolumen = "speaker.slash.fill"
        case 1..<33:
            iconoVolumen = "speaker.wave.1.fill"
        case 33..<66:
            iconoVolumen = "speaker.wave.2.fill"
        default:
            iconoVolumen = "speaker.wave.3.fill"
        }
    }
    
    private func hacerVibrarReloj() {
        // En una app real de watchOS usaríamos WKInterfaceDevice
        print("Vibración háptica ejecutada al cambiar el volumen")
    }
}</pre>

El uso de onChange aquí es fundamental porque nos permite lanzar efectos secundarios (como la retroalimentación háptica del reloj) que no afectan puramente al dibujo de la interfaz, sino a la experiencia física del dispositivo.


Buenas Prácticas y Anti-patrones con onChange

Para ser un iOS Developer Senior, no basta con saber que la herramienta existe; debes saber cuándo no usarla o cómo evitar problemas de rendimiento.

1. Cuidado con los Bucles Infinitos

El error más peligroso al usar onChange en SwiftUI es modificar el mismo estado que estás observando dentro del bloque del closure sin una condición de salida sólida.

// ⚠️ PELIGRO: Posible bucle infinito
@State private var cantidad = 0

var body: some View {
    Button("Sumar") { cantidad += 1 }
        .onChange(of: cantidad) { _, nuevaCantidad in
            // Si modificas el estado observado aquí incondicionalmente,
            // onChange se llamará a sí mismo infinitamente hasta crashear.
            // cantidad = nuevaCantidad + 1 // ESTO ROMPERÁ LA APP
        }
}

2. Tareas Asíncronas (Async/Await) dentro de onChange

Si dentro de tu onChange necesitas llamar a una función asíncrona (por ejemplo, descargar algo de internet usando el nuevo sistema de concurrencia de la programación Swift), no puedes hacerlo directamente, ya que el closure de onChange es síncrono.

Tienes dos opciones:

Opción A: Usar un Task interno (Bueno)

.onChange(of: busqueda) { _, nuevoTexto in
    Task {
        await realizarBusquedaEnRed(query: nuevoTexto)
    }
}

Opción B: Usar .task(id:) (Mejor para llamadas de red)

SwiftUI ofrece un modificador específico llamado .task(id:) que actúa como un onChange asíncrono. Si el id (el valor que observa) cambia antes de que termine la tarea de red anterior, SwiftUI cancelará automáticamente la tarea anterior e iniciará la nueva. ¡Esto es oro puro para barras de búsqueda!

TextField("Buscar", text: $textoBusqueda)
    // Se ejecuta al inicio y cada vez que textoBusqueda cambia.
    // Además, soporta await de forma nativa y cancela peticiones obsoletas.
    .task(id: textoBusqueda) {
        await cargarResultadosAPI(query: textoBusqueda)
    }

3. Mantén el closure ligero

El modificador onChange se ejecuta en el hilo principal (Main Thread). Si colocas cálculos matemáticos intensivos o lógica de bloqueo pesado dentro de este closure, tu interfaz de usuario se congelará (stuttering). Siempre delega el trabajo pesado a funciones externas que se ejecuten en background threads (usando Task o DispatchQueue.global).


Tabla Resumen: Comparativa de Métodos Reactivos

Para que tengas una referencia rápida en tu próximo proyecto de Xcode, aquí tienes cuándo usar qué modificador:

Modificador / Técnica¿Cuándo usarlo?Soporte Async nativo
didSet en @StatePrácticamente NUNCA en vistas SwiftUI. Usar solo en clases Observables puras (ViewModels).No
.onChange(of:)Para reaccionar a cambios de UI (ej. un Picker), ejecutar efectos secundarios, validaciones rápidas o analíticas.No (Requiere envolver en Task)
.task(id:)Para hacer llamadas de red (API REST) o cargar datos de bases de datos cuando un estado de la UI cambia.Sí (Cancela tareas anteriores automáticamente)

Conclusión

El modificador onChange es un pilar absoluto en la programación Swift moderna y el framework SwiftUI. Permite tender un puente entre la naturaleza declarativa de la interfaz (cómo se ven las cosas) y la naturaleza imperativa de la lógica de negocio (qué debe suceder cuando el usuario hace algo).

Si tienes cualquier duda sobre este artículo, contacta conmigo y estaré encantado de ayudarte 🙂. Puedes contactar conmigo en mi perfil de X o en mi perfil de Instagram.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

ListStyle en SwiftUI

Next Article

keyboardType en SwiftUI

Related Posts