It means the app still works for someone who cannot see the screen, needs large text, cannot tell colours apart, feels sick from motion or cannot touch precisely. In practice: VoiceOver, Dynamic Type, contrast, Reduce Motion and alternative input.
VoiceOver: every control has a meaningful label and the right traits, related content is grouped, the reading order makes sense, and nothing is reachable only by a gesture.
Dynamic Type: text uses text styles, and the layout grows with it instead of clipping.
Colour, contrast and motion: at least 4.5:1 for body text, meaning never carried by colour alone, and your own animations and blurs respect Reduce Motion and Reduce Transparency.
Motor, voice and hearing: hit targets of at least 44×44 pt; Switch Control, Voice Control and Full Keyboard Access work from the same accessibility tree as VoiceOver, so good labels and actions serve them too; video has captions, and no alert exists only as a sound.
Standard UIKit and SwiftUI controls give you most of this for free; the work is in custom views, icon-only buttons, images and custom gestures.
iOSMiddle
Accessibility & Localization
VoiceOver, traits, Dynamic Type, Reduce Motion, String Catalogs, plurals, RTL, locale formatting
Questions closed
0 / 25
- 0Review
- 2Asked often
Questions
VoiceOver reads label, value, traits, hint: the label says what the element is, the value its current state, the traits what kind of element it is and how it behaves, and the hint what happens when you activate it.
label: a short name, localized, without the word "button" (the trait already says it): "Favourite".
value: the part that changes: "On", "4 of 5 stars", "60 percent", the text in a field. VoiceOver re-reads the value of the focused element when it changes.
traits: .isButton, .isHeader, .isSelected, .isLink, .updatesFrequently. They change behaviour, not only wording: a header shows up in the rotor's Headings list.
hint: "Saves the article to your list". Spoken last, after a pause, and only if Speak Hints is on in VoiceOver's Verbosity settings.
The two common mistakes: putting state into the label ("Favourite button, on"), and putting information the user needs into the hint, which many experienced users switch off.
Three stops, two of them noise: the placeholder's asset name as an image, the user's name, and the trash icon's asset name as a button. With nothing better to go on, SwiftUI uses an image's asset name as its label.
The placeholder avatar is decoration: Image(decorative: "avatar_placeholder") or .accessibilityHidden(true) removes it from the tree.
An icon-only button needs a real label. Label("Delete", image: "ic_trash").labelStyle(.iconOnly) keeps one localized title that serves VoiceOver, Voice Control and any other label style (menus, wider layouts); .accessibilityLabel("Delete") is equally valid. For Voice Control synonyms add .accessibilityInputLabels(["Delete", "Remove"]).
Name the action, not the picture: "Delete", not "Trash can".
Dynamic Type is the system-wide text size setting; text set in a text style such as .body or .headline scales with it automatically, across seven standard sizes and five larger accessibility sizes.
SwiftUI: .font(.body) or .font(.headline) scale for free. A brand font scales with Font.custom("Brand", size: 15, relativeTo: .subheadline); .system(size:) and Font.custom(_:fixedSize:) do not scale at all.
UIKit: UIFont.preferredFont(forTextStyle:) plus adjustsFontForContentSizeCategory = true, so the label updates while the app is running. A custom font goes through UIFontMetrics(forTextStyle:).scaledFont(for:).
Layout: let text wrap (numberOfLines = 0, no lineLimit(1)) and give rows a minimum height, not a fixed one.
Add the languages to the project, keep every user-facing string in a String Catalog (Localizable.xcstrings) that Xcode fills from your code on each build, and let the user's locale format numbers, dates and prices.
Languages: Project › Info › Localizations. The catalog gets a column per language; Product › Export Localizations produces .xcloc bundles for translators.
Strings: a literal in Text("Save") or Button("Delete") is already a key. Outside views use String(localized: "…", comment: "…"); the comment tells the translator the context.
Plurals: Vary by Plural in the catalog, never count == 1 ? … : ….
System strings: permission prompts and the app's display name go into InfoPlist.xcstrings.
Never hardcode:
sentences glued from pieces: word order differs between languages;
date patterns, decimal separators and currency symbols: use .formatted();
text inside images;
fixed widths for text: German is often a third longer than English;
left and right where you mean leading and trailing.
Wrap the card in .accessibilityElement(children: .combine) so it becomes one element whose label joins the children's, and expose the heart as a named custom action; in UIKit set isAccessibilityElement = true on the container and write its label.
.combine merges the children's labels, values and traits in reading order. .contain keeps the children separate but groups them; .ignore (what a bare .accessibilityElement() does) drops them and expects your own label.
Hide decoration and any control you replace with a custom action (.accessibilityHidden(true) on the photo and on the heart button) before combining, or its label leaks into the sentence.
A button inside a combined element stops being its own stop, and its label is merged into the sentence unless hidden. Give its action a name with .accessibilityAction(named:) so it appears on the Actions rotor, and set the double-tap action explicitly with .accessibilityAction { … }; otherwise it may land on the merged child button.
If the merged sentence reads badly, write the label yourself.
When not to merge: a card with several independent controls (play, shuffle, repeat), or long text the user may want to read piece by piece; there is no moving around inside one stop. Use .contain there. Rule of thumb: one stop per thing the user can do.
In SwiftUI group the views with .accessibilityElement(children: .contain) and order them with .accessibilitySortPriority (higher is read first); in UIKit set the container's accessibilityElements array in the order you want.
Defaults: SwiftUI reads in declaration order, UIKit in geometric order (top to bottom, then in the layout direction). They stop matching what is drawn once you use ZStack, .overlay, .offset or absolute frames. Often the simplest fix is to declare the views in reading order.
Sort priority sorts siblings within one accessibility container only, default 0. That is why it "does nothing" across groups.
**accessibilityElements replaces the automatic order completely: anything you leave out disappears from VoiceOver. Rebuild it when the subviews change.
Popups** must hide the page behind them: .accessibilityAddTraits(.isModal) in SwiftUI, accessibilityViewIsModal = true in UIKit. Otherwise swiping walks out of the popup into the screen underneath.
Check by swiping through the screen once with VoiceOver on: the order should match how a sighted person reads it.
Every gesture needs an accessibility equivalent: custom actions (.accessibilityAction(named:), accessibilityCustomActions) appear on VoiceOver's Actions rotor, and a value control becomes adjustable so swiping up and down changes it.
With VoiceOver on, one-finger swipes move between elements, so a custom swipe or pan is not just awkward: it is unreachable.
Custom actions also serve Switch Control and Full Keyboard Access. List's swipeActions are exposed as custom actions automatically; gestures you wrote yourself are not.
.accessibilityAdjustableAction { direction in … } adds the adjustable trait. In UIKit add the .adjustable trait and override accessibilityIncrement() and accessibilityDecrement().
Keep accessibilityValue in step, with a unit: "60%", not "0.6".
Two system actions are worth wiring: .accessibilityAction(.escape) (the two-finger Z scrub) to close a custom sheet, and .accessibilityAction(.magicTap) (two-finger double tap) for the screen's main action, such as play and pause.
Name actions with verbs and keep the list short.
It posted .screenChanged, which tells VoiceOver a new screen appeared and moves focus to its first element; a status message should be an .announcement, which is spoken without moving focus.
UIAccessibility.post(notification:argument:) has four values you use in practice:
.announcement: speaks a string, focus stays where it was. Saves, toasts, background errors.
.layoutChanged: part of the screen changed. With nil do not rely on where focus lands; pass the element when you want focus to move, which is right for a validation error.
.screenChanged marks a new context: a sound plays and focus goes to the first element. Only for a whole screen you swapped yourself in a custom container.
.pageScrolled: speaks a position such as "page 3 of 10" for custom paging.
Announcements can be swallowed when VoiceOver is already speaking, typically right after a screen change. Since iOS 17 you can give the AttributedString a priority (.high interrupts current speech and is not cut off) and post it with AccessibilityNotification.Announcement, which also works from SwiftUI.
Often you post nothing: when the user changes a focused control, updating its accessibilityValue is enough, because VoiceOver re-reads it.
Attach .accessibilityFocused(_:) to the error view, bind it to an @AccessibilityFocusState property, and set that property once the error is on screen; VoiceOver moves its cursor there and reads it.
@AccessibilityFocusState is the VoiceOver cursor; @FocusState is keyboard focus. They are separate: a text field can hold the keyboard while VoiceOver reads something else.
Use a Bool for one target, or an optional enum with .accessibilityFocused($focus, equals: .email) for several.
Setting the state in the same update that inserts the view can be ignored, because the element is not in the accessibility tree yet. Set it from the error view's onAppear, or after the view is on screen.
onAppear fires only on insertion; for a repeated failure while the error is already visible, set the focus state again from submit() after the state change.
Move focus only to something the user must act on. For "Saved" use an announcement and leave the cursor alone.
The UIKit equivalent is UIAccessibility.post(notification: .layoutChanged, argument: errorLabel).
Use a text style so the text scales, then remove every fixed dimension around it: minHeight instead of height, a @ScaledMetric icon size, no single-line limit, and a vertical layout at accessibility sizes.
.system(size: 17) is a fixed size. .font(.body) follows Dynamic Type: 17 pt at the default size, 53 pt at the largest.
.frame(height: 44) cuts off the bigger text. .frame(minHeight: 44) keeps the touch target and lets the row grow.
The 40-pt icon stays tiny next to large text. @ScaledMetric(relativeTo: .body) var iconSize = 40 scales it along.
.lineLimit(1) truncates the title; let it wrap.
At accessibility sizes there is no room side by side: check dynamicTypeSize.isAccessibilitySize and switch to a vertical layout with AnyLayout, or let ViewThatFits choose.
Capping the size with .dynamicTypeSize(...DynamicTypeSize.accessibility1) is fair only for things that cannot reflow (a chart axis, a dense table, a badge). .minimumScaleFactor(0.5) is not a fix: it shrinks exactly the text the user asked to enlarge.
The axis is chosen only in viewWillAppear, which does not run when the text size changes while the screen is open: the labels follow Dynamic Type live, but the stack stays horizontal, so the larger text clips.
adjustsFontForContentSizeCategory = true makes each label swap its font on a trait change by itself. Anything you derive from the size category (stack axis, spacing, icon size, constraints) you must recompute yourself.
viewWillAppear is not called when the user changes the size in Control Center or comes back from Settings, so the layout keeps the value from the last appearance.
The fix moves the logic into updateAxis() and registers it for UITraitPreferredContentSizeCategory with registerForTraitChanges (iOS 17+), the replacement for the deprecated traitCollectionDidChange(_:).
On iOS 18+ reading the trait in viewWillLayoutSubviews or layoutSubviews also works: UIKit tracks the traits read there and re-runs the method when they change.
To catch it, open the screen first and only then change the size with Xcode's Environment Overrides or the Accessibility Inspector. A UI test launched at the largest size passes, because the first appearance gets the axis right.
They are system accessibility settings that UIKit and SwiftUI honour in their own controls; for your own animations, blurs, colours and fonts you read them, as SwiftUI environment values or UIAccessibility flags, and adapt.
Reduce Motion: \.accessibilityReduceMotion / UIAccessibility.isReduceMotionEnabled. Replace parallax, zooms, bouncy springs and auto-playing loops with a short cross-fade rather than a jump cut.
Reduce Transparency: \.accessibilityReduceTransparency. System materials turn opaque by themselves; your own blur or translucent overlay needs a solid fallback.
Increase Contrast: \.colorSchemeContrast == .increased / UIAccessibility.isDarkerSystemColorsEnabled. Asset catalog colours can have a High Contrast variant that is picked automatically; hand-picked borders and grey text need a branch.
Bold Text: \.legibilityWeight == .bold / UIAccessibility.isBoldTextEnabled. System fonts get bolder on their own; a custom font family needs you to pick the heavier face.
In SwiftUI these are environment values, so the view re-renders when the user flips a setting. In UIKit observe the matching notification, such as UIAccessibility.reduceMotionStatusDidChangeNotification. In the simulator, the Settings app (Accessibility) toggles these.
Colour is the only carrier of the meaning, so the status is lost for colour-blind users and completely for VoiceOver; add a second signal (a shape or a word) and put the status into the accessibility label or value.
Red and green look alike to about 8% of men. Use symbols with different shapes (checkmark.circle.fill and exclamationmark.triangle.fill) or a text label.
\.accessibilityDifferentiateWithoutColor (UIAccessibility.shouldDifferentiateWithoutColor) is the user asking you not to rely on colour: when it is on, always show the shape or the word.
VoiceOver never sees a tint: hide the dot with .accessibilityHidden(true) and say "Paid" or "Overdue" in the label or value.
Contrast still matters: WCAG asks for 4.5:1 for body text, and 3:1 for large text (18 pt, or 14 pt bold) and meaningful icons. Measure the colours actually drawn in light, dark and Increase Contrast modes; text on photos and materials is where tools pass and users fail.
A quick check: take a screenshot and make it greyscale. If paid and overdue still look different, colour is no longer doing all the work.
Automated audits catch the mechanical problems on one screen (missing labels, small hit areas, low contrast, clipped text), while only a person using VoiceOver can tell whether the labels make sense and the task can be finished.
The tools
Accessibility Inspector (Xcode › Open Developer Tool): point at an element to see its label, value, traits and frame; audit the current screen; toggle Dynamic Type, Reduce Motion and contrast in the simulator.
**performAccessibilityAudit()** (Xcode 15+): the same audit inside an XCUITest, so it fails CI. Narrow it with for: [.contrast, .dynamicType, .textClipped, .hitRegion], and pass a closure that returns true for a known issue you accept.
Previews at a large dynamicTypeSize and in right-to-left layout for quick visual checks.
What only a person catches
A label that exists but is useless: "Button", "img_23", a raw symbol name.
A wrong reading order, or a card split into five fragments.
A gesture with no accessibility action.
Where focus lands after a sheet closes, and whether an announcement is heard.
Meaning carried only by colour, even when contrast passes.
So run the audit in CI as a regression net, and before a release turn on VoiceOver, enable Screen Curtain (three-finger triple tap) and finish a real task (sign up, buy something) without looking at the screen.
A String Catalog (.xcstrings) is a JSON file Xcode keeps in sync with your code: every build extracts localizable literals into it and tracks each translation's state, and it is compiled into ordinary .strings and .stringsdict files in the app bundle.
New: extracted, not translated yet; it counts against the language's completion percentage.
Needs Review: the source text changed after the translation was made, or someone flagged it. The old translation stays until you confirm or replace it.
Stale: the key was not found in this build. Usually dead code, sometimes a string you now build at runtime, so check before deleting.
Keys you add by hand are manual and never go stale; marking a string "Don't Translate" excludes it.
Extraction sees literals in Text, Button, String(localized:), LocalizedStringResource and NSLocalizedString. Text(viewModel.title) produces nothing. For Swift it relies on the build setting "Use Compiler to Extract Swift Strings" (SWIFT_EMIT_LOC_STRINGS), which is on in new projects but often off in older ones.
The runtime did not change: the app still looks strings up in <lang>.lproj; the catalog replaced the editing format, not the loading, so you can migrate from .strings one file at a time.
With a plural variation in the String Catalog: the system picks a CLDR plural category for the number and each language supplies its own forms, so your code never decides between singular and plural.
English has two categories (one, other), Russian four (one, few, many, other), Arabic six, Japanese one.
Russian looks at the last digits: 1, 21, 101 are one; 2–4 and 22–24 are few; 5–20 and 25–30 are many; fractions are other. A count == 1 check cannot express that.
English is already wrong: "1 items". The ternary that fixes English hard-codes a two-form language and breaks every other one.
In the catalog, right-click the entry › Vary by Plural. An interpolated Int makes the key %lld items.
Two numbers in one sentence stay one key with two placeholders, each varied on its own, never two strings glued together.
"Your cart is empty" is not a plural form. English has no zero category, so an empty state is a separate string chosen in code.
Text localizes only literals: Text(section.title) with a String uses the verbatim initializer, and "Recent" stored as a String was never extracted. Store the title as LocalizedStringResource, which is extracted and resolved when it is rendered.
.navigationTitle("Library") takes a LocalizedStringKey literal, so it is extracted and looked up.
Text(_:) with a String behaves like Text(verbatim:): no lookup, and no catalog entry, because the extractor only sees a plain String.
LocalizedStringResource carries key, table, bundle and locale and resolves later: the right type for model properties, App Intents and widgets. It is ExpressibleByStringLiteral, so call sites barely change.
String(localized:) resolves immediately; use it when you need characters now, such as a notification body. Do not cache it in a static let in code that outlives a language or region change (widgets, App Intents, extensions): keep the LocalizedStringResource and resolve at the point of use.
Text(verbatim:) is for what must never be looked up: user names, codes.
In a framework or Swift package pass the bundle (Text("Save", bundle: .module)) or use the #bundle macro from Xcode 26.
Use the .formatted() APIs and let Locale.current decide. The region, not the language, sets the number, date, unit and calendar conventions, so English with a German region shows English words with German-style numbers (1.234,50) and a 24-hour clock.
date.formatted(date: .abbreviated, time: .shortened) takes field order, separators and the 12- or 24-hour clock from the locale. A fixed dateFormat = "MM/dd/yyyy" is correct in one country.
The currency comes from your data, not from the locale: price.formatted(.currency(code: order.currencyCode)). The locale only places the symbol and the separators.
Measurement with .measurement(width: .abbreviated, usage: .road) converts kilometres to miles where the region uses miles.
"\(price)" always prints a dot as the decimal separator: right for JSON, wrong on screen.
Machine formats (APIs, files) use a fixed locale: ISO8601DateFormatter, or Locale(identifier: "en_US_POSIX") for a custom pattern.
The calendar is regional too: Calendar.current.firstWeekday is Sunday in the US and Monday in Germany.
< on String compares Unicode code points, not the user's alphabet, so names starting with an accented letter (Égypte, Österreich) sort after Z, and lowercase after uppercase. A list people read needs a locale-aware comparison such as localizedStandardCompare(_:).
Swift's String comparison is locale-independent on purpose: right for keys and stable storage, wrong for anything shown. In code-point order É (U+00C9) and Ö (U+00D6) come after Z (U+005A), and "Banana" comes before "apple".
localizedStandardCompare(_:) sorts by Locale.current the way Finder does: case- and diacritic-insensitive where the language says so, and numbers by value ("Item 2" before "Item 10"). For a model type, sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard)) does the same.
Locale-aware is not "strip the accents": German sorts Ö with O, Swedish puts it after Z. Only the locale knows, so the same names are ordered differently per language.
Search has the same trap: localizedStandardContains(_:) finds "Ägypten" from "agy", plain contains(_:) does not.
To catch it, run the app with a language that has accented initials (Scheme › Options › App Language) and check both ends of every sorted list.
English is not immune either: Åland Islands and Côte d’Ivoire land out of place with < too, so an English-only check does not prove the sort is right.
iOS has had a per-app language setting since iOS 13: if the app ships more than one localization, its page in Settings shows a Language option, and an in-app "switcher" should simply open that page with UIApplication.openSettingsURLString.
When the user picks a language there, the system terminates the app, and the next launch runs in that language: every string, formatter, system control and permission prompt switches together.
Writing ["de"] to the AppleLanguages key in UserDefaults.standard relies on undocumented behaviour, takes effect only after a relaunch, and can disagree with what Settings shows.
A live switch without relaunch works only partially: .environment(\.locale, Locale(identifier: "de")) on the root re-localizes SwiftUI Text("...") literals and .formatted(), but not String(localized:), NSLocalizedString, UIKit views, system alerts or permission prompts. You end up with a half-switched app, and anything outside SwiftUI needs your own .lproj bundle lookup.
To learn which translation the app actually runs in, read Bundle.main.preferredLocalizations.first, not Locale.preferredLanguages, which lists the user's languages, including ones you do not ship.
With leading and trailing layout, UIKit and SwiftUI mirror the interface automatically; you decide per image whether it is directional, keep things like media controls and logos unmirrored, and let the Unicode bidirectional algorithm order the text.
Automatic: stacks, leading/trailing constraints and alignment, navigation and the back swipe, and SF Symbols with "forward" and "backward" in their names.
Your images: mark a directional asset as mirrored: Direction in the asset catalog, .flipsForRightToLeftLayoutDirection(true), or imageFlippedForRightToLeftLayoutDirection(). Do not apply it to SF Symbols, which handle direction themselves.
Never flip: logos, media transport controls, clocks, maps and number lines. In UIKit set semanticContentAttribute to .playback or .forceLeftToRight; in SwiftUI put .environment(\.layoutDirection, .leftToRight) on that subtree.
Your own geometry: transforms, hard-coded points and custom drawing use raw coordinates; flip the sign yourself when the layout direction is right-to-left.
Mixed text: an order number or a Latin brand inside Arabic can be reordered by the bidi algorithm. Wrap it in Unicode isolates (\u{2068} … \u{2069}); never reverse the string. Digits follow the locale: .formatted() uses Arabic-Indic digits where the locale does, string interpolation does not.
Test early with the Right-to-Left Pseudolanguage in the scheme options: it mirrors the UI before any translation exists.
A drawn chart is one opaque element, so you build its accessibility tree by hand: .accessibilityChildren in SwiftUI, or UIAccessibilityElements with frames in UIKit, plus a summary, and ideally a chart descriptor for Audio Graphs.
SwiftUI
.accessibilityChildren { … } takes views that are never drawn but become the element's children: a ForEach of Rectangles, each with a label and a value.
.accessibilityRepresentation { … } replaces an element's accessibility with a standard control's, for example presenting a custom dial as a Slider.
Swift Charts does all of this automatically; the work is only for hand-drawn charts.
UIKit
Leave the view itself non-accessible and set accessibilityElements to one UIAccessibilityElement(accessibilityContainer: self) per bar.
Set each element's accessibilityFrameInContainerSpace, so the VoiceOver cursor sits on the bar and touch exploration finds it. Rebuild the elements when the data or the layout changes.
Beyond individual bars
Hundreds of points as separate stops are unusable. Summarise ("Sales rose 12% from January to June"), group by period, or add .accessibilityChartDescriptor(_:) with a type conforming to AXChartDescriptorRepresentable (it builds an AXChartDescriptor; in UIKit set accessibilityChartDescriptor directly): VoiceOver's Audio Graphs then plays the series as tones and reads its statistics.
Give the chart a label and a summary value, so someone who skips it still gets the point.
Run UI tests under Xcode's pseudolanguages and the largest text size: they stretch, accent and bracket every localized string, so clipping, glued sentences and never-extracted literals show up with no translator involved.
What each pseudolanguage exposes
Double-Length repeats every string, a stand-in for German or Finnish; fixed widths and single-line labels clip at once.
Accented adds diacritics; text still in plain ASCII was never localized.
Bounded String wraps every string in brackets, so a sentence glued from two strings shows the seam.
Right-to-Left mirrors the UI without any translation.
Making it a gate
Add test plan configurations whose Application Language is a pseudolanguage or your longest real language, and pass the largest Dynamic Type size as a launch argument. xcodebuild test -testLanguage de -testRegion DE sets language and region per CI job.
A text assertion does not find truncation: a clipped label still reports its full string. performAccessibilityAudit(for: [.textClipped]) compares the text with its frame.
Snapshot tests of key screens at double length and accessibility sizes catch overlaps the audit misses.
A small script over the .xcstrings JSON can fail the build when a shipping language still has untranslated entries.
None of this checks the translations themselves: wrong plurals, tone or meaning still need native reviewers and a screenshot pass.
Each lives outside your view code: the store listing in App Store Connect, push text as loc-key resolved on the device or in a Notification Service Extension, and App Shortcut phrases in their own AppShortcuts.xcstrings.
App Store
Name, subtitle, keywords, description, What's New and screenshots are set per locale in App Store Connect, or through its API or fastlane deliver. A locale you leave empty falls back to the primary language.
In-app purchase names and descriptions are localized there too. Prices come from the storefront; never hardcode them.
Push notifications
Send keys, not sentences: title-loc-key, loc-key, loc-args. The device looks them up in the installed app's strings, so the server never needs to know the user's language.
The key must exist in the version the user has installed: an older build shows the raw key. Add keys; never rename them.
Use positional placeholders (%1$@, %2$@) so translators can reorder the arguments. Plurals and dates the server cannot format belong in a Notification Service Extension, which runs in the user's locale.
The alternative is a server that stores each user's language and sends finished text: simpler, but that value must be kept in sync with the device.
Siri and Shortcuts
AppIntent titles are LocalizedStringResource and extract normally. AppShortcutsProvider phrases go into AppShortcuts.xcstrings, and each must contain \(.applicationName).
Write several natural phrasings per language: that is authoring, not translation.