Accesibilidad en SwiftUI: VoiceOver, accessibilityLabel, Traits, Actions y Dynamic Type

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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP