Adjustable controls

Status: draft

When to use this guide

A slider, a stepper, a page indicator, a rating or an amount picker needs .adjustable for VoiceOver. Adjustable is the right tool for one value that goes up and down. It's the wrong tool when the user picks an option by name.

Adjustable control decision
The matrix shows where adjustable is suitable, and where it starts to conflict with Voice Control or the clarity of the choice.

The rule

Verdict: adjustable for one ordered value (quantity, volume, rating, page). Separate buttons or a segmented control for named options (size, delivery speed, payment method).

Why: for VoiceOver, swipe up and down changes the value in one stop. For named options, Voice Control users want to say "Medium", not "Increase" twice, and VoiceOver users want to hear all options, not step through them blind.

Control Adjustable?
Quantity 1–10 Yes
Rating 1–5 Yes
Onboarding page indicator Yes, the system one already is
Small / Medium / Large No, separate choices
Today / Tomorrow / This week No, separate choices

Implementation

Native first. Stepper and Slider are already adjustable:

Stepper("Quantity: \(quantity)", value: $quantity, in: 1...10)

Custom SwiftUI control:

QuantityControl(value: quantity)
    .accessibilityElement(children: .ignore)
    .accessibilityLabel("Quantity")
    .accessibilityValue("\(quantity)")
    .accessibilityAdjustableAction { direction in
        switch direction {
        case .increment: quantity = min(quantity + 1, 10)
        case .decrement: quantity = max(quantity - 1, 1)
        @unknown default: break
        }
    }

UIKit. The trait promises increment and decrement, so both are implemented, and the value updates:

override var accessibilityTraits: UIAccessibilityTraits {
    get { super.accessibilityTraits.union(.adjustable) }
    set { super.accessibilityTraits = newValue }
}

override var accessibilityValue: String? {
    get { "\(quantity)" }
    set { }
}

override func accessibilityIncrement() { quantity = min(quantity + 1, 10) }
override func accessibilityDecrement() { quantity = max(quantity - 1, 1) }

Before and after:

// Before: three stops, "minus" and "plus" without context
HStack {
    Button("-") { quantity -= 1 }
    Text("\(quantity)")
    Button("+") { quantity += 1 }
}

// After: one stop, "Quantity: 2, adjustable"
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...10)

Named options stay separate:

Bad:     "Delivery speed, adjustable"
Better:  "Today", "Tomorrow", "This week", each a button, the selected one with .isSelected

Check

Profile:   VoiceOver
Task:      set the quantity to 3
Expected:  one stop: name, value, "adjustable"
           swipe up and down change the value by 1
           the value doesn't go below 1 or above 10
Profile:   UIKit code review
Expected:  if the trait includes .adjustable,
           accessibilityIncrement() and accessibilityDecrement() exist
           accessibilityValue changes after each call
Profile:   Voice Control
Task:      choose a size
Expected:  "Tap Medium" works
           if the user has to say "Increase" several times, adjustable is the wrong choice

The audit catches a missing description:

try app.performAccessibilityAudit(for: [.trait, .sufficientElementDescription])

What we don't do

  • We don't make a segmented choice or product tabs adjustable.
  • We don't add .adjustable without increment and decrement.
  • We don't let the value run past its limits.

Chapter 5, Chapter 7, Chapter 10, Chapter 11, Semantic Control, Voice Control label audit, Rotor and custom actions, Accessibility testing toolkit.

Sources

Apple Developer: accessibilityAdjustableAction, Apple Developer: UIAccessibilityTraits adjustable, Apple Developer: UIAccessibility, Apple Developer: Performing accessibility audits for your app.