SwiftUI Accessibility 2026: Пълно ръководство за VoiceOver, Dynamic Type и достъпен дизайн

Практически гайд за SwiftUI accessibility в iOS 26: как да добавиш VoiceOver, Dynamic Type, custom Rotor, Reduce Motion и да преминеш App Store audit-а.

SwiftUI Accessibility 2026: VoiceOver Гайд

Обновено: 16 юли 2026 г.

Достъпността в SwiftUI е практиката всеки изглед да разкрива семантично значение (етикет, стойност, характеристика и действие), така че VoiceOver, Dynamic Type, Voice Control и Switch Control да работят правилно без допълнителни изгледи. В основата стоят три модификатора: accessibilityLabel, accessibilityValue и accessibilityHint, плюс адаптивна типография чрез Font.TextStyle. Ако интегрираш тези елементи още от началото, а не като финална корекция, приложението ти работи еднакво добре за всички потребители и покрива изискванията на App Store за iOS 26.

  • SwiftUI генерира автоматично accessibility елементи, но Image, Canvas и custom shapes изискват ръчен accessibilityLabel за VoiceOver.
  • Използвай Font.TextStyle (например .body, .title) вместо фиксирани размери. Dynamic Type ги мащабира до accessibility5 (310% от базовото).
  • Environment стойностите \Environment(\.accessibilityReduceMotion) и \Environment(\.accessibilityDifferentiateWithoutColor) ти позволяват да замениш анимации и цветови индикатори.
  • Custom Rotor чрез .accessibilityRotor ускорява навигацията през дълги списъци с VoiceOver с до 5-10 пъти.
  • Accessibility Inspector в Xcode 16.4 показва проблеми преди TestFlight; audit endpoint покрива 24 категории.
  • iOS 26 добави .accessibilityActionSet за групиране на custom действия и подобри интеграцията с Assistive Access.

Основи на accessibility в SwiftUI

Честно, това е темата, за която най-много ми се иска да имам „машина на времето“ за старите си проекти. SwiftUI поддържа accessibility на ниво изглед: всеки Text, Button, Image(systemName:) и Toggle идва с готов accessibility елемент, който системата извежда автоматично към VoiceOver, Voice Control, Switch Control и Full Keyboard Access. Проблемът започва, когато композираш собствени контроли от ZStack, Canvas или графики без семантика. Тогава accessibility дървото се превръща в хаос от анонимни елементи, а VoiceOver прочита „бутон, бутон, изображение“ без никакъв контекст.

Трите основни модификатора, които според мен трябва да знаеш наизуст:

  • .accessibilityLabel(_:): какво е това (например „Хареса ми“).
  • .accessibilityValue(_:): текущата стойност (например „12 харесвания“).
  • .accessibilityHint(_:): какво ще се случи при активация (например „Двоен допир за отмяна“).

Ето минимален пример за custom „like“ бутон, който VoiceOver прочита правилно:

struct LikeButton: View {
    @State private var isLiked = false
    @State private var count = 12

    var body: some View {
        Button {
            isLiked.toggle()
            count += isLiked ? 1 : -1
        } label: {
            HStack {
                Image(systemName: isLiked ? "heart.fill" : "heart")
                Text("\(count)")
            }
        }
        .accessibilityLabel("Хареса ми")
        .accessibilityValue("\(count) харесвания, \(isLiked ? "активно" : "неактивно")")
        .accessibilityHint("Двоен допир за смяна на статуса")
    }
}

Как да добавя VoiceOver поддръжка в SwiftUI?

За да добавиш VoiceOver поддръжка в SwiftUI, задай accessibilityLabel на всеки нетекстов елемент, обедини свързаните изгледи с accessibilityElement(children: .combine) и премахни декоративни елементи чрез accessibilityHidden(true). VoiceOver обхожда accessibility дървото отгоре надолу, така че редът на дефиниране на изгледите определя реда на четене. Можеш да го промениш ръчно с accessibilitySortPriority.

Класическа грешка (шибнах я и аз в първия си SwiftUI проект): SF Symbol с етикет отдолу.

// ❌ Неправилно, VoiceOver чете "изображение, Съобщения"
VStack {
    Image(systemName: "envelope.fill")
    Text("Съобщения")
}

// ✅ Правилно, един елемент, чете "Съобщения, бутон"
VStack {
    Image(systemName: "envelope.fill")
        .accessibilityHidden(true)
    Text("Съобщения")
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)

Когато имаш карта с картинка, заглавие и подзаглавие, използвай .combine, за да не задължаваш потребителя да прави три swipe жеста за един семантичен обект. За списъци със стотици редове обаче .contain е по-добър. Той запазва вътрешните елементи като отделни фокусируеми обекти. Ако си несигурен коя стратегия избираш, виж как това е решено в SwiftUI навигация с NavigationStack и Deep Linking, където детайлно е разгледано как фокусът се измества между screens.

Apple препоръчва да тестваш VoiceOver директно на устройство, защото симулаторът показва accessibility hierarchy, но не и звука, което влияе на ритъм и приоритетност. Референция: официалната документация за View Accessibility.

Dynamic Type и адаптивна типография

Dynamic Type позволява на потребителите да увеличат текста от Settings → Display & Text Size → Larger Text до т. нар. accessibility5 размер, който е около 310% от базовия. SwiftUI поддържа това автоматично, ако използваш Font.TextStyle вместо фиксирани точки. Големият капан е ясен: hard-coded .font(.system(size: 14)) не мащабира и изглежда като буболечка при потребители с ниско зрение.

// ❌ Фиксиран размер, не се мащабира
Text("Заглавие")
    .font(.system(size: 20, weight: .bold))

// ✅ Text style, Dynamic Type-friendly
Text("Заглавие")
    .font(.title2)

// ✅ Custom font, но с релативен scaling
Text("Заглавие")
    .font(.custom("Georgia", size: 20, relativeTo: .title2))

За тестване вкарай .environment(\.dynamicTypeSize, .accessibility3) в preview и провери дали layout-ът не се разпада:

#Preview("XXL text") {
    ContentView()
        .environment(\.dynamicTypeSize, .accessibility3)
}

За контроли, които не могат да поберат много текст (например компактен toolbar), можеш да сложиш горна граница чрез .dynamicTypeSize(...DynamicTypeSize.accessibility2). Не забранявай Dynamic Type изцяло, защото това е нарушение на Apple guidelines и може да блокира ревюто в App Store. Ако имаш анимации, синхронизирани с текст, разгледай SwiftUI анимации с PhaseAnimator и KeyframeAnimator, където показах как да задаваш timing curves, които се адаптират към Reduce Motion.

Accessibility traits и custom actions

Accessibility traits са семантични етикети, които обясняват на assistive технологиите какво прави даден изглед. VoiceOver ги превежда в аудио, например „бутон“, „избран“, „хедър“, „линк“. SwiftUI задава някои автоматично (Button има .isButton), но за custom изгледи трябва да ги добавиш ръчно с accessibilityAddTraits или да ги премахнеш с accessibilityRemoveTraits.

Пълният списък включва .isButton, .isHeader, .isLink, .isSelected, .isSummaryElement, .updatesFrequently, .playsSound, .isModal, .isSearchField и няколко други. За стъпков tracker например:

Text("Стъпки днес: \(steps)")
    .accessibilityAddTraits(.updatesFrequently)
    .accessibilityValue("\(steps) от \(goal) стъпки")

Custom actions са по-мощни от hint-ове, защото се появяват в VoiceOver rotor като „Действия“ и позволяват на потребителя да ги задейства без да рисува жестове. Класически пример са swipe действия за row в списък:

MessageRow(message: message)
    .accessibilityAction(named: "Изтрий") { delete(message) }
    .accessibilityAction(named: "Архивирай") { archive(message) }
    .accessibilityAction(named: "Маркирай като непрочетено") { markUnread(message) }

В iOS 26 Apple въведе AccessibilityActionSet, който обединява свързани действия под единично име и така намалява „шума“ в rotor-а. Виж Accessibility framework референцията за пълния API.

Reduce Motion, цветови контрасти и Differentiate Without Color

Около 8% от потребителите са с някаква форма на motion sensitivity, а още 8% от мъжете имат цветна слепота. SwiftUI предоставя environment ключове за всяко от тези състояния; трябва само да ги четеш и да адаптираш анимациите и цветовете. Игнорирането на Reduce Motion е една от най-честите причини за 1-звездни отзиви в App Store при парлаксни ефекти и spring анимации. Аз лично видях как един такъв ефект на login екрана свали рейтинга от 4.6 на 4.2 за две седмици.

struct AnimatedCard: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var expanded = false

    var body: some View {
        Card()
            .scaleEffect(expanded ? 1.05 : 1)
            .animation(
                reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.6),
                value: expanded
            )
            .onTapGesture { expanded.toggle() }
    }
}

За цветове винаги добавяй втори индикатор (икона, форма или текстов етикет), когато Differentiate Without Color е активен:

@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiate

HStack {
    if differentiate {
        Image(systemName: status == .success ? "checkmark.circle" : "xmark.circle")
    }
    Circle()
        .fill(status == .success ? .green : .red)
        .frame(width: 12, height: 12)
    Text(status.description)
}

За контраст използвай Contrast Ratio checker в Accessibility Inspector. Трябват ти минимум 4.5:1 за нормален текст и 3:1 за големи текстове (WCAG 2.2 AA). Ако прилагаш dark mode с Liquid Glass материали от iOS 26, следи материалът да остава четим над всякакъв фон. Виж моя гайд за SwiftUI Liquid Glass и новите API-та в iOS 26, където разгледах как да задаваш tint и vibrancy за accessibility безопасност.

Как да създам custom Rotor за VoiceOver?

Custom Rotor е ротационен селектор във VoiceOver (жестът е усукване с два пръста), който позволява бърза навигация през еднотипни елементи. Ако имаш новинарски feed със 100 елемента, VoiceOver обичайно налага 100 swipe-а, а Rotor намалява това до два жеста. Дефинираш го чрез accessibilityRotor, обикновено на root изгледа на екрана.

struct NewsFeed: View {
    let articles: [Article]

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(articles) { article in
                    ArticleRow(article: article)
                        .id(article.id)
                }
            }
        }
        .accessibilityRotor("Заглавия") {
            ForEach(articles) { article in
                AccessibilityRotorEntry(article.title, article.id)
            }
        }
        .accessibilityRotor("Непрочетени") {
            ForEach(articles.filter { !$0.isRead }) { article in
                AccessibilityRotorEntry(article.title, article.id)
            }
        }
    }
}

Rotor-ите се появяват в стандартното VoiceOver меню. За таблици и графики можеш да групираш по колона, дата или категория. Apple документира най-често използваните шаблони в WWDC24 сесията „VoiceOver rotor navigation“. Тествай с истински VoiceOver потребител, ако имаш възможност; synthetic testing никога не открива всички edge cases.

Тестване с Accessibility Inspector в Xcode 16

Accessibility Inspector е инструмент в Xcode, който показва accessibility дървото, симулира VoiceOver и открива грешки. Стартира се от Xcode → Open Developer Tool → Accessibility Inspector. В Xcode 16.4 audit endpoint покрива 24 категории: липсващи етикети, недостатъчен контраст, малки допирни зони (< 44×44 pt), липсваща поддръжка за Dynamic Type и др.

Работен процес, който препоръчвам:

  1. Стартирай приложението в симулатор.
  2. Отвори Accessibility Inspector и избери процеса на приложението.
  3. Натисни бутона за audit. Получаваш списък с проблеми, всеки с screenshot и препоръка.
  4. За всеки проблем натисни „Show“, за да видиш точния изглед в дървото.
  5. Поправи и повтори. Целта е нула проблеми преди TestFlight.

Освен това можеш да автоматизираш audit чрез XCUITest:

final class AccessibilityUITests: XCTestCase {
    func testHomeScreenAccessibility() throws {
        let app = XCUIApplication()
        app.launch()
        try app.performAccessibilityAudit(for: [.contrast, .dynamicType, .elementDetection])
    }
}

Провалите излизат като XCTest грешки, което е идеално за CI. Ако още не си настроил CI за accessibility тестове, разгледай моята статия за миграция от XCTest към Swift Testing, където описах модерния подход за тестова инфраструктура.

Какво ново в iOS 26 за accessibility?

iOS 26 донесе три значителни промени за accessibility в SwiftUI. Първо, новият AccessibilityActionSet, който групира custom actions под общо име. Второ, Assistive Access интеграция за приложения, които искат да предложат опростен UI за когнитивни увреждания. Трето, по-стриктни изисквания за поддръжка на VoiceOver в App Store review, особено за приложения, категоризирани като „Utility“ или „Productivity“.

// Групиране на действия за rotor
MessageRow(message: message)
    .accessibilityActions {
        AccessibilityActionSet(named: "Управление") {
            Button("Изтрий") { delete(message) }
            Button("Архивирай") { archive(message) }
            Button("Маркирай") { flag(message) }
        }
        AccessibilityActionSet(named: "Отговор") {
            Button("Отговор") { reply(message) }
            Button("Отговор до всички") { replyAll(message) }
        }
    }

Assistive Access режимът се активира чрез Info.plist ключа UIAccessibilityAssistiveAccessSupported. Когато е активен, SwiftUI автоматично разширява допирните зони, увеличава минималния размер на буквите до 22pt и премахва анимации, които не са критични. За приложения с образователна или комуникационна цел това може да бъде голяма разлика за потребителите.

За пълни release notes и списък с новите API-та виж iOS/iPadOS release notes от Apple. Съветвам те да прочетеш и WWDC26 сесията „Deliver an accessible experience“, където инженерите на Apple показват реални case studies.

Продукшън чек-лист преди release

Използвам следния чек-лист преди всеки release. Ако някой елемент не може да бъде отметнат, връщам PR-а за преработка. Просто е, но работи.

КатегорияПроверкаИнструмент
VoiceOverВсеки нетекстов елемент има labelAccessibility Inspector audit
Dynamic TypeLayout работи до accessibility5Xcode preview с environment
КонтрастМин. 4.5:1 за нормален текстContrast checker
Reduce MotionВсички spring/parallax се заменятРъчна проверка на устройство
Допирни зониМинимум 44×44 ptAccessibility Inspector
RotorCustom rotor за списъци >20 елементаVoiceOver на устройство
Voice ControlВсеки бутон има произносим labelVoice Control test session
Switch ControlЛогичен ред на фокусиранеSwitch Control симулация

Няколко правила от опита ми: тествай на реално устройство поне веднъж на седмица, включвай accessibility audit в PR checks (не само в CI за main branch) и настоявай за accessibility acceptance criteria в тикетите. Достъпността не е нещо, което се „добавя после“; тя е част от дизайна от първия wireframe.

Често задавани въпроси

Какво е accessibilityLabel в SwiftUI и кога го използвам?

accessibilityLabel е модификатор, който задава текстово описание на изглед за assistive технологии като VoiceOver. Използвай го за всеки нетекстов елемент (икона, custom shape, Canvas), както и когато искаш да замениш автоматично генерирания label на Button или Toggle с по-описателен текст.

Как да тествам достъпност без устройство с VoiceOver?

Използвай Accessibility Inspector в Xcode за симулация. Той показва accessibility дървото, произнася елементите и стартира audit за 24 категории. За CI пиши XCUITest с performAccessibilityAudit, което автоматизира проверките при всеки PR.

Задължителна ли е поддръжката на Dynamic Type за App Store?

Формално не е задължителна за одобрение, но Apple review team отбелязва липсата ѝ като „design issue“ и препоръчва промени. От iOS 26 приложенията, категоризирани като „Utility“ или „Productivity“, получават по-стриктни ревюта, така че поддръжка до accessibility3 е практически задължителна.

Разликата между accessibilityHidden и isAccessibilityElement?

accessibilityHidden(true) изключва изгледа и всички негови деца от accessibility дървото и VoiceOver ги пропуска. accessibilityElement(children: .ignore) прави изгледа единичен accessibility елемент, който потребителят вижда, но игнорира деца. Използвай hidden за декоративни изображения, element за композитни контроли.

Как да поддържам accessibility в анимации без да губя визуалния ефект?

Чети @Environment(\.accessibilityReduceMotion) и заменяй spring/parallax с бърз fade или cross-dissolve. Не изключвай анимациите изцяло, защото това понякога е по-объркващо от плавен преход. Целта е да елиминираш движение, което може да предизвика световъртеж, а не всяка форма на промяна.

Ava Thompson
За Автора Ava Thompson

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