Making sense to VoiceOver
Imagine describing your screen to a friend over the phone. You wouldn’t say “image, text, text, image, button” for every product; you’d say “Desk lamp, €34.99, four stars, add to cart”. VoiceOver needs the same help:
- Every control needs a name. A heart icon means “Add to favorites”; say so.
- Decoration should be silent. A background pattern or a divider isn’t worth a swipe.
- Things that belong together should be read together. A product row is one thing, not five.
- Tappable things must be buttons, so VoiceOver knows it can activate them.
SwiftUI derives accessibility from views: Text becomes a static text element, Button gets the button trait and its label from content, Image(systemName:) gets a generic name, Toggle exposes a value. Your job is the semantics it can’t infer: accessibilityLabel for icon-only controls, accessibilityHidden/Image(decorative:) for decoration, accessibilityElement(children: .combine/.ignore) to make rows one element, accessibilityValue for state, accessibilityAddTraits(.isHeader/.isSelected), accessibilityAction(named:) for secondary actions, accessibilitySortPriority for reading order, and accessibilityRepresentation when a custom control should behave like a standard one.
Before: Five stops per product, and an unnamed button
struct ProductRow: View {
let product: Product
let onFavorite: () -> Void
var body: some View {
HStack {
AsyncImage(url: product.imageURL)
.frame(width: 64, height: 64)
VStack(alignment: .leading) {
Text(product.name).font(.headline)
Text(product.price.formatted(.currency(code: "EUR")))
RatingStars(value: product.rating) // five star images
}
Spacer()
Button(action: onFavorite) {
Image(systemName: product.isFavorite ? "heart.fill" : "heart")
}
}
}
}After: One element per product, with a named action
struct ProductRow: View {
let product: Product
let onFavorite: () -> Void
var body: some View {
HStack {
AsyncImage(url: product.imageURL)
.frame(width: 64, height: 64)
.accessibilityHidden(true) // the name already says what it is
VStack(alignment: .leading) {
Text(product.name).font(.headline)
Text(product.price.formatted(.currency(code: "EUR")))
RatingStars(value: product.rating)
}
Spacer()
Button(action: onFavorite) {
Image(systemName: product.isFavorite ? "heart.fill" : "heart")
}
.accessibilityLabel(product.isFavorite ? "Remove from favorites" : "Add to favorites")
}
.accessibilityElement(children: .combine) // read as one element
.accessibilityLabel("\(product.name), \(product.price.formatted(.currency(code: "EUR"))), rated \(product.rating) out of 5")
.accessibilityAction(named: product.isFavorite ? "Remove from favorites" : "Add to favorites", onFavorite)
}
}accessibilityHidden(true)on the photo: the product name already describes it..combineturns the row into one VoiceOver stop that reads “Desk lamp, €34.99, rated 4 out of 5”.accessibilityAction(named:)keeps the favorite button reachable from the row: VoiceOver users swipe up or down to pick “Add to favorites” from the actions list.- The label changes with the state, so it always says what the button will do.
Tap the line with the bug.
HStack {
Image(systemName: "creditcard")
Text("Pay \(total.formatted(.currency(code: "EUR")))")
.font(.headline)
}
.padding()
.frame(maxWidth: .infinity)
.background(.blue, in: .rect(cornerRadius: 12))
.foregroundStyle(.white)
.onTapGesture { checkout.pay() }Hint: What does VoiceOver announce for this view, and what happens when a VoiceOver user double-taps it? What about Voice Control and Full Keyboard Access?
It looks like a button, but it’s an HStack with a tap gesture. VoiceOver announces “credit card, Pay €49.99” with no button trait, so users don’t know it’s actionable; Voice Control has no “Tap Pay” target; Full Keyboard Access can’t focus it; and it doesn’t get the button’s pressed state or hit-testing behavior. For some assistive technology users, the app has no way to pay.
Use a Button and style it; everything else comes for free:
Button { checkout.pay() } label: {
Label("Pay \(total.formatted(.currency(code: "EUR")))", systemImage: "creditcard")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)Label also gives VoiceOver the text while the icon stays decorative.
Predict the output
A product row has a photo, a name, a price, a rating made of five star images, and a heart button, with no accessibility modifiers. How many swipes does a VoiceOver user need to get past one product?
Each element is its own stop: the image (often read by its file or symbol name), the name, the price, possibly each star, and the button ('heart fill'). That's 5 to 9 swipes per product, so a list of 30 products takes hundreds. Combining the row into one element with a clear label, and exposing the button as a named action, makes it one swipe per product.
Make a quantity stepper accessible
A custom stepper shows −, the number 3, and + as three separate views, each with a tap gesture on the symbols. Make it a single VoiceOver element that reads “Quantity, 3” and lets users change the value by swiping up and down, like a native stepper.
›
accessibilityElement(children: .ignore) replaces the children with one element. accessibilityAdjustableAction handles swipe up and down. accessibilityValue says the current number.
SolutionTry it yourself first
struct QuantityStepper: View {
@Binding var quantity: Int
let range: ClosedRange<Int>
var body: some View {
HStack(spacing: 16) {
Button { quantity -= 1 } label: { Image(systemName: "minus") }
.disabled(quantity <= range.lowerBound)
Text("\(quantity)").monospacedDigit()
Button { quantity += 1 } label: { Image(systemName: "plus") }
.disabled(quantity >= range.upperBound)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Quantity")
.accessibilityValue("\(quantity)")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: if quantity < range.upperBound { quantity += 1 }
case .decrement: if quantity > range.lowerBound { quantity -= 1 }
@unknown default: break
}
}
}
}VoiceOver now reads “Quantity, 3, adjustable. Swipe up or down to adjust the value”, exactly like the system Stepper. (Using Stepper itself, with a custom style, would get this automatically.) The buttons are real Buttons too, so Voice Control and keyboard users can still press them.
Beyond labels
- Headings:
.accessibilityAddTraits(.isHeader)on section titles enables the rotor’s heading navigation. - Announcements:
AccessibilityNotification.Announcement("Added to cart").post()for changes that happen away from focus;.accessibilityFocusedto move focus after navigation or errors. - Rotors:
accessibilityRotorfor custom navigation (unread messages, errors in a form). - Voice Control uses the visible text: keep labels consistent with what’s on screen, and add
accessibilityInputLabelsfor alternative spoken names. - Custom controls:
accessibilityRepresentation { Slider(...) }gives a custom drawn control the full semantics of a native one.
- Labels for icon-only controls, state-dependent; hide decoration;
.combine/.ignorerows into one element; named actions instead of extra stops. - Real
Buttons, never tap gestures on containers (no trait, no Voice Control or keyboard access). - Adjustable actions and values for steppers/sliders;
accessibilityRepresentationfor custom controls. - Headers, announcements, focus management, rotors, input labels for Voice Control.
Check this part
What does VoiceOver read for a button showing only Image(systemName: "heart")?
Why use Button instead of onTapGesture for tappable views?
How do you keep a secondary action reachable after combining a row into one element?