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.

  1. A ZStack or overlay puts important text after the controls.
  2. Custom cell subviews were added in arbitrary order.
  3. The error is placed near the field visually, but at the end of the tree.
  4. An explicit order was set once and not updated after a redesign.
  5. 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. 1.3.2 Meaningful Sequence: the order preserves the meaning.
  2. 2.4.3 Focus Order: the focus order supports the task.
  3. 3.3.1 Error Identification: the error is where the user can act on it.
  1. Reading Order Contract
  2. Right Sized Grouping
  1. Chapter 3
  2. Chapter 5
  3. Chapter 11
  4. Chapter 12
  1. VoiceOver gestures guide

Sources

  1. Apple Developer: accessibilitySortPriority
  2. Apple Developer: accessibilityElements
  3. W3C: Understanding SC 1.3.2 Meaningful Sequence