Reading Order Drift
Status: ready
Symptom
The spoken order doesn't match the visual order. VoiceOver reads the price before the product name, the action before the object, or the error after the Pay button.
Pay now
Card number
Payment failed
Support
The screen looks fine. The spoken path tells the story backwards.
Where it shows up
Custom layouts, grids, cards, forms, dashboards, RTL layouts, mixed SwiftUI and UIKit screens, sheets, overlays, reused cells.
Why it blocks the task
The user builds a model of the screen from the order. If "Try again" comes before "Payment failed", the user hears an action without knowing what failed. If the price comes before the name in every row, scanning a list becomes guessing. Switch Control and Full Keyboard Access follow the same order, so the problem isn't VoiceOver-only.
Root cause
The view hierarchy follows implementation convenience, not the task.
- A
ZStackor overlay puts important text after the controls. - Custom cell subviews were added in arbitrary order.
- The error is placed near the field visually, but at the end of the tree.
- An explicit order was set once and not updated after a redesign.
- Nobody checked RTL or a long localization.
Before
Price
Favorite
Product image
Product name
Add to cart
Properties before the object.
Fix
Verdict: fix the structure first. Use explicit order only where the layout can't match the task.
Why: a structure that matches the task stays correct after redesigns. Explicit order is a second source of truth that drifts.
Target order:
Product name
Price
Favorite
Add to cart
SwiftUI, structure:
VStack(alignment: .leading) {
Text(product.name)
Text(product.priceText)
Button("Add to cart") { add(product) }
.accessibilityLabel("Add \(product.name) to cart")
.accessibilityInputLabels(["Add to cart", "Add \(product.name) to cart"])
}
SwiftUI, when the layout forces another order. Higher priority is read first, within the same container:
VStack {
Text("Payment failed").accessibilitySortPriority(3)
TextField("Card number", text: $card).accessibilitySortPriority(2)
Button("Try again") { pay() }.accessibilitySortPriority(1)
}
.accessibilityElement(children: .contain)
UIKit, custom container:
view.accessibilityElements = [
titleLabel,
errorLabel,
cardNumberField,
payButton,
supportButton
]
The array has to be updated when elements appear or disappear. An element missing from the array is unreachable.
What we don't do
- We don't number every element on the screen with
accessibilitySortPriority. Two or three repairs per screen, not an ordering system. - We don't assume left-to-right. In RTL the visual order mirrors, the task order doesn't.
Check
Profile: VoiceOver
Task: recover from a payment error
Expected: "Payment failed" is heard before "Try again"
the card field follows the error
Support comes after the primary recovery path
Profile: VoiceOver, Arabic or Hebrew locale
Task: swipe through one product card
Expected: name, price, favorite, add to cart, same as in English
WCAG mapping
1.3.2 Meaningful Sequence: the order preserves the meaning.2.4.3 Focus Order: the focus order supports the task.3.3.1 Error Identification: the error is where the user can act on it.