La accesibilidad en SwiftUI es una de las áreas donde el framework brilla de forma especial: muchos controles son accesibles por defecto con VoiceOver sin ningún trabajo adicional. Sin embargo, las interfaces personalizadas necesitan anotaciones explícitas. Aprenderlas no solo ayuda a usuarios con discapacidad, sino que también mejora la integración con Siri y los controles de voz.
accessibilityLabel y accessibilityHint
accessibilityLabel describe qué es el elemento. accessibilityHint explica qué ocurre al activarlo. VoiceOver lee primero el label y después, opcionalmente, el hint:
import SwiftUI
struct BotonesAccesibles: View {
@State private var favorito = false
var body: some View {
Button(action: { favorito.toggle() }) {
Image(systemName: favorito ? "heart.fill" : "heart")
}
.accessibilityLabel(favorito ? "Quitar de favoritos" : "Añadir a favoritos")
.accessibilityHint("Doble toque para cambiar.")
// Para imágenes decorativas que VoiceOver debe ignorar:
Image("fondo-decorativo")
.accessibilityHidden(true)
}
}
Traits: comportamiento de los elementos
Los traits informan a VoiceOver de la función del elemento, permitiendo una interacción más natural:
struct TipoDeElemento: View {
var body: some View {
VStack {
// Encabezado de sección (VoiceOver lo anuncia como "Encabezado")
Text("Sección de configuración")
.accessibilityAddTraits(.isHeader)
// Elemento seleccionado (VoiceOver anuncia "seleccionado")
Text("Opción A")
.accessibilityAddTraits(.isSelected)
// Elemento que cambia frecuentemente (VoiceOver no cachea el label)
Text("Cargando: (progreso)%")
.accessibilityAddTraits(.updatesFrequently)
// Enlace que abrirá el navegador
Text("Más información")
.accessibilityAddTraits(.isLink)
// Eliminar traits heredados innecesarios
Button("Cancelar") { }
.accessibilityRemoveTraits(.isButton) // poco habitual
}
}
}
Acciones personalizadas
Cuando un elemento tiene varias acciones, accessibilityAction las expone a VoiceOver sin añadir botones visibles en la pantalla:
struct TareaRow: View {
let tarea: Tarea
var onCompletar: () -> Void
var onEliminar: () -> Void
var onPosponer: () -> Void
var body: some View {
Text(tarea.titulo)
.accessibilityLabel(tarea.titulo)
.accessibilityAction(named: "Completar") { onCompletar() }
.accessibilityAction(named: "Eliminar") { onEliminar() }
.accessibilityAction(named: "Posponer 1 hora") { onPosponer() }
.accessibilityAction(.default) { onCompletar() } // acción de doble toque
}
}
Agrupar elementos: accessibilityElement
Para que VoiceOver trate varios elementos como uno solo (evitando navegación excesiva):
struct TarjetaProducto: View {
let producto: Producto
var body: some View {
HStack {
AsyncImage(url: producto.imagenURL)
.frame(width: 60, height: 60)
VStack(alignment: .leading) {
Text(producto.nombre)
.font(.headline)
Text(producto.precio, format: .currency(code: "EUR"))
.foregroundStyle(.secondary)
}
}
// VoiceOver lee todo el HStack como un elemento
.accessibilityElement(children: .combine)
.accessibilityLabel("(producto.nombre), (producto.precio.formatted(.currency(code: "EUR")))")
}
}
Dynamic Type con @ScaledMetric
Dynamic Type permite que los usuarios elijan el tamaño del texto. Los tamaños hardcodeados no respetan esta preferencia. @ScaledMetric escala valores numéricos igual que el sistema escala el texto:
struct ElementoEscalable: View {
@ScaledMetric(relativeTo: .body) private var tamanoIcono: CGFloat = 24
@ScaledMetric private var espaciado: CGFloat = 8
var body: some View {
HStack(spacing: espaciado) {
Image(systemName: "star.fill")
.font(.system(size: tamanoIcono))
Text("Elemento favorito")
}
}
}
Alto contraste y reducción de movimiento
Para adaptar la interfaz a las preferencias del sistema de contraste y movimiento:
struct VistaAdaptable: View {
@Environment(.colorSchemeContrast) private var contraste
@Environment(.accessibilityReduceMotion) private var reducirMovimiento
@State private var visible = false
var body: some View {
Rectangle()
.fill(contraste == .increased ? .black : .gray)
.frame(height: 4)
Image(systemName: "star")
.scaleEffect(visible ? 1.5 : 1.0)
.animation(
reducirMovimiento ? nil : .spring(response: 0.4),
value: visible
)
.onTapGesture { visible.toggle() }
}
}
accessibilityValue para controles con estado
Los sliders y toggles personalizados necesitan un accessibilityValue que VoiceOver pueda leer:
struct SliderPersonalizado: View {
@Binding var valor: Double // 0.0 a 1.0
var body: some View {
GeometryReader { geo in
// Barra de progreso personalizada
ZStack(alignment: .leading) {
Capsule().fill(.gray.opacity(0.3))
Capsule().fill(.blue)
.frame(width: geo.size.width * valor)
}
.gesture(DragGesture().onChanged { gesto in
valor = max(0, min(1, gesto.location.x / geo.size.width))
})
}
.frame(height: 8)
.accessibilityLabel("Volumen")
.accessibilityValue("(Int(valor * 100)) por ciento")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: valor = min(1, valor + 0.1)
case .decrement: valor = max(0, valor - 0.1)
@unknown default: break
}
}
}
}
Resumen
La accesibilidad en SwiftUI se construye capa a capa: accessibilityLabel y accessibilityHint para describir elementos; traits para comunicar su función; acciones personalizadas para exponer operaciones sin saturar la UI visual; accessibilityElement(children: .combine) para agrupar tarjetas; @ScaledMetric para respetar Dynamic Type; y el entorno de contraste y movimiento para adaptar animaciones y colores. Una app accesible no es un añadido opcional, sino una señal de calidad que también mejora la integración con Siri y los atajos de teclado de iPad y Mac.
