Accessibility & Localization
VoiceOver, traits, Dynamic Type, Reduce Motion, String Catalogs, plurals, RTL, locale formatting
- 01
VoiceOver reads a control as "Favourite, on, button, saves the article" — which part is the label, which the value, and which one may never be spoken?
EasyThe order is fixed — label, then value, then traits, then hint — and the hint is the only part the user can silence, under Settings › Accessibility › VoiceOver › Verbosity › Speak Hints.
// Wrong: the type is in the label, the state is in the label, and the hint // carries information the user needs — hints can be switched off. Button(action: toggle) { Image(systemName: icon) } .accessibilityLabel(isOn ? "Favourite button, on" : "Favourite button, off") .accessibilityHint("This is the only way to save an article") … - 02
A product card is five swipe stops for VoiceOver — image, title, price, badge, heart — how do you collapse it into one, and when should you not?
Easy.accessibilityElement(children: .combine)promotes the container to a single element and folds its children's labels into one; in UIKit the same move isisAccessibilityElement = trueon the container, because the children of an accessibility element are never traversed.// Five stops, four of them fragments: "photo", "Nimbus Runner", "$129", "New", "heart" HStack { AsyncImage(url: product.imageURL) VStack(alignment: .leading) { Text(product.name) Text(product.price.formatted(.currency(code: "USD"))) … - 03
VoiceOver reads a card's price before its title even though the price is drawn underneath — how do you take control of the reading order?
EasyIn UIKit you hand the container an ordered
accessibilityElementsarray; in SwiftUI there is no array to set, so you group with.accessibilityElement(children: .contain)and reorder the siblings inside with.accessibilitySortPriority.// SwiftUI: declaration order is the reading order, and a ZStack breaks the illusion ZStack(alignment: .topLeading) { CoverArt(album: album) VStack(alignment: .leading) { Text(album.price).accessibilitySortPriority(1) // drawn first, read last Text(album.title).accessibilitySortPriority(2) // higher priority is read first … - 04
A row has swipe-to-delete and a custom volume slider, and a VoiceOver user never touches either directly — how do they reach them?
MediumAnything a gesture does must also exist as a named action —
.accessibilityAction(named:)in SwiftUI,accessibilityCustomActionsin UIKit — and a control with a range becomes adjustable so the up and down swipes drive it.// Custom actions land on the Actions rotor — and on Switch Control and Full Keyboard Access MessageRow(message: message) .accessibilityAction(named: "Delete") { delete(message) } .accessibilityAction(named: "Mark as read") { markRead(message) } // List's own swipe actions are exposed for free — no custom action needed … - 05
After a save succeeds, VoiceOver jumps back to the top of the screen and re-reads it — what did the code post, and what should it have posted?
MediumIt posted
.screenChanged, which is a whole-screen context switch; a result message belongs in an.announcement, which speaks without moving focus.// Wrong: a toast that resets the user's place and re-reads the whole screen func didSave() { UIAccessibility.post(notification: .screenChanged, argument: "Saved") } // Right: speak, and leave focus where the user put it … - 06
QA files a bug: at the largest accessibility text size a fixed-height row clips its label — what do you change, and when is clamping the size the right answer?
MediumThe fixed height is the bug: let the text drive the height, scale the constants around it with
@ScaledMetricorUIFontMetrics, and clamp only elements that genuinely cannot reflow.struct InboxRow: View { @Environment(\.dynamicTypeSize) private var typeSize @ScaledMetric(relativeTo: .headline) private var avatar: CGFloat = 40 // grows with text var body: some View { let layout = typeSize.isAccessibilitySize … - 07
A tester with Reduce Motion on says your parallax header makes them queasy — what do you read in code, and which sibling settings does the same pass catch?
MediumRead
\.accessibilityReduceMotionand replace the motion with a cross-fade — the system only strips its own animations, never yours, and three sibling settings work the same way.struct Hero: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @Environment(\.colorSchemeContrast) private var contrast @Environment(\.legibilityWeight) private var legibilityWeight … - 08
Your screen passes the Accessibility Inspector's audit and performAccessibilityAudit() in a UI test — what has neither of them actually looked at?
MediumBoth are static checks of one visible screen — missing labels, clipped text, small hit regions, contrast ratios — so neither has any opinion on whether the labels mean anything or whether the task can be completed.
final class CheckoutAccessibilityTests: XCTestCase { func testCheckoutPassesTheAudit() throws { let app = XCUIApplication() app.launch() app.buttons["Cart"].tap() … - 09
Design ships an order list where a green dot means paid and a red one means overdue — what fails a real accessibility audit here?
MediumColour is the only carrier of the meaning, so the status is invisible to a colour-blind user, to anyone with Differentiate Without Color on, and to VoiceOver entirely — the state has to be readable a second way.
// Wrong: hue is the entire message HStack { Circle().fill(order.isPaid ? .green : .red).frame(width: 10, height: 10) Text(order.number) } … - 10
After a build, a key you deleted from the code is still in the String Catalog and a translated one turned to NEEDS REVIEW — what is Xcode doing, and what ships in the app?
MediumA String Catalog is a build-time database: every build re-extracts the literals the compiler can see, merges them into the
.xcstringsfile, marks entries it can no longer find as stale rather than deleting them, and compiles the whole thing back down to per-language.stringsand.stringsdictin the bundle.// Extractable: the compiler sees a literal and writes it into Localizable.xcstrings Text("Save") // key "Save" Button("Delete", role: .destructive) { delete() } let msg = String(localized: "Order \(number) shipped", comment: "Confirmation banner; number is an order id like A-1024") … - 11
Your string is "(count) items" and the Russian build now shows «1 файлов» — what is the correct fix, and why is the English string a bug too?
MediumPlural selection belongs to the language, not to your call site: the value picks a CLDR category and each language declares its own set, so the fix is a plural variation in the String Catalog rather than an
if count == 1.// Wrong: a two-form language baked into the call site let label = count == 1 ? "1 item" : "\(count) items" // Right: one key, the catalog carries every language's categories Text("\(count) items") // extracted as the key "%lld items" let title = String(localized: "\(count) items", … - 12
Every label on the screen is translated except the title that comes from the view model — why did that one slip through, and which type fixes it?
MediumTextlocalizes only when its argument is a literal: aStringvariable binds to the non-localizing initializer, so the view model's title is rendered verbatim and was never extracted into the catalog — the fix is to store aLocalizedStringResourcein the model instead of aString.// Wrong: the model holds resolved characters, so nothing is ever extracted or translated struct Section { let title: String } let sections = [Section(title: "Recent")] // no catalog entry Text(section.title) // Text(_: String) — verbatim, not localized // Right: the model holds a deferred resource, resolved at render time in the user's locale … - 13
A user runs the app in English with the region set to Germany — who decides the date format, the price separators and whether you show miles or kilometres?
MediumThe region does:
Locale.currentis the pair of settings, the language picks the words and the region picks every number, date, unit and calendar — soen_DEis English text with a 24-hour clock, a comma decimal separator and kilometres.// Wrong: a pattern and a symbol chosen by the developer let df = DateFormatter() df.dateFormat = "MM/dd/yyyy HH:mm" // 24-hour and US order, everywhere Text("$\(order.total)") // dot decimal, dollar sign, always // Right: locale decides presentation, your data decides the facts … - 14
The Arabic build mirrors correctly, but the order number reads backwards and the brand mark came out flipped — what is going on?
HardTwo independent systems are at work: mirroring is a layout decision you make per view, while the order of characters inside a line is decided by the Unicode bidirectional algorithm — and neither of them looks at your
leadingconstraints.// An identifier inside a right-to-left sentence: isolate it so neutrals stay put let isolated = "\u{2068}" + order.number + "\u{2069}" // FSI … PDI Text("Order \(isolated) shipped") // Digits follow the locale's numbering system — interpolation never does Text(count.formatted()) // ١٢٣ in ar_EG … - 15
Translations land two weeks before release — how do you find the truncation and the hardcoded strings now, in CI, rather than then?
HardRun the app in Xcode's pseudolanguages: they expand, accent and bracket every localized string at load time, so truncation, concatenation and strings that were never extracted appear with no translator involved.
import XCTest final class LocalizationLayoutTests: XCTestCase { // One test, run per language by CI; xcodebuild -testLanguage overrides it per job func testCheckoutSurvivesLongStrings() throws { let app = XCUIApplication() … - 16
The app itself is fully localized, but the App Store page, your push notifications and the Siri phrase are all still English — where does each of those live?
HardNone of the three reads your catalog the way the UI does: the store listing lives in App Store Connect, a push is localized on the device through
loc-key, and App Shortcut phrases have to sit in their own String Catalog.// 1. Push: the server sends keys, the device does the localizing let payload = """ { "aps": { "alert": { "title-loc-key": "PUSH_ORDER_TITLE", "loc-key": "PUSH_ORDER_BODY", "loc-args": ["A-1024", "Anna"] …