Quality and craft · core
Accessibility
VoiceOver, Dynamic Type and the modifiers that decide whether a quarter of your users can actually use what you built.
By the end you will be able to
- Make custom views speak correctly with label, value, hint and traits
- Build layouts that survive the largest Dynamic Type sizes
- Group child elements so VoiceOver navigation matches meaning, not structure
Roughly what fraction of iPhone users have at least one accessibility setting enabled — larger text, VoiceOver, Reduce Motion, bold text, zoom?
SwiftUI gives you a head start — then you break it
Standard controls come wired: a Button("Delete") speaks as "Delete, button"; a Toggle announces its state and flips on double-tap; Text scales with the user's type size. You inherit accessibility by using the framework idiomatically.
The defects in shipped apps come from the three ways teams leave the paved road:
- Icon-only controls — an
Image(systemName: "square.and.arrow.up")in a button speaks as… nothing useful. - Tap gestures on non-controls — the gestures lesson warned you:
.onTapGestureon a view creates a tappable thing VoiceOver does not even see as interactive. - Fixed-size layouts — hard-coded heights and single-line assumptions that clip at larger text sizes.
The modifiers below exist to repair exactly those holes.
The vocabulary of a spoken interface
VoiceOver reads each element as label, value, traits, hint — in that order:
Image(systemName: "heart.fill")
.accessibilityLabel("Favourite") // WHAT it is
.accessibilityValue(isOn ? "on" : "off") // its current STATE
.accessibilityHint("Double-tap to toggle") // what happens (spoken after a pause)
.accessibilityAddTraits(.isButton) // HOW to interact
- Label names the thing — a noun phrase, not instructions. Play both aloud to hear why: with an instruction baked in, VoiceOver speaks "Tap here to favourite. Button. Double-tap to activate." — coaching twice on every element, because the system already appends the trait and the how-to. "Favourite" composes cleanly: "Favourite. Button."
- Value carries changing state — a slider's percentage, a stepper's count.
- Traits tell VoiceOver the element's kind:
.isButton,.isHeader(enables rotor navigation between sections),.isSelected. - Hint is optional coaching; keep it short, most elements need none.
The custom-view rule of thumb: if you drew it yourself, you owe it a label; if it changes, you owe it a value; if it's tappable, it must have the button trait — or better, be a Button and inherit everything.
Grouping: navigate meaning, not structure
A stat card built from four Texts is, to VoiceOver, four separate swipe-stops: "Steps" — "8,432" — "Goal" — "10,000". Four swipes to hear one fact, multiplied by every card on the screen.
VStack {
Text("Steps"); Text("8,432")
Text("Goal"); Text("10,000")
}
.accessibilityElement(children: .combine)
.combine merges the children into one element speaking the merged content in one stop. For full control, .ignore the children and supply your own sentence:
.accessibilityElement(children: .ignore)
.accessibilityLabel("Steps: 8,432 of 10,000 goal")
This is the single highest-leverage accessibility modifier in SwiftUI, because it fixes the navigation experience, not just the words: a dashboard goes from forty swipe-stops to eight meaningful ones.
Dynamic Type: the setting your layout must survive
Users choose their text size, up to accessibility sizes ~3× the default. The framework scales any Text using semantic fonts (.body, .headline) automatically. Layouts break when code fights that:
| Breaks at large sizes | Survives |
|---|---|
.font(.system(size: 14)) | .font(.subheadline) — scales with the setting |
.frame(height: 44) around text | padding — the frame grows with content |
.lineLimit(1) on meaningful text | let it wrap; layout adapts |
HStack { label; value } always | switch axis at accessibility sizes ↓ |
That last row has a dedicated tool — a layout that changes shape when text grows:
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
// Side-by-side normally; stacked when text is accessibility-sized.
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading))
: AnyLayout(HStackLayout())
layout {
Text("Monthly total")
Spacer()
Text("€1,240")
}
}
And motion is a setting too — you met accessibilityReduceMotion in the animation lesson; it belongs in the same checklist as type size.
This model mirrors how VoiceOver flattens a screen into swipe-stops. What does it print?
struct Element {
let text: String
let isCombined: Bool
let children: [String]
}
func swipeStops(for elements: [Element]) -> [String] {
var stops: [String] = []
for element in elements {
if element.isCombined {
stops.append(element.children.joined(separator: ", "))
} else {
stops.append(contentsOf: element.children)
}
}
return stops
}
let screen = [
Element(text: "steps card", isCombined: false,
children: ["Steps", "8,432", "Goal", "10,000"]),
Element(text: "sleep card", isCombined: true,
children: ["Sleep", "7h 20m", "Goal", "8h"]),
]
let stops = swipeStops(for: screen)
print(stops.count)
for stop in stops {
print("• \(stop)")
}Your designer insists on a custom star-shaped rating control drawn with Canvas, tappable per star. What is the accessibility contract you owe it?
Open your most-used app with VoiceOver for two minutes (the Xcode task above). Write down: the worst unlabelled element you hit, the worst over-fragmented region, and — based on this lesson — the exact one or two modifiers that would fix each. You now have the vocabulary to file precise accessibility bugs, which is a genuinely rare skill on iOS teams.
You can now:
- Give custom views labels, values, traits and hints — and know which each needs
- Merge composite views into meaningful VoiceOver stops
- Build layouts that survive accessibility text sizes, and adapt axis when needed
- Audit an app with VoiceOver and the Accessibility Inspector
Next up: shipping — signing, TestFlight, review, and the last mile to the App Store.