Bindings personalizados en SwiftUI: closures frente a subscripts
Arturo Rivas Arias
Binding es una de las piezas fundamentales del flujo de datos en SwiftUI. Permite que una vista lea y modifique un valor cuya propiedad pertenece a otro ámbito, sin convertirse por ello en su fuente de verdad. En los casos sencillos, el propio framework crea esa conexión mediante la sintaxis $: un TextField recibe $model.name, un Toggle recibe $settings.notificationsEnabled y la vista no necesita saber dónde se almacena realmente el dato.
La situación se complica cuando el valor que necesita un control no existe como propiedad almacenada. Puede ser una valor proyectado que se calcula a partir de un Set, una entrada de un diccionario o una representación simplificada de un estado más complejo. Binding(get:set:) resuelve cualquiera de estos escenarios, pero no siempre es la forma que mejor encaja con el sistema de dependencias de SwiftUI.
Cuando esa proyección puede expresarse como un subscript directamente en el modelo, Swift ofrece de una alternativa más estructurada: crear el binding a través de un key path. Además de limpiar el código de la vista, esta técnica puede evitar reevaluaciones provocadas por bindings construidos de nuevo cada vez que se calcula el body.
Un binding no almacena el valor
Antes de comparar las dos soluciones, conviene recordar qué representa Binding<Value>. No es la fuente de verdad ni una copia del valor, sino un acceso de lectura y otro de escritura. Cuando un control consulta wrappedValue, el binding obtiene el dato de su almacenamiento real; cuando lo modifica, escribe de vuelta en ese mismo almacenamiento.
Apple proporciona un inicializador que permite describir ambas operaciones manualmente:
let binding = Binding<Bool>(
get: {
// Devuelve el valor actual
},
set: { newValue in
// Escribe el nuevo valor
}
)
Esta API es muy flexible porque el getter y el setter pueden adaptar casi cualquier modelo a la interfaz que espera una vista. El problema no está en el inicializador, sino en utilizarlo repetidamente dentro de una parte que cambia muchas veces dentro de la jerarquía sin valorar cómo se reconstruye esa conexión.
El enfoque habitual con Binding(get:set:)
Imaginemos una pantalla que permite descargar mapas para utilizarlos sin conexión. El modelo guarda los identificadores de las regiones descargadas en un Set, mientras que cada fila necesita un Binding<Bool> para su Toggle.
import Observation
import SwiftUI
struct MapRegion: Identifiable {
let id: UUID
let name: String
let size: Measurement<UnitInformationStorage>
}
@MainActor
@Observable
final class OfflineMapsModel {
var downloadedRegionIDs: Set<MapRegion.ID> = []
var searchText = ""
}
struct MapRegionRow: View {
let region: MapRegion
@Binding var isAvailableOffline: Bool
var body: some View {
Toggle(isOn: $isAvailableOffline) {
VStack(alignment: .leading) {
Text(region.name)
Text(region.size.formatted())
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Como el booleano no existe directamente en el modelo, la solución inmediata consiste en construirlo dentro del ForEach:
struct OfflineMapsView: View {
let regions: [MapRegion]
@State private var model = OfflineMapsModel()
var body: some View {
List {
TextField("Buscar región", text: $model.searchText)
ForEach(regions) { region in
MapRegionRow(
region: region,
isAvailableOffline: Binding(
get: {
model.downloadedRegionIDs.contains(region.id)
},
set: { isAvailableOffline in
if isAvailableOffline {
model.downloadedRegionIDs.insert(region.id)
} else {
model.downloadedRegionIDs.remove(region.id)
}
}
)
)
}
}
}
}
El código es correcto y el Toggle modifica el Set esperado. Sin embargo, cada nueva evaluación de OfflineMapsView.body vuelve a crear un Binding para cada fila. Es algo que también ocurre al escribir en searchText, aunque esa propiedad no cambie qué regiones están descargadas.
Los closures capturan el modelo y el identificador de cada región. Dependiendo del contexto y de las optimizaciones del compilador, esas capturas pueden implicar almacenamiento adicional. Más importante aún para SwiftUI, las nuevas funciones son valores opacos que el framework no puede comparar de una forma semánticamente útil. Una fila puede recibir lo que parece una entrada diferente aun cuando el booleano que muestra no haya cambiado.
Esto no significa que cada Binding(get:set:) vaya a provocar necesariamente una asignación en el heap o una actualización visible. El compilador puede eliminar parte del coste y SwiftUI aplica sus propias optimizaciones. La conclusión práctica es más precisa: un binding basado en closures ofrece menos ayuda para identificar la misma proyección entre dos evaluaciones, por lo que en listas grandes o vistas que se recalculan con frecuencia conviene evitarlo.
Convertir la proyección en un subscript
La relación «esta región está disponible sin conexión» pertenece al modelo y puede representarse como un subscript con getter y setter. El dato sigue almacenado en el Set; el subscript solo presenta una vista booleana de ese almacenamiento.
@MainActor
@Observable
final class OfflineMapsModel {
var downloadedRegionIDs: Set<MapRegion.ID> = []
var searchText = ""
subscript(isAvailableOffline regionID: MapRegion.ID) -> Bool {
get {
downloadedRegionIDs.contains(regionID)
}
set {
if newValue {
downloadedRegionIDs.insert(regionID)
} else {
downloadedRegionIDs.remove(regionID)
}
}
}
}
La vista ya no necesita conocer cómo se traduce el booleano al conjunto de identificadores. Puede solicitar el binding directamente desde la proyección de @State:
struct OfflineMapsView: View {
let regions: [MapRegion]
@State private var model = OfflineMapsModel()
var body: some View {
List {
TextField("Buscar región", text: $model.searchText)
ForEach(regions) { region in
MapRegionRow(
region: region,
isAvailableOffline: $model[
isAvailableOffline: region.id
]
)
}
}
}
}
La llamada resulta compacta, pero la diferencia importante no es estética. Binding y Bindable utilizan @dynamicMemberLookup para transformar propiedades modificables en nuevos bindings mediante WritableKeyPath o ReferenceWritableKeyPath. Los key paths de Swift también pueden incluir subscripts, de modo que el compilador puede representar esta conexión como una ruta estructurada hacia el valor en lugar de como dos funciones nuevas creadas en el body.
Esa ruta incorpora tanto el subscript como su argumento, region.id. SwiftUI recibe así una proyección más estable y puede relacionarla mejor con el modelo observable. Al modificar searchText, las filas dejan de implementar que han recibido un binding completamente nuevo solo porque el padre ha vuelto a evaluar su body.
Qué ocurre con Observation
El subscript no crea un segundo sistema de almacenamiento ni duplica el estado. Su getter termina leyendo downloadedRegionIDs, por lo que Observation registra esa dependencia. El setter modifica la misma propiedad observable y produce la invalidación correspondiente.
Hay un matiz importante: almacenar todos los identificadores en un único Set mantiene la observación a nivel de esa propiedad. Cambiar una región puede invalidar otras filas que también hayan leído downloadedRegionIDs. El subscript ayuda a aislar las reevaluaciones causadas por propiedades no relacionadas, como searchText, pero no convierte automáticamente cada elemento del conjunto en una dependencia observable de forma independiente.
Si una pantalla necesita granularidad por elemento a gran escala, puede ser preferible que cada fila observe un modelo propio o que la colección almacene objetos observables identificables. Es una decisión de arquitectura diferente; el subscript mejora la forma de proyectar el estado existente, no altera por sí solo su granularidad.
Cómo comprobar las reevaluaciones
SwiftUI incluye la función de diagnóstico _printChanges(), que puede colocarse temporalmente en el cuerpo de la fila:
struct MapRegionRow: View {
let region: MapRegion
@Binding var isAvailableOffline: Bool
var body: some View {
let _ = Self._printChanges()
Toggle(region.name, isOn: $isAvailableOffline)
}
}
Al escribir en el buscador se puede comparar cuántas veces vuelve a ejecutarse el cuerpo de las filas con la versión basada en closures y con la versión basada en el subscript. _printChanges() es una ayuda de depuración, no una API sobre la que deba depender la aplicación ni una medición completa de rendimiento.
Para evaluar el impacto real también conviene utilizar el instrumento SwiftUI de Instruments y probar una compilación de Release con una cantidad representativa de elementos. Contar llamadas a body en Debug ayuda a descubrir el patrón, pero no demuestra por sí solo que exista un problema perceptible para el usuario.
Cuándo seguir utilizando closures
Binding(get:set:) continúa siendo la herramienta adecuada cuando la adaptación es puntual, no puede expresarse mediante un key path modificable o necesita combinar fuentes que no pertenecen a un único modelo. También es útil al encapsular APIs antiguas o al normalizar un valor para un control concreto.
TextField(
"Alias",
text: Binding(
get: { draft.alias ?? "" },
set: { draft.alias = $0.isEmpty ? nil : $0 }
)
)
En una pantalla pequeña, esta solución es directa y perfectamente razonable. Crear un subscript solo para ocultar dos líneas de transformación podría añadir más abstracción de la que elimina.
Los setters de un binding deberían seguir siendo síncronos, rápidos y predecibles. Iniciar una petición de red, guardar en disco o ejecutar lógica de negocio compleja desde ellos dificulta saber cuántas veces y en qué circunstancias se producirá el efecto. Es preferible actualizar el estado local y reaccionar al cambio desde una capa responsable de esas operaciones.
Cuándo elegir un subscript
El subscript encaja especialmente bien cuando la proyección se repite para muchos elementos, representa una operación natural del modelo y dispone de una lectura y una escritura simétricas. Los ejemplos típicos son un Set<ID> expuesto como booleanos, un diccionario con valores por clave o una matriz de preferencias indexada por una clave estable.
También mejora la encapsulación. La vista deja de saber si el modelo utiliza un Set, un diccionario o cualquier otra estructura. Si el almacenamiento cambia, basta con actualizar el getter y el setter del subscript sin tocar cada lugar donde se construía el binding.
El argumento debe tener una identidad estable. Utilizar el índice momentáneo de un array como clave puede realizar la escritura en un elemento equivocado después de insertar, borrar o reordenar contenido. Siempre que sea posible, es mejor proyectar mediante el identificador estable del dominio.
Una elección basada en la forma del estado
Los bindings creados con closures no son un antipatrón. Constituyen la salida más flexible cuando una vista necesita adaptar un valor y, para usos aislados, suelen ser la opción más sencilla. El problema aparece al convertirlos en la solución automática para proyecciones repetidas dentro de jerarquías que cambian con frecuencia.
Cuando el dato derivado se comporta como una propiedad indexada del modelo, un subscript variable describe mejor esa relación. SwiftUI puede formar el binding mediante un key path, la vista recibe una dependencia más estable y la lógica de lectura y escritura queda encapsulada junto a la fuente de verdad. En interfaces con listas numerosas, es una pequeña decisión de diseño que puede traducirse en código más limpio y actualizaciones más predecibles.