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.

12 min read12 min practice0/2 exercises4 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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:

  1. Icon-only controls — an Image(systemName: "square.and.arrow.up") in a button speaks as… nothing useful.
  2. Tap gestures on non-controls — the gestures lesson warned you: .onTapGesture on a view creates a tappable thing VoiceOver does not even see as interactive.
  3. 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.

The a11y modifiers are recorded on the tree — the grader checks them like any other.
Run to see the preview.

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 sizesSurvives
.font(.system(size: 14)).font(.subheadline) — scales with the setting
.frame(height: 44) around textpadding — the frame grows with content
.lineLimit(1) on meaningful textlet it wrap; layout adapts
HStack { label; value } alwaysswitch 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.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

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)")
}
Check yourself

Your designer insists on a custom star-shaped rating control drawn with Canvas, tappable per star. What is the accessibility contract you owe it?

Label the icon buttons

Build the view

This toolbar is three mystery buttons to VoiceOver. Give each an accessibility label naming what it does.

Solution unlocks after 3 attempts

One card, one swipe-stop

Build the view

Merge this price card into a single VoiceOver element with a coherent spoken sentence.

Solution unlocks after 3 attempts
Connect itSaved on this device. Never graded.

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.

Checkpoint

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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.