Accessibility & Localization
Irbisa · cheatsheetSeptember 13, 2026

Accessibility & Localization

VoiceOver, traits, Dynamic Type, Reduce Motion, String Catalogs, plurals, RTL, locale formatting

Middle Developer16 itemscompressed for a skim
  1. 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?

    Easy

    The 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")
    …
  2. 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 is isAccessibilityElement = true on 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")))
    …
  3. 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?

    Easy

    In UIKit you hand the container an ordered accessibilityElements array; 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
    …
  4. 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?

    Medium

    Anything a gesture does must also exist as a named action — .accessibilityAction(named:) in SwiftUI, accessibilityCustomActions in 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
    …
  5. 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?

    Medium

    It 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
    …
  6. 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?

    Medium

    The fixed height is the bug: let the text drive the height, scale the constants around it with @ScaledMetric or UIFontMetrics, 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
    …
  7. 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?

    Medium

    Read \.accessibilityReduceMotion and 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
    …
  8. 08

    Your screen passes the Accessibility Inspector's audit and performAccessibilityAudit() in a UI test — what has neither of them actually looked at?

    Medium

    Both 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()
    …
  9. 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?

    Medium

    Colour 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. 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?

    Medium

    A String Catalog is a build-time database: every build re-extracts the literals the compiler can see, merges them into the .xcstrings file, marks entries it can no longer find as stale rather than deleting them, and compiles the whole thing back down to per-language .strings and .stringsdict in 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. 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?

    Medium

    Plural 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. 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?

    Medium

    Text localizes only when its argument is a literal: a String variable 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 a LocalizedStringResource in the model instead of a String.

    // 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. 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?

    Medium

    The region does: Locale.current is the pair of settings, the language picks the words and the region picks every number, date, unit and calendar — so en_DE is 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. 14

    The Arabic build mirrors correctly, but the order number reads backwards and the brand mark came out flipped — what is going on?

    Hard

    Two 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 leading constraints.

    // 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. 15

    Translations land two weeks before release — how do you find the truncation and the hardcoded strings now, in CI, rather than then?

    Hard

    Run 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. 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?

    Hard

    None 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"]
    …