Saltar al contenido
K
iOS con SwiftUIPaso a paso35 min de lectura · 6 min de máquina Intermedio

@Binding

Al terminar: Usar @Binding y compararlo con el state hoisting de Compose.

Al terminar esta lección, los botones de gol de Marcador funcionan en iOS: tocas dos veces «Gol Rayo FC» y una «Gol Toros» y la tarjeta muestra 2 - 1. El marcador vive en una vista (PantallaMarcador) y los botones viven en otra (BotonesDeGol) que puede cambiarlo sin ser su dueña.

Importa porque en SwiftUI casi toda la interfaz es esto: un dueño del dato y vistas hijas que lo leen o lo cambian. Si una hija recibe una copia en vez de la conexión, el dato no cambia donde debe.

Trabajas en ~/Marcador-Swift/MarcadorApp. Los ficheros nuevos se crean con clic derecho sobre la carpeta que te indique cada paso (dentro de MarcadorApp, la que contiene MarcadorApp.swift, no el icono azul) → New File from Template… → Swift File → Next, el nombre y Create. Si el menú de tu versión de Xcode se llama distinto, usa la Terminal: mkdir -p de la carpeta y touch del fichero dentro de ~/Marcador-Swift/MarcadorApp/MarcadorApp/, y Xcode lo muestra solo.

Un enlace a un dato que vive en otro sitio

Un enlace (binding) es otra cosa que @State: no guarda el dato, guarda una conexión de lectura y escritura con un dato que vive en otro sitio. Se declara con @Binding y se le pasa con el prefijo $.

ejemplo mínimo: un dueño y una hija (con comentarios)
struct Padre: View {
    @State private var encendido = false          // el dueño: guarda el dato
 
    var body: some View {
        VStack {
            Text(encendido ? "Encendido" : "Apagado")
            Interruptor(encendido: $encendido)    // `$`: la llave, no la fotocopia
        }
    }
}
 
struct Interruptor: View {
    @Binding var encendido: Bool                  // la hija: no guarda, se conecta
 
    var body: some View {
        Button("Cambiar") { encendido.toggle() }  // `toggle()` da la vuelta al Bool; escribe en el `@State` del padre
    }
}

Al empezar se ve «Apagado» y el botón «Cambiar». Cada toque da la vuelta al valor y el texto del padre se actualiza, aunque quien lo cambió fue la hija:

Apagado       (al empezar)
Encendido     (tras tocar «Cambiar»)
Apagado       (tras tocar «Cambiar» otra vez)

encendido es el valor y $encendido es la puerta a él. El $ de un .sheet(isPresented: $…), con el que abriste una hoja desde un @State en Un UIViewController en SwiftUI, es exactamente esto: un enlace a ese @State.

Compose frente a SwiftUI

ComposeSwiftUI
remember { mutableStateOf(x) }@State private var x = …
El padre pasa valor: T y onCambio: (T) -> UnitEl padre pasa $x; la hija declara @Binding var valor: T
onCambio: (T) -> Unit: la hija avisa de qué pasóUna closure alCambiar: (T) -> Void: lo mismo
El estado baja, los eventos subenCon @Binding la hija escribe directo en el estado del padre

Un @Binding es más cómodo que el hoisting de Compose y menos estricto. El padre no se entera de qué pasó, solo de cómo quedó. Para un control genérico (un interruptor, un campo de texto) es lo ideal. Para una acción con reglas propias (registrar un gol, avisar, guardar), es mejor pasar una closure, como en Compose:

ejemplo mínimo: la hija avisa con una closure (con comentarios)
struct InterruptorConAviso: View {
    // La hija no recibe una llave: recibe una función que llamará cuando pase algo.
    let alCambiar: (Bool) -> Void
 
    var body: some View {
        // No escribe en ningún estado: solo avisa de «qué pasó».
        Button("Encender") { alCambiar(true) }
    }
}
 
struct PadreConAviso: View {
    @State private var encendido = false
 
    var body: some View {
        VStack {
            Text(encendido ? "Encendido" : "Apagado")
            // El padre decide qué hacer con el aviso: aquí lo guarda, pero podría validarlo antes.
            InterruptorConAviso { nuevo in encendido = nuevo }
        }
    }
}

Fíjate en la llamada: InterruptorConAviso { nuevo in encendido = nuevo } no lleva paréntesis. Las llaves son la closure alCambiar; Swift deja escribirla así, fuera de los paréntesis, cuando es el último argumento, y nuevo in da nombre al valor que recibe.

En el proyecto, el Lado que creas en el paso 2 sirve para decir de qué lado es un gol, local o visitante. Lo usarás en ObservableObject y @StateObject, en una función que suma un gol a mano a uno de los dos lados.

Qué pasa si olvidas el $

El segundo fallo que verás al principio es olvidar el $ al pasar el enlace a una hija que pide un @Binding. Da un error de compilación, con este texto o muy parecido (varía un poco entre versiones de Xcode):

con el bug: pasar el valor donde se espera el enlace
struct Hija: View {
    @Binding var goles: Int
    var body: some View { Text("\(goles)") }
}
 
struct Padre: View {
    @State private var goles = 0
    var body: some View { Hija(goles: goles) }     // `goles` es un Int, no un Binding<Int>
}
error: cannot convert value 'goles' of type 'Int' to expected type 'Binding<Int>', use wrapper instead
corregido: el prefijo $ pasa el enlace
struct Padre: View {
    @State private var goles = 0
    var body: some View { Hija(goles: $goles) }
}

Falla porque Hija pide una llave (Binding<Int>) y le das una fotocopia (Int). El propio mensaje lo dice: use wrapper instead. Puedes provocarlo en PantallaMarcador cuando la tengas escrita: cambia $marcador por marcador al crear BotonesDeGol, mira el error y devuélvelo.

Reto · Ejercicio: el turno de la panadería

Crea una subcarpeta nueva dentro de Ejercicios, SelectorDeTurno, y dentro un fichero SelectorDeTurno.swift (la carpeta Ejercicios la creaste en El ciclo de vida de UIViewController). En ese único fichero escribe:

  • Un enum, Turno, con dos casos: manana y tarde.
  • Una vista hija, SelectorDeTurno, que no guarda el turno sino que recibe una conexión al de otra vista, con dos botones, «Mañana» y «Tarde», que lo cambian.
  • Una vista dueña, PantallaDeTurno, que guarda el turno empezando en la mañana, muestra «Turno: mañana» o «Turno: tarde» y pone el selector debajo.
  • Una vista previa que enseñe PantallaDeTurno.

En la vista previa del lienzo (Editor → Canvas; se puede tocar como la app) debe verse «Turno: mañana» al empezar y «Turno: tarde» tras tocar «Tarde». Después, pásale al selector el valor del turno en vez de la conexión, lee el error y devuélvelo.

Ver solución
MarcadorApp/Ejercicios/SelectorDeTurno/SelectorDeTurno.swift
import SwiftUI
 
// Los dos turnos de una panadería.
enum Turno {
    case manana, tarde
}
 
// La hija: no guarda el turno, se conecta al de quien la crea.
struct SelectorDeTurno: View {
    @Binding var turno: Turno
 
    var body: some View {
        HStack(spacing: 8) {
            Button("Mañana") { turno = .manana }
            Button("Tarde") { turno = .tarde }
        }
        .buttonStyle(.bordered)
    }
}
 
// La dueña del dato: empieza en la mañana y le pasa el enlace a la hija.
struct PantallaDeTurno: View {
    @State private var turno = Turno.manana
 
    var body: some View {
        VStack(spacing: 16) {
            Text(turno == .manana ? "Turno: mañana" : "Turno: tarde")
                .font(.title2)
            SelectorDeTurno(turno: $turno)
        }
    }
}
 
#Preview {
    PantallaDeTurno()
}

Sin el $, es decir, con SelectorDeTurno(turno: turno), sale cannot convert value 'turno' of type 'Turno' to expected type 'Binding<Turno>', use wrapper instead. Con el $ vuelve a compilar. Si el lienzo de tu Xcode no responde a los toques, comprueba al menos que el fichero compila con Cmd+B.

Paso 1 · @Binding: que otra vista cambie el dato

Objetivo: que los dos botones de gol cambien el marcador, desde una vista hija (BotonesDeGol) que no es su dueña.

MarcadorApp (proyecto)
MarcadorApp
Marcador
UI
PantallaMarcador.swiftse modifica
Se modifica el fichero de la lección anterior.

Piezas nuevas de este paso:

  • @Binding var marcador: Marcador: la llave en la hija. Escribir marcador = … dentro de la hija cambia el @State del padre.
  • $marcador: el enlace, para pasárselo a la hija.
  • Marcador(local: …, visitante: …): un Marcador es inmutable porque sus propiedades son let (no se pueden cambiar tras crearlo), así que sumar un gol no lo modifica: se crea otro con un gol más y se asigna.

Intenta escribirlo tú: PantallaMarcador es la dueña del marcador, que empieza en 0-0, y se lo da a la tarjeta para pintarlo. En lugar de la fila de botones, usa una vista hija, BotonesDeGol, que recibe el partido y una conexión al marcador de la pantalla (no una copia): sus dos botones, los de antes, ahora suman un gol a su lado, creando un marcador nuevo con ese gol de más. Lo que debes ver: tras pulsar dos veces «Gol Rayo FC» y una «Gol Toros», la tarjeta dice 2 - 1. Es el fichero PantallaMarcador.swift que ya tienes, en Marcador/UI: no se crea ninguno nuevo. Cuando termines, abre el bloque y compara (o sustituye el fichero entero).

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PantallaMarcador.swift: versión final
import SwiftUI
import Torneo
 
struct PantallaMarcador: View {
    let partido: Partido = Ejemplo.rayoContraToros
 
    // El dato y su dueña: el marcador vive aquí.
    @State private var marcador = Marcador(local: 0, visitante: 0)
 
    var body: some View {
        VStack(spacing: 24) {
            Text("Marcador")
                .font(.headline)
 
            TarjetaDePartido(partido: partido, marcador: marcador)
 
            // `$marcador` (con dólar) es el enlace al estado: no el valor, sino una conexión de lectura y escritura.
            BotonesDeGol(partido: partido, marcador: $marcador)
 
            Spacer()
        }
        .padding(16)
    }
}
 
// Los botones de gol: una vista hija que cambia el marcador sin ser su dueña.
struct BotonesDeGol: View {
    let partido: Partido
    // La llave: no guarda el dato, se conecta al `@State` de quien la creó.
    @Binding var marcador: Marcador
 
    var body: some View {
        HStack(spacing: 8) {
            Button("Gol \(partido.local.nombre)") {
                // Un `Marcador` es inmutable: se crea otro con un gol más y se asigna al enlace.
                marcador = Marcador(local: marcador.local + 1, visitante: marcador.visitante)
            }
            Button("Gol \(partido.visitante.nombre)") {
                marcador = Marcador(local: marcador.local, visitante: marcador.visitante + 1)
            }
        }
        .buttonStyle(.borderedProminent)
    }
}
 
struct TarjetaDePartido: View {
    let partido: Partido
    let marcador: Marcador
 
    var body: some View {
        HStack {
            Text(partido.local.nombre)
                .font(.title3)
                .frame(maxWidth: .infinity, alignment: .leading)
            Text("\(marcador.local) - \(marcador.visitante)")
                .font(.system(size: 40, weight: .bold, design: .rounded))
                .monospacedDigit()
            Text(partido.visitante.nombre)
                .font(.title3)
                .frame(maxWidth: .infinity, alignment: .trailing)
        }
        .padding(20)
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
    }
}
 
#Preview {
    PantallaMarcador()
}

Ejecuta con Cmd+R. Toca dos veces «Gol Rayo FC» y una «Gol Toros»: la tarjeta muestra 2 - 1.

Paso 2 · El Lado

Objetivo: un enum pequeño, Lado, que todavía no usa nadie en esta lección: lo estrena ObservableObject y @StateObject, en la función que suma un gol a mano al local o al visitante.

MarcadorApp (proyecto)
MarcadorApp
Marcador
Domain
Lado.swiftse crea
Un fichero nuevo.

Lado no dibuja nada: dice de qué lado se marca. Va en Domain, la carpeta de lo que la funcionalidad sabe de sí misma, sin pantallas. Créala dentro de Marcador: clic derecho sobre la carpeta Marcador → New Folder, de nombre Domain. Después, clic derecho sobre Domain → New File from Template… → Swift File → Next, nombre Lado, Create. Desde la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Marcador/Domain && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Marcador/Domain/Lado.swift.

Intenta escribirlo tú: un enum con dos casos, local y visitante. Cuando termines, abre el bloque.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/Domain/Lado.swift
// Un lado de un partido: el local o el visitante. Un `enum` con dos casos.
enum Lado {
    case local, visitante
}

En Xcode 26 los ficheros que creas dentro de la carpeta MarcadorApp entran solos en el target de la app, y no hace falta marcar nada más. Cmd+B: Build Succeeded.

Paso 3 · Las pruebas

Objetivo: una prueba unitaria que muestra que un Marcador es un valor, y una prueba de interfaz que toca los botones.

Esta prueba unitaria se copia tal cual. Comprueba lo que dice el paso 1: un gol no modifica el marcador de antes, devuelve otro. Usa despuesDe(_:en:), el método de Marcador (del paquete Torneo) que calcula el marcador tras un evento; lo escribiste en AsyncStream en Marcador. Dentro de struct MarcadorAppTests (el fichero MarcadorAppTests.swift, en la carpeta MarcadorAppTests), debajo de la prueba que ya tienes:

MarcadorAppTests.swift: una prueba nueva
    // Un gol no modifica el marcador de antes: devuelve otro, y el de antes sigue igual.
    @Test func sumarUnGolCreaOtroMarcador() {
        let partido = Ejemplo.rayoContraToros
        let antes = Marcador(local: 0, visitante: 0)
        // Un gol del Rayo FC (el local) en el minuto 12.
        let gol = EventoDePartido.gol(minuto: 12, jugador: Ejemplo.ana, equipo: partido.local)
        let despues = antes.despuesDe(gol, en: partido)
        #expect(antes == Marcador(local: 0, visitante: 0))     // el de antes no cambió
        #expect(despues == Marcador(local: 1, visitante: 0))   // el nuevo lleva el gol
        #expect(despues != antes)                              // y son dos marcadores distintos
    }

La prueba de interfaz es mecánica y no enseña nada nuevo. Si no recuerdas cómo funciona una (arrancan la app en otro proceso y la manejan como un usuario), repasa la tabla de piezas de XCUITest de Tus primeras pruebas en iOS. Es una prueba nueva: toca dos veces «Gol Rayo FC» y una «Gol Toros» y espera ver 2 - 1. Abre MarcadorAppUITests → MarcadorAppUITests.swift y añádela dentro de la clase, entre las dos pruebas que ya tienes: debajo de la llave que cierra testArrancaYMuestraElMarcador (la que viene después de capturar(app, "marcador")) y encima de @MainActor de testLaboratorioMuestraElOrdenDeLosModificadores. Deja una línea en blanco antes y otra después:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppUITests.swift: una prueba nueva
    // Cada botón cambia el @State y la tarjeta se redibuja.
    @MainActor
    func testLosBotonesDeGolCambianElMarcador() throws {
        let app = XCUIApplication()
        app.launch()
        app.buttons["Gol Rayo FC"].tap()
        app.buttons["Gol Rayo FC"].tap()
        app.buttons["Gol Toros"].tap()
        XCTAssertTrue(app.staticTexts["2 - 1"].waitForExistence(timeout: 3))
        capturar(app, "marcador-2-1")
    }

Pasa todas las pruebas con Cmd+U, o desde la Terminal:

cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' build test

El nombre del simulador tiene que existir en tu Mac; con xcrun simctl list devices available ves los que tienes.

Punto de control

  • En la pestaña «Marcador» la tarjeta arranca en 0 - 0. Tras tocar dos veces «Gol Rayo FC» y una «Gol Toros» muestra 2 - 1.
  • Las pruebas pasan, y la orden termina en ** TEST SUCCEEDED ** (con el simulador que tengas: cambia iPhone 17 por un nombre de xcrun simctl list devices available): siete pruebas, dos unitarias y cinco de interfaz.
  • El proyecto tiene Marcador/UI/PantallaMarcador.swift (con PantallaMarcador, BotonesDeGol y TarjetaDePartido) y Marcador/Domain/Lado.swift, y ya no existe PantallaMarcadorEstatica.swift. En Ejercicios tienes ContadorDeCafes y SelectorDeTurno.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.La vista hija declara @Binding var marcador: Marcador. ¿Cómo se lo pasa el padre?

  2. 2.Un botón registra un gol y además debe comprobar reglas antes de cambiar nada (por ejemplo, que el partido no haya terminado). ¿Qué es mejor pasarle a la vista hija?

  3. 3.En el padre hay @State private var encendido = false. ¿Qué es $encendido?

  4. 4.La hija tiene @Binding var encendido: Bool y un botón que ejecuta encendido.toggle(). ¿Qué cambia?

  5. 5.Un Marcador es inmutable (sus propiedades son let). ¿Cómo suma un gol el botón de BotonesDeGol?

Faltan 5 de 5