Prístupnosť v SwiftUI: Kompletný sprievodca VoiceOver, Dynamic Type a accessibility traits v iOS 26

Kompletný sprievodca prístupnosťou v SwiftUI pre iOS 26. Pokrýva VoiceOver, Dynamic Type, @ScaledMetric, accessibility traits, Reduce Motion, AccessibilityRotor a audio grafy v Swift Charts. S príkladmi kódu a praktickými tipmi z code review.

SwiftUI Prístupnosť iOS 26: Sprievodca

Aktualizované: 19. júna 2026

Prístupnosť v SwiftUI je súbor modifikátorov a API, ktoré opisujú vašu používateľskú vrstvu pre asistívne technológie (VoiceOver, Switch Control, Voice Control a Dynamic Type) bez toho, aby ste museli prepisovať vizuálnu vrstvu. V mojej praxi platí jedno pravidlo: ak prístupnosť nie je vpletená do dizajnu od prvého VStack, vždy ju potom dolepujete a vždy to vidieť. Tento sprievodca pokrýva všetko, čo v iOS 26 reálne potrebujete, od accessibilityLabel cez traits, Dynamic Type, Reduce Motion až po Accessibility Inspector a audio grafy v Swift Charts.

  • SwiftUI generuje predvolené accessibility elementy zo štandardných komponentov ako Button, Text a Toggle. Vlastné kresby v Canvas alebo Shape však musíte popísať ručne cez accessibilityLabel.
  • Accessibility traits (.isButton, .isHeader, .updatesFrequently) určujú, ako VoiceOver oznámi prvok aj akú gestiku ponúkne. Bez nich užívateľ nevie, či má prvok ťuknúť alebo posunúť.
  • Dynamic Type podporujete cez SF fontové štýly (.font(.body)) a @ScaledMetric pre rozmery, ktoré sa majú škálovať lineárne s textom (ikony, padding).
  • iOS 26 priniesol accessibilityZoomAction, lepšie AccessibilityRotor API a natívne audio grafy v Swift Charts cez chartAccessibility(_:).
  • Testovanie robte v Accessibility Inspector v Xcode 16+ a aspoň raz za sprint zapnite VoiceOver na reálnom zariadení. Simulátor nezachytí cadenc reči ani gestá.
  • Reduce Motion a Differentiate Without Color sú dva najčastejšie ignorované signály. Riešte ich cez @Environment(\.accessibilityReduceMotion) a doplnkové ikony, nielen farby.

Čo je prístupnosť v SwiftUI a prečo na nej záleží

Prístupnosť v SwiftUI je vrstva metadát, ktorú framework prikladá k vašim view hierarchiám, aby asistívne technológie pochopili, čo je na obrazovke a čo s tým môže používateľ robiť. Keď napíšete Button("Uložiť") { save() }, SwiftUI automaticky vytvorí accessibility element s labelom "Uložiť", traitom .isButton a aktivačnou akciou. To je hlavný dôvod, prečo sú deklaratívne frameworky pre prístupnosť výhodné. Popis je vedľajším produktom toho, ako popisujete UI.

Problém začína vtedy, keď opustíte štandardné komponenty. Vlastný Canvas, gestá na Shape, či custom segmented control z dvoch Rectangle-ov sú pre VoiceOver neviditeľné, kým mu to ručne nepoviete. V mojej praxi je toto najčastejší zdroj a11y bugov v review. Vývojár zamení Picker za pekný vlastný komponent a tým zruší prístupnosť pre všetkých používateľov s VoiceOverom. (Áno, robil som to aj ja, asi v druhom roku so SwiftUI, a od tej doby si na to dávam pozor.)

Podľa Apple Accessibility tímu denne aktívne používa asistívne technológie viac než 15 % používateľov iPhonu. Od zníženého kontrastu, cez Dynamic Type až po plne nevidomých používateľov VoiceOveru. Z hľadiska App Store reviewov je prístupnosť v iOS 26 tiež jednou z najčastejšie spomínaných tém v rejection feedbacku pre apky orientované na verejný sektor.

accessibilityLabel, Hint a Value: tri vrstvy popisu

VoiceOver číta každý prvok v troch fázach: label (čo to je), value (aká je jeho hodnota) a hint (čo sa stane, keď to aktivujete). Tieto tri modifikátory sú váš primárny nástroj a v praxi tvoria zhruba 80 % všetkej a11y práce.

struct VolumeSliderView: View {
    @State private var volume: Double = 0.5

    var body: some View {
        Slider(value: $volume)
            .accessibilityLabel("Hlasitosť prehrávania")
            .accessibilityValue(Text("\(Int(volume * 100)) percent"))
            .accessibilityHint("Posuňte nahor pre zvýšenie hlasitosti")
    }
}

Pravidlo, ktoré používam pri review: label nikdy neopakuje typ prvku. "Tlačidlo Uložiť" je zlý label, pretože VoiceOver už trait .isButton oznámi sám ("Uložiť, tlačidlo"). Label je krátky podstatný popis. Hint pridajte len ak akcia nie je z labelu zjavná, napríklad pri swipe geste, ktoré nie je v UI viditeľné.

Value používajte vždy, keď sa hodnota mení dynamicky (slider, stepper, toggle, graf). Bez accessibilityValue VoiceOver oznámi len typ prvku, ale nie jeho aktuálny stav. To je presne ten typ chyby, ktorý sa nikdy neobjaví v simulátore, ale ihneď v produkčnej recenzii. Tento bug som naposledy chytil cez nočný TestFlight, asi týždeň pred releaseom, a odvtedy si ho automaticky kontrolujem v každom PR review.

Accessibility traits: ako VoiceOver pozná typ prvku

Traits sú flagy, ktoré hovoria VoiceOveru, akú rolu prvok zohráva. Najčastejšie potrebujete .isButton, .isHeader, .isSelected, .isImage, .updatesFrequently a .startsMediaSession. Pri vlastných komponentoch je nastavenie správneho traitu rozdielom medzi "fungujúce UI" a "VoiceOver háda, čo má robiť, a používateľ sa stratí".

struct CardTitle: View {
    let text: String

    var body: some View {
        Text(text)
            .font(.title2.bold())
            .accessibilityAddTraits(.isHeader)
    }
}

struct TappableCard: View {
    let item: Article
    let onSelect: () -> Void

    var body: some View {
        VStack(alignment: .leading) {
            Text(item.title).font(.headline)
            Text(item.excerpt).font(.body)
        }
        .contentShape(Rectangle())
        .onTapGesture(perform: onSelect)
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(.isButton)
        .accessibilityHint("Otvorí detail článku")
    }
}

Header trait je obzvlášť dôležitý. VoiceOver má v rotore položku "Headings", ktorá umožňuje skákať medzi sekciami. Bez header traitu sa nevidiaci používateľ musí celou obrazovkou pretiahnuť riadok po riadku. V praxi to v slovenskej štátnej správe sleduje aj WCAG 2.2 audit.

Pri toggleoch a tab baroch používajte .isSelected dynamicky cez ternárny operátor. SwiftUI od iOS 17 podporuje aj accessibilityRemoveTraits, ak chcete predvolený trait zhodiť. Užitočné napríklad pri dekoratívnych ikonách v tlačidle, kde nechcete, aby VoiceOver dvakrát opakoval, že ide o obrázok.

Dynamic Type a @ScaledMetric v praxi

Dynamic Type je systémová preferencia, ktorá umožňuje používateľom zväčšovať text, od .xSmall až po .accessibility5, čo je v praxi až 3,1× pôvodná veľkosť. SwiftUI to rieši automaticky, ak používate semantické fonty (.body, .title, .caption), nie pevné .system(size: 14). Pravidlo: pevnú veľkosť používajte iba pri logu, ikonách a pri textoch, ktoré z designových dôvodov nesmú rásť. Aj tam minimálne overte, ako vyzerajú na .accessibility3.

struct AccessibleBadge: View {
    @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 20
    @ScaledMetric(relativeTo: .body) private var horizontalPadding: CGFloat = 12

    let title: String

    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: iconSize))
                .accessibilityHidden(true)
            Text(title)
                .font(.body)
                .lineLimit(nil)
        }
        .padding(.horizontal, horizontalPadding)
        .padding(.vertical, 8)
        .background(Capsule().fill(.green.opacity(0.15)))
    }
}

@ScaledMetric zo SwiftUI škáluje akúkoľvek hodnotu lineárne s aktuálnou Dynamic Type preferenciou. Najčastejšie ho potrebujete pre veľkosti ikon (aby ostali optické v rovnakej váhe ako text), vertikálny padding a corner radius. Bez @ScaledMetric sa pri .accessibility5 ikona nezmení a vyzerá ako bodka vedľa obrieho písmena.

Detailný rozbor animačného timingu, ktorý reaguje na Dynamic Type, nájdete v staršom článku o animáciách v SwiftUI. Krivky .smooth a .snappy totiž majú implicitné dĺžky, ktoré pri veľkom písme vyzerajú trhane a treba ich predĺžiť.

Ako testovať VoiceOver v SwiftUI

VoiceOver testujte v troch krokoch: Accessibility Inspector v Xcode 16+, simulátor s VoiceOver-om a reálne zariadenie. Žiadny z týchto krokov nenahradí ten ďalší. Inspector vám ukáže statickú štruktúru (labely, traits, value, frame). Simulátor cez Editor → VoiceOver simuluje audio výstup. Reálne zariadenie odhalí timing, sčítanie a cadenc reči.

  1. Accessibility Inspector: Xcode → Open Developer Tool → Accessibility Inspector. Pripojte sa na bežiacu aplikáciu, zapnite Audit a sledujte výstup. Zachytí kontrast farieb, chýbajúce labely aj nedostupné prvky.
  2. Triple-click skratka: Settings → Accessibility → Accessibility Shortcut → VoiceOver. Potom v aplikácii trikrát ťuknite na bočné tlačidlo a VoiceOver sa zapne. Ušetríte si polovicu času oproti chodeniu do nastavení.
  3. Rotor test: otočte dvoma prstami ako gombíkom a prepínajte na "Headings", "Buttons", "Form Controls". Ak sa nedá preskočiť po sekciách, chýbajú vám trait .isHeader.
  4. Audit v Xcode 16+: Test plan → XCTest → enable Accessibility Audit. Test failne, ak Audit nájde issue. Skvelé pre PR pipeline.

Pravidlo, ktoré v tíme aplikujeme: každý PR, ktorý mení UI, musí prejsť aspoň jedným manuálnym VoiceOver passom autorom PR. Trvá to 60 sekúnd na obrazovku a chytí 90 % regresií. Detaily o štruktúre, v ktorej tieto testy fungujú, sme rozoberali v sprievodcovi o navigácii v SwiftUI. Router pattern výrazne zjednodušuje aj a11y, pretože každá obrazovka má jasný entry point.

Vlastné views: accessibilityElement a combine

Keď máte vlastný komponent zložený z viacerých Textov a Imageov, ktoré spolu logicky tvoria jeden prvok (napríklad karta produktu s názvom, cenou a ikonou), VoiceOver ich predvolene číta každý zvlášť. Výsledok: používateľ musí prejsť 4 swipe-y, aby získal informáciu, ktorú vidiaci vidí naraz. Riešenie je .accessibilityElement(children: .combine).

struct ProductRow: View {
    let product: Product

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: product.imageURL)
                .frame(width: 60, height: 60)
                .accessibilityHidden(true)

            VStack(alignment: .leading) {
                Text(product.name).font(.headline)
                Text(product.price.formatted(.currency(code: "EUR")))
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            if product.inStock {
                Image(systemName: "checkmark.circle.fill")
                    .foregroundStyle(.green)
                    .accessibilityHidden(true)
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel(
            "\(product.name), \(product.price.formatted(.currency(code: "EUR"))), " +
            (product.inStock ? "skladom" : "vypredané")
        )
        .accessibilityAddTraits(.isButton)
    }
}

Tri parametre children majú jasné rozdelenie. .combine spojí všetkých potomkov do jedného elementu a ich labely zreťazí. .ignore ich úplne skryje a vy musíte poskytnúť vlastný label. .contain ponechá potomkov dostupných, ale skupinu obalí do navigačnej jednotky pre rotor.

Pri grafoch a interaktívnych vizualizáciách takmer vždy chcete .ignore alebo .contain kombinované s explicitným accessibilityChartDescriptor. Viď sekciu o audio grafoch nižšie.

Reduce Motion, Differentiate Without Color a ďalšie preferencie

Asistívne preferencie nie sú len o VoiceOveri. iOS 26 vystavuje cez environment cca 12 a11y flagov, ktoré by váš dizajn mal rešpektovať. Najdôležitejšie:

  • \.accessibilityReduceMotion: vypnúť parallax, spring animácie, autoplaying video.
  • \.accessibilityDifferentiateWithoutColor: pridať ikony alebo vzory tam, kde stav značíte len farbou.
  • \.accessibilityReduceTransparency: nahradiť materiálne pozadia plnou farbou.
  • \.accessibilityInvertColors: vyhnúť sa inverzii loga a fotografií cez .accessibilityIgnoresInvertColors().
  • \.legibilityWeight: Bold Text systémové nastavenie.
struct SuccessBanner: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiate
    @State private var isVisible = false

    var body: some View {
        HStack {
            if differentiate {
                Image(systemName: "checkmark.circle.fill")
            }
            Text("Uložené")
        }
        .padding()
        .background(.green.opacity(0.2), in: Capsule())
        .scaleEffect(isVisible ? 1 : 0.8)
        .opacity(isVisible ? 1 : 0)
        .onAppear {
            withAnimation(reduceMotion ? nil : .smooth(duration: 0.4)) {
                isVisible = true
            }
        }
    }
}

Reduce Motion neznamená "žiadne animácie", znamená "žiadne pohyby, ktoré môžu vyvolať nevoľnosť". Cross-fade je v poriadku, parallax a scale-from-zero nie. V mojej praxi vždy nahrádzam pohyb cross-fadom (withAnimation(reduceMotion ? .linear(duration: 0.2) : .smooth)), nikdy ho úplne nerušim. Používatelia chcú feedback, len nie kinetiku. Ak používate @Observable pre stav, viažte tieto preferencie priamo na model a UI ich len odčíta.

AccessibilityRotor a zoom actions v iOS 26

Rotor je kruhové menu VoiceOveru. Používateľ ho aktivuje gestom dvoch prstov ako otáčaním gombíkom a prepína sa medzi navigačnými režimami (Headings, Links, Buttons). Pre dlhé zoznamy (správy, články, kalendár) môžete pridať vlastný rotor, ktorý umožní skákať medzi konkrétnymi prvkami.

struct MessagesView: View {
    let messages: [Message]
    var unread: [Message] { messages.filter { !$0.isRead } }

    var body: some View {
        List(messages) { message in
            MessageRow(message: message)
                .id(message.id)
        }
        .accessibilityRotor("Neprečítané") {
            ForEach(unread) { message in
                AccessibilityRotorEntry(message.subject, id: message.id)
            }
        }
    }
}

iOS 26 priniesol aj accessibilityZoomAction, ktorý umožní mapovať pinch-to-zoom gestá na VoiceOver akciu. Pre používateľov, ktorí pinch nemôžu vykonať (motorické postihnutie, alebo VoiceOver, kde je pinch rezervovaný pre iné gestá), je to často jediný spôsob, ako napríklad priblížiť mapu.

struct ZoomableMapView: View {
    @State private var zoomLevel: Double = 12

    var body: some View {
        MapView(zoom: zoomLevel)
            .accessibilityZoomAction { action in
                switch action.direction {
                case .zoomIn:  zoomLevel = min(zoomLevel + 1, 20)
                case .zoomOut: zoomLevel = max(zoomLevel - 1, 1)
                @unknown default: break
                }
            }
            .accessibilityValue("Úroveň priblíženia \(Int(zoomLevel))")
    }
}

Audio grafy v Swift Charts

Swift Charts od iOS 17 (a v iOS 26 výrazne vylepšene) generujú audio reprezentácie dát. VoiceOver prejde graf zľava doprava a prehrá tóny, ktorých výška zodpovedá Y hodnote. Pre používateľov s VoiceOverom je to často jediný spôsob, ako "vidieť" trend.

import Charts

struct RevenueChart: View {
    let data: [DailyRevenue]

    var body: some View {
        Chart(data) { day in
            LineMark(
                x: .value("Dátum", day.date),
                y: .value("Tržby", day.amount)
            )
        }
        .accessibilityLabel("Denné tržby za posledný mesiac")
        .accessibilityChartDescriptor(self)
    }
}

extension RevenueChart: AXChartDescriptorRepresentable {
    func makeChartDescriptor() -> AXChartDescriptor {
        let xAxis = AXNumericDataAxisDescriptor(
            title: "Dátum",
            range: 0...Double(data.count),
            gridlinePositions: []
        ) { "Deň \(Int($0) + 1)" }

        let yAxis = AXNumericDataAxisDescriptor(
            title: "Tržby v eurách",
            range: 0...(data.map(\.amount).max() ?? 0),
            gridlinePositions: []
        ) { "\(Int($0)) eur" }

        let series = AXDataSeriesDescriptor(
            name: "Tržby",
            isContinuous: true,
            dataPoints: data.enumerated().map { idx, day in
                AXDataPoint(x: Double(idx), y: day.amount)
            }
        )

        return AXChartDescriptor(
            title: "Denné tržby",
            summary: "Líniový graf tržieb za 30 dní",
            xAxis: xAxis,
            yAxis: yAxis,
            additionalAxes: [],
            series: [series]
        )
    }
}

Podľa dokumentácie Swift Charts stačí implementovať AXChartDescriptorRepresentable a VoiceOver automaticky pridá akciu "Play audio graph" do rotora. Detaily o tom, ako stavať grafy, ktoré dobre vyzerajú vizuálne aj audio, sme rozobrali v článku Swift Charts v SwiftUI.

Časté chyby a ako sa im vyhnúť

Tu je šestica chýb, ktoré chytám v code review najčastejšie:

  1. Dekoratívne obrázky bez accessibilityHidden(true): VoiceOver číta Image("background-pattern") ako "obrázok background pattern". Pridajte .accessibilityHidden(true) alebo prázdny label.
  2. Tap target menší než 44×44 pt: WCAG 2.5.5 vyžaduje minimum 44 pt. Použite .contentShape(Rectangle()) a .padding(8).
  3. Spoliehanie sa na placeholder ako label: TextField("Hľadať", text: $query) nemá explicitný label pre VoiceOver. Pridajte .accessibilityLabel("Vyhľadávanie").
  4. Custom toggle bez .isSelected: ak segmented control postavíte zo zaobalených Buttonov, musíte sami nastaviť accessibilityAddTraits(isOn ? .isSelected : []).
  5. Zmrznuté accessibilityValue: pri animovaných gauge/progress prvkoch nezabudnite hodnotu prepočítať pri každej zmene. @State binding stačí, ale skontrolujte, či sa updateuje.
  6. Nestabilné id pri AccessibilityRotor: ForEach v rotore musí mať stabilné Identifiable id, inak VoiceOver "zabudne" pozíciu pri reload.

Často kladené otázky

Ako pridám accessibility v SwiftUI?

Štandardné SwiftUI komponenty (Button, Text, Toggle) generujú prístupnosť automaticky. Pri vlastných views použite .accessibilityLabel(_:) pre popis, .accessibilityValue(_:) pre dynamickú hodnotu a .accessibilityAddTraits(_:) pre typ prvku (napr. .isButton, .isHeader). Vždy testujte cez Accessibility Inspector.

Aký je rozdiel medzi accessibilityLabel a accessibilityHint?

Label je krátky názov prvku ("Hlasitosť"), ktorý VoiceOver oznámi vždy. Hint je nepovinný dodatočný popis akcie ("Posuňte nahor pre zvýšenie"), ktorý sa prečíta s oneskorením a používateľ ho môže vypnúť v nastaveniach. Pravidlo: label je čo to je, hint je čo sa stane.

Ako podporím Dynamic Type v SwiftUI?

Používajte semantické fonty (.font(.body), .font(.title2)), nie pevné veľkosti. Pre ikony a padding používajte @ScaledMetric(relativeTo: .body). Vyhnite sa .lineLimit(1) pri texte, ktorý môže byť dlhý. Pri .accessibility5 sa zoreže. Pre podmienené layouty čítajte @Environment(\.dynamicTypeSize).

Ako otestujem VoiceOver bez reálneho zariadenia?

V simulátore zapnite VoiceOver cez menu Editor → VoiceOver. Pre statickú analýzu použite Accessibility Inspector v Xcode (Open Developer Tool), ktorý dokáže spustiť Audit a chytí chýbajúce labely, nízky kontrast a malé tap targety. Pre CI pipeline zapnite Accessibility Audit v XCTest test plane.

Prečo môj custom view VoiceOver číta ako viacero prvkov?

SwiftUI predvolene generuje accessibility element pre každý potomka. Pre logicky súvisiace prvky použite .accessibilityElement(children: .combine). VoiceOver ich spojí do jedného elementu a zreťazí labely. Pre úplné prebratie kontroly použite .accessibilityElement(children: .ignore) a poskytnite vlastný .accessibilityLabel(_:).

Ava Thompson
O Autorovi Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.