Это значит, что приложением может пользоваться человек, который не видит экран, нуждается в крупном тексте, не различает цвета, плохо переносит анимацию или не может точно попадать пальцем. На практике это означает VoiceOver, Dynamic Type, контраст, Reduce Motion и альтернативный ввод.
VoiceOver: у каждого контрола осмысленный label и правильные traits, связанный контент сгруппирован, порядок чтения логичен, и ничего не доступно только через жест.
Dynamic Type: текст задан текстовыми стилями, а вёрстка растёт вместе с ним, а не обрезает его.
Цвет, контраст и анимация: не меньше 4.5:1 для основного текста, смысл никогда не передаётся только цветом, а ваши собственные анимации и размытия учитывают Reduce Motion и Reduce Transparency.
Моторика, голос и слух: зоны нажатия не меньше 44×44 pt; Switch Control, Voice Control и Full Keyboard Access работают с тем же деревом доступности, что и VoiceOver, поэтому хорошие labels и действия помогают и им; у видео есть субтитры, и ни одно оповещение не существует только в виде звука.
Стандартные контролы UIKit и SwiftUI дают большую часть этого бесплатно; работа остаётся в кастомных вью, кнопках-иконках, картинках и своих жестах.
iOSMiddle
Доступность и локализация
VoiceOver, traits, Dynamic Type, Reduce Motion, String Catalog, плюрализация, RTL, форматы локали
Закрыто вопросов
0 / 25
- 0Повторить
- 2Часто спрашивают
Вопросы
VoiceOver читает label, value, traits, hint: label говорит, что это за элемент, value передаёт его текущее состояние, traits описывают, какого он типа и как себя ведёт, а hint сообщает, что произойдёт при активации.
label: короткое название, локализованное, без слова «кнопка» (это уже скажет trait): «Избранное».
value: то, что меняется: «Вкл.», «4 из 5 звёзд», «60 процентов», текст в поле. Когда value у элемента в фокусе меняется, VoiceOver перечитывает его.
traits: .isButton, .isHeader, .isSelected, .isLink, .updatesFrequently. Они меняют поведение, а не только озвучку: заголовок попадает в список Headings на роторе.
hint: «Сохраняет статью в ваш список». Звучит последним, после паузы, и только если в настройках VoiceOver в разделе «Подробность» включены подсказки (Speak Hints).
Две типичные ошибки: зашивать состояние в label («Кнопка избранного, вкл.») и класть в hint информацию, без которой не обойтись. Многие опытные пользователи подсказки отключают.
Три остановки, две из них создают шум: имя ассета заглушки как картинка, имя пользователя и имя ассета корзины как кнопка. Если ничего лучше нет, SwiftUI использует имя ассета картинки как её label.
Аватар-заглушка служит декорацией: Image(decorative: "avatar_placeholder") или .accessibilityHidden(true) убирает её из дерева.
Кнопке с одной иконкой нужен настоящий label. Label("Delete", image: "ic_trash").labelStyle(.iconOnly) хранит один локализованный заголовок, который работает и для VoiceOver, и для Voice Control, и для любого другого стиля label (меню, широкие раскладки); .accessibilityLabel("Delete") подходит не хуже. Для синонимов Voice Control добавьте .accessibilityInputLabels(["Delete", "Remove"]).
Называйте действие, а не картинку: «Удалить», а не «Мусорный бак».
Dynamic Type представляет собой системную настройку размера текста; текст, заданный текстовым стилем вроде .body или .headline, масштабируется вместе с ней автоматически: через семь обычных размеров и пять увеличенных размеров для доступности.
SwiftUI: .font(.body) или .font(.headline) масштабируются сами. Фирменный шрифт масштабируется через Font.custom("Brand", size: 15, relativeTo: .subheadline); .system(size:) и Font.custom(_:fixedSize:) не масштабируются вовсе.
UIKit: UIFont.preferredFont(forTextStyle:) плюс adjustsFontForContentSizeCategory = true, чтобы label обновлялся прямо во время работы приложения. Кастомный шрифт пропускают через UIFontMetrics(forTextStyle:).scaledFont(for:).
Вёрстка: позвольте тексту переноситься (numberOfLines = 0, без lineLimit(1)) и давайте строкам минимальную высоту, а не фиксированную.
Добавьте языки в проект, держите каждую строку для пользователя в String Catalog (Localizable.xcstrings), который Xcode наполняет из кода при каждой сборке, и позвольте локали пользователя форматировать числа, даты и цены.
Языки: Project › Info › Localizations. В каталоге появляется колонка на каждый язык; Product › Export Localizations выдаёт .xcloc-пакеты для переводчиков.
Строки: литерал в Text("Save") или Button("Delete") уже является ключом. Вне вью используйте String(localized: "…", comment: "…"); комментарий объясняет переводчику контекст.
Множественное число: Vary by Plural в каталоге, никогда не count == 1 ? … : ….
Системные строки: тексты запросов разрешений и отображаемое имя приложения лежат в InfoPlist.xcstrings.
Нельзя хардкодить:
предложения, склеенные из кусков: порядок слов в языках разный;
шаблоны дат, десятичные разделители и символы валют: используйте .formatted();
текст внутри картинок;
фиксированную ширину под текст: немецкий часто на треть длиннее английского;
left и right там, где имеются в виду leading и trailing.
Оберните карточку в .accessibilityElement(children: .combine), чтобы она стала одним элементом с объединённым label, а сердечко отдайте как именованное кастомное действие; в UIKit поставьте контейнеру isAccessibilityElement = true и напишите ему label.
.combine объединяет labels, values и traits детей в порядке чтения. .contain оставляет детей отдельными, но группирует их; .ignore (то, что делает голый .accessibilityElement()) выбрасывает детей и ждёт ваш собственный label.
Скройте декорацию и любой контрол, который вы заменяете кастомным действием (.accessibilityHidden(true) на фото и на кнопке-сердечке), до объединения, иначе их label попадёт в общую фразу.
Кнопка внутри объединённого элемента перестаёт быть отдельной остановкой, а её label вливается в фразу, если кнопку не скрыть. Дайте её действию имя через .accessibilityAction(named:), чтобы оно появилось на роторе Actions, и явно задайте действие на двойной тап через .accessibilityAction { … }, иначе оно может уйти во вложенную кнопку.
Если объединённая фраза звучит плохо, напишите label сами.
Когда не объединять: в карточке несколько независимых контролов (play, shuffle, repeat) или длинный текст, который пользователь может захотеть читать по частям; внутри одной остановки перемещаться нельзя. Там нужен .contain. Правило: одна остановка на одно действие пользователя.
В SwiftUI сгруппируйте вью через .accessibilityElement(children: .contain) и упорядочьте их через .accessibilitySortPriority (чем больше, тем раньше читается); в UIKit задайте контейнеру массив accessibilityElements в нужном порядке.
По умолчанию SwiftUI читает в порядке объявления, а UIKit в геометрическом (сверху вниз, затем по направлению вёрстки). С тем, что нарисовано, это расходится, как только появляются ZStack, .overlay, .offset или абсолютные фреймы. Часто проще всего объявить вью в порядке чтения.
Sort priority сортирует только соседей внутри одного accessibility-контейнера, по умолчанию 0. Поэтому между группами он «ничего не делает».
**accessibilityElements полностью заменяет автоматический порядок: всё, что вы не перечислили, пропадает для VoiceOver. Пересобирайте массив, когда меняются сабвью.
Попапы** должны скрывать страницу под собой: .accessibilityAddTraits(.isModal) в SwiftUI, accessibilityViewIsModal = true в UIKit. Иначе свайп уводит из попапа на экран под ним.
Проверка: один раз пройдите экран свайпами с включённым VoiceOver; порядок должен совпадать с тем, как экран читает зрячий человек.
У каждого жеста должен быть аналог в доступности: кастомные действия (.accessibilityAction(named:), accessibilityCustomActions) появляются на роторе Actions в VoiceOver, а контрол со значением становится adjustable, чтобы свайпы вверх и вниз его меняли.
С включённым VoiceOver свайпы одним пальцем переключают элементы, так что свой свайп или пан не просто неудобен, а недоступен.
Кастомные действия работают и для Switch Control и Full Keyboard Access. swipeActions у List выставляются как кастомные действия автоматически, а жесты, написанные вами, не выставляются.
.accessibilityAdjustableAction { direction in … } добавляет trait adjustable. В UIKit добавьте trait .adjustable и переопределите accessibilityIncrement() и accessibilityDecrement().
Держите accessibilityValue в актуальном состоянии и с единицей измерения: «60%», а не «0.6».
Два системных действия стоит подключить: .accessibilityAction(.escape) (жест «Z» двумя пальцами), чтобы закрыть свой шит, и .accessibilityAction(.magicTap) (двойной тап двумя пальцами) для главного действия экрана, например play и pause.
Называйте действия глаголами и держите список коротким.
Он отправил .screenChanged: это сообщает VoiceOver, что появился новый экран, и переносит фокус на его первый элемент; статусное сообщение должно быть .announcement, которое произносится без перемещения фокуса.
У UIAccessibility.post(notification:argument:) на практике четыре значения:
.announcement: произносит строку, фокус остаётся на месте. Сохранения, тосты, фоновые ошибки.
.layoutChanged: изменилась часть экрана. С nil не рассчитывайте на то, куда попадёт фокус; передайте элемент, когда фокус должен переместиться (то, что нужно для ошибки валидации).
.screenChanged означает новый контекст: звучит сигнал, фокус уходит на первый элемент. Только для целого экрана, который вы сами подменили в своём контейнере.
.pageScrolled: произносит позицию вроде «страница 3 из 10» для собственного пейджинга.
Анонсы теряются, если VoiceOver уже что-то говорит (типично сразу после смены экрана). С iOS 17 у AttributedString можно задать приоритет (.high прерывает текущую речь и сам не обрывается) и отправить его через AccessibilityNotification.Announcement, что работает и из SwiftUI.
Часто не нужно отправлять ничего: когда пользователь меняет контрол в фокусе, достаточно обновить его accessibilityValue, и VoiceOver перечитает его сам.
Повесьте .accessibilityFocused(_:) на вью с ошибкой, привяжите его к свойству @AccessibilityFocusState и выставьте это свойство, когда ошибка уже на экране; VoiceOver переведёт курсор туда и прочитает её.
@AccessibilityFocusState управляет курсором VoiceOver, а @FocusState фокусом клавиатуры. Они независимы: текстовое поле может держать клавиатуру, пока VoiceOver читает что-то другое.
Для одной цели используйте Bool, для нескольких берите опциональный enum и .accessibilityFocused($focus, equals: .email).
Если выставить состояние в том же обновлении, которое вставляет вью, оно может быть проигнорировано: элемента ещё нет в дереве доступности. Выставляйте его из onAppear вью с ошибкой или после того, как она появилась на экране.
onAppear срабатывает только при вставке; при повторной ошибке, пока прежняя уже на экране, выставьте состояние фокуса снова из submit() после изменения состояния.
Переводите фокус только на то, с чем пользователь должен что-то сделать. Для «Сохранено» используйте анонс и не трогайте курсор.
В UIKit то же самое делает UIAccessibility.post(notification: .layoutChanged, argument: errorLabel).
Используйте текстовый стиль, чтобы текст масштабировался, и уберите вокруг него все фиксированные размеры: minHeight вместо height, размер иконки через @ScaledMetric, без ограничения в одну строку и с вертикальной раскладкой на размерах для доступности.
.system(size: 17) задаёт фиксированный размер. .font(.body) следует Dynamic Type: 17 pt на стандартном размере, 53 pt на самом крупном.
.frame(height: 44) обрезает увеличенный текст. .frame(minHeight: 44) сохраняет зону нажатия и даёт строке расти.
Иконка в 40 pt остаётся крошечной рядом с крупным текстом. @ScaledMetric(relativeTo: .body) var iconSize = 40 масштабирует её вместе с текстом.
.lineLimit(1) обрезает заголовок; дайте ему переноситься.
На размерах для доступности места в ряд нет: проверьте dynamicTypeSize.isAccessibilitySize и переключитесь на вертикальную раскладку через AnyLayout или дайте выбрать ViewThatFits.
Ограничивать размер через .dynamicTypeSize(...DynamicTypeSize.accessibility1) допустимо только для того, что не может перестроиться: оси графика, плотная таблица, бейдж. .minimumScaleFactor(0.5) не решение: он уменьшает именно тот текст, который пользователь попросил увеличить.
Ось выбирается только во viewWillAppear, который не вызывается, когда размер текста меняется при открытом экране: подписи следуют за Dynamic Type сразу, а стек остаётся горизонтальным, и крупный текст обрезается.
adjustsFontForContentSizeCategory = true заставляет каждую подпись саму менять шрифт при смене trait. Всё, что вы выводите из категории размера (ось стека, отступы, размер иконки, констрейнты), нужно пересчитывать самостоятельно.
viewWillAppear не вызывается, когда пользователь меняет размер в Пункте управления или возвращается из Настроек, поэтому вёрстка держит значение с последнего появления экрана.
Исправление выносит логику в updateAxis() и подписывает её на UITraitPreferredContentSizeCategory через registerForTraitChanges (iOS 17+), который заменяет устаревший traitCollectionDidChange(_:).
На iOS 18+ можно и просто читать trait во viewWillLayoutSubviews или layoutSubviews: UIKit отслеживает прочитанные там traits и перезапускает метод, когда они меняются.
Чтобы поймать такой баг, сначала откройте экран и только потом меняйте размер через Environment Overrides в Xcode или Accessibility Inspector. UI-тест, запущенный сразу с максимальным размером, проходит, потому что при первом появлении ось выбирается верно.
Это системные настройки доступности, которые UIKit и SwiftUI учитывают в своих контролах; для собственных анимаций, размытий, цветов и шрифтов вы читаете их (как значения окружения SwiftUI или флаги UIAccessibility) и подстраиваетесь.
Reduce Motion: \.accessibilityReduceMotion / UIAccessibility.isReduceMotionEnabled. Замените параллакс, зумы, пружинистые анимации и зацикленное автовоспроизведение коротким кросс-фейдом, а не резкой сменой кадра.
Reduce Transparency: \.accessibilityReduceTransparency. Системные материалы становятся непрозрачными сами; вашему размытию или полупрозрачной подложке нужен сплошной вариант.
Increase Contrast: \.colorSchemeContrast == .increased / UIAccessibility.isDarkerSystemColorsEnabled. У цветов в asset catalog может быть вариант High Contrast, который подставляется автоматически; подобранным вручную рамкам и серому тексту нужна отдельная ветка.
Bold Text: \.legibilityWeight == .bold / UIAccessibility.isBoldTextEnabled. Системные шрифты становятся жирнее сами; для своего семейства шрифтов более жирное начертание нужно выбрать вам.
В SwiftUI это значения окружения, поэтому вью перерисовывается, когда пользователь переключает настройку. В UIKit подпишитесь на соответствующее уведомление, например UIAccessibility.reduceMotionStatusDidChangeNotification. В симуляторе их переключают в приложении «Настройки» (раздел «Универсальный доступ»).
Смысл передаётся только цветом, поэтому статус теряется для людей с нарушением цветовосприятия, а для VoiceOver теряется полностью; добавьте второй сигнал (форму или слово) и вынесите статус в accessibility label или value.
Красный и зелёный неразличимы примерно для 8% мужчин. Используйте символы разной формы (checkmark.circle.fill и exclamationmark.triangle.fill) или текстовую подпись.
\.accessibilityDifferentiateWithoutColor (UIAccessibility.shouldDifferentiateWithoutColor) означает просьбу пользователя не полагаться на цвет: когда настройка включена, всегда показывайте форму или слово.
VoiceOver не видит цвет вообще: скройте точку через .accessibilityHidden(true) и скажите «Оплачен» или «Просрочен» в label или value.
Контраст всё равно важен: WCAG требует 4.5:1 для основного текста и 3:1 для крупного текста (18 pt или 14 pt жирным) и значимых иконок. Измеряйте реально отрисованные цвета в светлой, тёмной теме и в режиме Increase Contrast; текст на фото и материалах как раз то место, где инструменты проходят проверку, а пользователи нет.
Быстрая проверка: сделайте скриншот и переведите его в оттенки серого. Если оплаченный и просроченный всё ещё различимы, цвет больше не несёт весь смысл в одиночку.
Автоматические аудиты ловят механические проблемы одного экрана (отсутствующие labels, маленькие зоны нажатия, низкий контраст, обрезанный текст), а понять, есть ли в labels смысл и можно ли довести задачу до конца, может только человек с VoiceOver.
Инструменты
Accessibility Inspector (Xcode › Open Developer Tool): наведите на элемент, чтобы увидеть его label, value, traits и фрейм; запустите аудит текущего экрана; переключайте Dynamic Type, Reduce Motion и контраст в симуляторе.
**performAccessibilityAudit()** (Xcode 15+): тот же аудит внутри XCUITest, поэтому он роняет CI. Сужайте его через for: [.contrast, .dynamicType, .textClipped, .hitRegion] и передавайте замыкание, которое возвращает true для известной проблемы, которую вы принимаете.
Превью с крупным dynamicTypeSize и в раскладке справа налево для быстрой визуальной проверки.
Что находит только человек
Label, который есть, но бесполезен: «Button», «img_23», сырое имя символа.
Неправильный порядок чтения или карточку, разбитую на пять фрагментов.
Жест, у которого нет accessibility-действия.
Куда попадает фокус после закрытия шита и слышен ли анонс.
Смысл, переданный только цветом, даже когда контраст в норме.
Поэтому гоняйте аудит в CI как страховку от регрессий, а перед релизом включайте VoiceOver, включайте Screen Curtain (тройной тап тремя пальцами) и проходите реальную задачу (регистрацию, покупку), не глядя на экран.
String Catalog (.xcstrings) представляет собой JSON-файл, который Xcode синхронизирует с кодом: каждая сборка извлекает в него локализуемые литералы и отслеживает состояние каждого перевода, а в бандл он компилируется в обычные файлы .strings и .stringsdict.
New: строка извлечена, но ещё не переведена; она уменьшает процент готовности языка.
Needs Review: исходный текст изменился после перевода, или кто-то пометил строку. Старый перевод остаётся, пока вы его не подтвердите или не замените.
Stale: ключ не найден в этой сборке. Обычно это мёртвый код, а иногда строка, которую вы теперь собираете в рантайме, поэтому проверьте, прежде чем удалять.
Ключи, добавленные вручную, помечены как manual и никогда не становятся stale; пометка «Don't Translate» исключает строку из перевода.
Извлечение видит литералы в Text, Button, String(localized:), LocalizedStringResource и NSLocalizedString. Text(viewModel.title) не создаёт ничего. Для Swift оно опирается на настройку сборки «Use Compiler to Extract Swift Strings» (SWIFT_EMIT_LOC_STRINGS), которая включена в новых проектах, но часто выключена в старых.
Рантайм не изменился: приложение по-прежнему ищет строки в <lang>.lproj; каталог заменил формат редактирования, а не загрузку, поэтому мигрировать с .strings можно по одному файлу.
Через вариацию по числу (plural variation) в String Catalog: система выбирает для числа категорию множественного числа по CLDR, а каждый язык даёт свои формы, так что ваш код никогда не выбирает между единственным и множественным.
В английском две категории (one, other), в русском четыре (one, few, many, other), в арабском шесть, в японском одна.
Русский смотрит на последние цифры: 1, 21, 101 (one); 2–4 и 22–24 (few); 5–20 и 25–30 (many); дроби (other). Проверкой count == 1 это не выразить.
Английский уже сломан: «1 items». Тернарник, который чинит английский, зашивает язык с двумя формами и ломает все остальные.
В каталоге: правый клик по записи › Vary by Plural. Интерполированный Int даёт ключ %lld items.
Два числа в одном предложении остаются одним ключом с двумя плейсхолдерами, каждый со своей вариацией, а не двумя склеенными строками.
«Ваша корзина пуста» не форма множественного числа. В английском нет категории zero, поэтому для пустого состояния нужна отдельная строка, которую выбирает код.
Text локализует только литералы: Text(section.title) со String использует дословный инициализатор, а «Recent», сохранённое как String, так и не было извлечено. Храните заголовок как LocalizedStringResource, который извлекается и резолвится в момент отрисовки.
.navigationTitle("Library") принимает литерал LocalizedStringKey, поэтому он извлекается и ищется в каталоге.
Text(_:) со String ведёт себя как Text(verbatim:): поиска нет и записи в каталоге нет, потому что экстрактор видит обычный String.
LocalizedStringResource хранит ключ, таблицу, бандл и локаль и резолвится позже; это правильный тип для свойств модели, App Intents и виджетов. Он ExpressibleByStringLiteral, так что места вызова почти не меняются.
String(localized:) резолвится сразу; используйте его, когда символы нужны прямо сейчас, например для текста уведомления. Не кэшируйте его в static let в коде, который переживает смену языка или региона (виджеты, App Intents, расширения): храните LocalizedStringResource и резолвите в момент использования.
Text(verbatim:) нужен для того, что никогда не надо искать в каталоге: имена пользователей, коды.
Во фреймворке или Swift-пакете передавайте бандл (Text("Save", bundle: .module)) или используйте макрос #bundle из Xcode 26.
Используйте API .formatted() и дайте решать Locale.current. Форматы чисел, дат, единиц и календаря задаёт регион, а не язык, поэтому английский с немецким регионом показывает английские слова с числами в немецком стиле (1.234,50) и 24-часовыми часами.
date.formatted(date: .abbreviated, time: .shortened) берёт порядок полей, разделители и 12- или 24-часовой формат из локали. Фиксированный dateFormat = "MM/dd/yyyy" верен в одной стране.
Валюта берётся из ваших данных, а не из локали: price.formatted(.currency(code: order.currencyCode)). Локаль только расставляет символ и разделители.
Measurement с .measurement(width: .abbreviated, usage: .road) переводит километры в мили там, где регион пользуется милями.
"\(price)" всегда печатает точку как десятичный разделитель: правильно для JSON, неправильно на экране.
Машинные форматы (API, файлы) используют фиксированную локаль: ISO8601DateFormatter или Locale(identifier: "en_US_POSIX") для своего шаблона.
Календарь тоже региональный: Calendar.current.firstWeekday возвращает воскресенье в США и понедельник в Германии.
< у String сравнивает кодовые точки Unicode, а не алфавит пользователя, поэтому названия на букву с диакритикой (Égypte, Österreich) уходят после Z, а строчные идут после заглавных. Списку, который читает человек, нужно сравнение с учётом локали, например localizedStandardCompare(_:).
Сравнение String в Swift намеренно не зависит от локали: это правильно для ключей и хранения, но неправильно для всего, что показывается на экране. В порядке кодовых точек É (U+00C9) и Ö (U+00D6) идут после Z (U+005A), а "Banana" идёт раньше "apple".
localizedStandardCompare(_:) сортирует по Locale.current так же, как Finder: без учёта регистра и диакритики там, где так принято в языке, и числа по значению («Item 2» раньше «Item 10»). Для модели то же самое делает sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard)).
Учёт локали не сводится к «убрать диакритику»: в немецком Ö сортируется вместе с O, а в шведском стоит после Z. Это знает только локаль, поэтому одни и те же названия в разных языках упорядочены по-разному.
В поиске та же ловушка: localizedStandardContains(_:) находит «Ägypten» по «agy», а обычный contains(_:) не находит.
Чтобы поймать такой баг, запустите приложение на языке с диакритикой в начале слов (Scheme › Options › App Language) и проверьте оба конца каждого отсортированного списка.
В английском тоже не всё гладко: с < Åland Islands и Côte d’Ivoire тоже стоят не на месте, так что проверка только на английском не доказывает, что сортировка верна.
С iOS 13 в системе есть язык для отдельного приложения: если приложение поставляется с несколькими локализациями, на его странице в Настройках появляется пункт «Язык», а «переключатель» в приложении должен просто открывать эту страницу через UIApplication.openSettingsURLString.
Когда пользователь выбирает там язык, система завершает приложение, и следующий запуск идёт уже на этом языке: все строки, форматтеры, системные контролы и запросы разрешений переключаются вместе.
Запись ["de"] в ключ AppleLanguages в UserDefaults.standard опирается на недокументированное поведение, срабатывает только после перезапуска и может расходиться с тем, что показывают Настройки.
Переключение на лету без перезапуска работает лишь частично: .environment(\.locale, Locale(identifier: "de")) на корне перелокализует литералы Text("...") в SwiftUI и .formatted(), но не String(localized:), NSLocalizedString, UIKit-вью, системные алерты и запросы разрешений. В итоге приложение переключено наполовину, а всё за пределами SwiftUI требует вашего собственного поиска по .lproj-бандлу.
Чтобы узнать, на каком переводе приложение реально работает, читайте Bundle.main.preferredLocalizations.first, а не Locale.preferredLanguages: там список языков пользователя, включая те, что вы не поставляете.
При вёрстке через leading и trailing UIKit и SwiftUI зеркалят интерфейс автоматически; вы решаете для каждой картинки, направленная ли она, оставляете незеркальными медиаконтролы и логотипы, а порядок символов в тексте доверяете алгоритму двунаправленного текста Unicode (bidi).
Автоматически: стеки, констрейнты и выравнивание по leading/trailing, навигация и свайп назад, а также SF Symbols со словами «forward» и «backward» в названии.
Ваши картинки: пометьте направленный ассет как зеркалируемый: Direction в asset catalog, .flipsForRightToLeftLayoutDirection(true) или imageFlippedForRightToLeftLayoutDirection(). Не применяйте это к SF Symbols, которые сами учитывают направление.
Никогда не зеркалить: логотипы, кнопки управления медиа, часы, карты и числовые оси. В UIKit задайте semanticContentAttribute значение .playback или .forceLeftToRight; в SwiftUI повесьте .environment(\.layoutDirection, .leftToRight) на это поддерево.
Ваша собственная геометрия: трансформы, захардкоженные точки и кастомная отрисовка работают в сырых координатах; при направлении справа налево меняйте знак сами.
Смешанный текст: номер заказа или латинский бренд внутри арабского текста алгоритм bidi может переставить. Оберните его в изоляторы Unicode (\u{2068} … \u{2069}); никогда не разворачивайте строку. Цифры следуют локали: .formatted() использует арабо-индийские цифры там, где их использует локаль, а интерполяция строк не использует.
Проверяйте заранее с Right-to-Left Pseudolanguage в настройках схемы: он зеркалит UI ещё до появления переводов.
Нарисованный график остаётся одним непрозрачным элементом, поэтому дерево доступности для него строится вручную: .accessibilityChildren в SwiftUI или UIAccessibilityElement с фреймами в UIKit, плюс сводка и, в идеале, дескриптор графика для Audio Graphs.
SwiftUI
.accessibilityChildren { … } принимает вью, которые никогда не рисуются, но становятся детьми элемента: ForEach из Rectangle, у каждого свой label и value.
.accessibilityRepresentation { … } подменяет доступность элемента доступностью стандартного контрола, например показывает кастомный регулятор как Slider.
Swift Charts делает всё это автоматически; работа нужна только для графиков, нарисованных вручную.
UIKit
Саму вью оставьте недоступной и задайте accessibilityElements: по одному UIAccessibilityElement(accessibilityContainer: self) на столбец.
Задайте каждому элементу accessibilityFrameInContainerSpace, чтобы курсор VoiceOver ложился на столбец, а исследование касанием его находило. Пересобирайте элементы, когда меняются данные или раскладка.
Больше, чем отдельные столбцы
Сотни точек как отдельные остановки непригодны. Дайте сводку («Продажи выросли на 12% с января по июнь»), группируйте по периодам или добавьте .accessibilityChartDescriptor(_:) с типом, реализующим AXChartDescriptorRepresentable (он собирает AXChartDescriptor; в UIKit задайте свойство accessibilityChartDescriptor): тогда Audio Graphs в VoiceOver проигрывает ряд тонами и зачитывает статистику.
Дайте графику label и итоговое value, чтобы тот, кто его пропускает, всё равно понял главное.
Гоняйте UI-тесты на псевдоязыках Xcode и на самом крупном размере текста: они растягивают, снабжают диакритикой и берут в скобки каждую локализованную строку, так что обрезка, склеенные предложения и неизвлечённые литералы видны без всякого переводчика.
Что показывает каждый псевдоязык
Double-Length повторяет каждую строку (замена немецкому или финскому); фиксированная ширина и однострочные labels сразу обрезаются.
Accented добавляет диакритику; текст, оставшийся чистым ASCII, не был локализован.
Bounded String берёт каждую строку в скобки, так что у предложения, склеенного из двух строк, виден шов.
Right-to-Left зеркалит UI без всяких переводов.
Как сделать из этого проверку
Добавьте в тест-план конфигурации, где Application Language задан как псевдоязык или ваш самый длинный реальный язык, и передайте самый крупный размер Dynamic Type аргументом запуска. xcodebuild test -testLanguage de -testRegion DE задаёт язык и регион для отдельной джобы CI.
Assert на текст обрезку не найдёт: обрезанный label всё равно отдаёт полную строку. performAccessibilityAudit(for: [.textClipped]) сравнивает текст с его фреймом.
Снапшот-тесты ключевых экранов на двойной длине и размерах для доступности ловят наложения, которые аудит пропускает.
Небольшой скрипт по JSON файла .xcstrings может ронять сборку, когда в поставляемом языке остались непереведённые записи.
Сами переводы это не проверяет: неправильные формы множественного числа, тон и смысл всё равно требуют носителей языка и прохода по скриншотам.
Всё это живёт вне кода вью: описание в магазине лежит в App Store Connect, текст пуша приходит как loc-key, который резолвится на устройстве или в Notification Service Extension, а фразы App Shortcuts хранятся в отдельном AppShortcuts.xcstrings.
App Store
Название, подзаголовок, ключевые слова, описание, What's New и скриншоты задаются для каждой локали в App Store Connect: вручную, через его API или fastlane deliver. Пустая локаль откатывается к основному языку.
Названия и описания встроенных покупок локализуются там же. Цены приходят из витрины; никогда их не хардкодьте.
Push-уведомления
Отправляйте ключи, а не предложения: title-loc-key, loc-key, loc-args. Устройство ищет их в строках установленного приложения, поэтому серверу не нужно знать язык пользователя.
Ключ должен существовать в той версии, которая установлена у пользователя: старая сборка покажет сырой ключ. Ключи только добавляйте, никогда не переименовывайте.
Используйте позиционные плейсхолдеры (%1$@, %2$@), чтобы переводчики могли менять порядок аргументов. Множественное число и даты, которые сервер не может отформатировать, остаются задачей Notification Service Extension, который работает в локали пользователя.
Альтернатива: сервер хранит язык каждого пользователя и шлёт готовый текст. Так проще, но это значение нужно синхронизировать с устройством.
Siri и Shortcuts
Заголовки AppIntent имеют тип LocalizedStringResource и извлекаются как обычно. Фразы AppShortcutsProvider лежат в AppShortcuts.xcstrings, и каждая должна содержать \(.applicationName).
Пишите по несколько естественных формулировок на язык: это авторская работа, а не перевод.