Voice Control enables users to navigate and interact with their devices entirely through voice commands, without touching the screen or using a keyboard. In this post, we'll explore how to ensure your SwiftUI apps work seamlessly with Voice Control, making them accessible to users with motor impairments and those who prefer hands-free interaction.

What is Voice Control?

Voice Control is an accessibility feature that allows users to control their devices using only their voice. Unlike VoiceOver (which is for blind users), Voice Control is designed for users who have difficulty with physical interaction—such as those with motor impairments, repetitive strain injuries, or temporary injuries.

With Voice Control enabled, users can:

  • Navigate through apps by saying "Tap [label]"

  • Speak numbers that appear on screen elements

  • Use voice commands for gestures like scrolling and swiping

  • Dictate text into fields

  • Control the entire device without physical touch

Voice Control displays numbers or names over interactive elements, allowing users to precisely target what they want to interact with by voice.

How Voice Control Differs from VoiceOver

While both are voice-based accessibility features, they serve different purposes:

  • VoiceOver: Screen reader for blind users. Reads content aloud and uses gestures for navigation.

  • Voice Control: Hands-free navigation for users with motor impairments. Users speak commands to interact with visible interface elements.

Understanding this distinction is crucial for proper implementation. Voice Control users can see the screen but may not be able to touch it effectively.

How to Implement Voice Control Support in SwiftUI

The good news is that most Voice Control support comes from proper VoiceOver support. However, there are specific considerations for Voice Control users.

Basic Button Accessibility

Voice Control relies on accessibility labels to identify elements. Users speak the label to interact with an element:

import SwiftUI

struct VoiceControlExample: View {
    var body: some View {
        VStack(spacing: 20) {
            // User can say "Tap Submit"
            Button("Submit") {
                submitForm()
            }

            // For icon-only buttons, provide a clear label
            Button(action: refreshData) {
                Image(systemName: "arrow.clockwise")
            }
            .accessibilityLabel("Refresh")
            // User can say "Tap Refresh"

            // Complex button with multiple elements
            Button(action: shareContent) {
                HStack {
                    Image(systemName: "square.and.arrow.up")
                    Text("Share")
                }
            }
            .accessibilityLabel("Share")
            .accessibilityHint("Opens the share sheet")
        }
    }

    func submitForm() {
        print("Form submitted")
    }

    func refreshData() {
        print("Refreshing data...")
    }

    func shareContent() {
        print("Sharing content")
    }
}

Using .accessibilityInputLabels(_:) for Alternative Commands

Voice Control users might try different phrases to interact with your buttons. Use .accessibilityInputLabels(_:) to provide alternative voice commands:

struct AlternativeLabelsExample: View {
    var body: some View {
        VStack(spacing: 20) {
            // User can say "Tap Save", "Tap Submit", or "Tap Send"
            Button("Save") {
                saveDocument()
            }
            .accessibilityLabel("Save")
            .accessibilityInputLabels(["Save", "Submit", "Send"])

            // User can say "Tap Delete", "Tap Remove", or "Tap Trash"
            Button(action: deleteItem) {
                Image(systemName: "trash")
            }
            .accessibilityLabel("Delete")
            .accessibilityInputLabels(["Delete", "Remove", "Trash"])

            // Shopping cart with multiple commands
            Button(action: checkout) {
                HStack {
                    Image(systemName: "cart")
                    Text("Cart")
                }
            }
            .accessibilityLabel("Shopping Cart")
            .accessibilityInputLabels([
                "Shopping Cart",
                "Cart",
                "Basket",
                "Checkout"
            ])
        }
    }

    func saveDocument() {
        print("Document saved")
    }

    func deleteItem() {
        print("Item deleted")
    }

    func checkout() {
        print("Checking out...")
    }
}

Interactive Form Elements

For forms, ensure all interactive elements have clear, speakable labels:

struct VoiceControlForm: View {
    @State private var username = ""
    @State private var email = ""
    @State private var agreeToTerms = false

    var body: some View {
        Form {
            Section(header: Text("Account Information")) {
                TextField("Username", text: $username)
                    .accessibilityLabel("Username")
                    .accessibilityInputLabels(["Username", "User name", "Login name"])

                TextField("Email", text: $email)
                    .accessibilityLabel("Email address")
                    .accessibilityInputLabels(["Email", "Email address", "E-mail"])
            }

            Section {
                Toggle("I agree to the terms and conditions", isOn: $agreeToTerms)
                    .accessibilityLabel("Agree to terms")
                    .accessibilityInputLabels([
                        "Agree to terms",
                        "Terms and conditions",
                        "Accept terms"
                    ])
            }

            Section {
                Button("Create Account") {
                    createAccount()
                }
                .accessibilityLabel("Create Account")
                .accessibilityInputLabels([
                    "Create Account",
                    "Sign up",
                    "Register"
                ])
            }
        }
    }

    func createAccount() {
        guard !username.isEmpty, !email.isEmpty, agreeToTerms else {
            print("Please fill in all fields and agree to the terms")
            return
        }
        print("Account created for \(username)")
    }
}

Custom Controls

For custom controls, ensure they're focusable and have appropriate labels:

struct CustomStarRating: View {
    @Binding var rating: Int
    let maxRating: Int = 5

    var body: some View {
        HStack(spacing: 12) {
            ForEach(1...maxRating, id: \.self) { index in
                Button(action: {
                    rating = index
                }) {
                    Image(systemName: index <= rating ? "star.fill" : "star")
                        .foregroundColor(index <= rating ? .yellow : .gray)
                        .font(.title)
                }
                .accessibilityLabel("\(index) star\(index == 1 ? "" : "s")")
                .accessibilityInputLabels([
                    "\(index) star\(index == 1 ? "" : "s")",
                    "Star \(index)",
                    "\(index)"
                ])
                .accessibilityHint("Sets rating to \(index) out of \(maxRating)")
            }
        }
        .accessibilityElement(children: .contain)
    }
}

struct RatingView: View {
    @State private var rating = 0

    var body: some View {
        VStack {
            Text("Rate this item")
                .font(.headline)

            CustomStarRating(rating: $rating)

            Text("Current rating: \(rating) stars")
                .font(.subheadline)
        }
        .padding()
    }
}

Navigation and Lists

For lists and navigation, ensure each item is clearly labeled:

struct VoiceControlList: View {
    let items = ["Home", "Settings", "Profile", "Help", "About"]

    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: \.self) { item in
                    NavigationLink(destination: DetailView(title: item)) {
                        HStack {
                            Image(systemName: iconForItem(item))
                            Text(item)
                        }
                    }
                    .accessibilityLabel(item)
                    .accessibilityInputLabels([item, "Go to \(item)"])
                }
            }
            .navigationTitle("Menu")
        }
    }

    func iconForItem(_ item: String) -> String {
        switch item {
        case "Home": return "house"
        case "Settings": return "gear"
        case "Profile": return "person"
        case "Help": return "questionmark.circle"
        case "About": return "info.circle"
        default: return "doc"
        }
    }
}

struct DetailView: View {
    let title: String

    var body: some View {
        Text("\(title) Details")
            .navigationTitle(title)
    }
}

Context Menus and Actions

Ensure context menu items are accessible with Voice Control:

struct ContextMenuExample: View {
    @State private var selectedItem: String?

    var body: some View {
        VStack {
            Text("Long press or say 'Show Actions' for the box")
                .padding()

            RoundedRectangle(cornerRadius: 12)
                .fill(Color.blue)
                .frame(width: 200, height: 200)
                .contextMenu {
                    Button(action: copyAction) {
                        Label("Copy", systemImage: "doc.on.doc")
                    }
                    .accessibilityLabel("Copy")

                    Button(action: shareAction) {
                        Label("Share", systemImage: "square.and.arrow.up")
                    }
                    .accessibilityLabel("Share")

                    Button(role: .destructive, action: deleteAction) {
                        Label("Delete", systemImage: "trash")
                    }
                    .accessibilityLabel("Delete")
                }
                .accessibilityLabel("Content box")
                .accessibilityHint("Long press for actions menu")
        }
    }

    func copyAction() {
        print("Item copied to clipboard")
    }

    func shareAction() {
        print("Sharing item...")
    }

    func deleteAction() {
        print("Item deleted")
    }
}

Best Practices for Voice Control

  1. Use Clear, Speakable Labels: Choose labels that are easy to pronounce and remember. Avoid technical jargon when possible.

  2. Provide Alternative Input Labels: Use .accessibilityInputLabels(_:) to give users multiple ways to refer to the same element.

  3. Keep Labels Short: Shorter labels are easier to speak. "Save" is better than "Save Document to Cloud Storage".

  4. Avoid Ambiguous Labels: Don't have multiple buttons with the same label. Voice Control users need unique identifiers.

  5. Test Common Phrases: Think about what users might naturally say to interact with your elements and include those as input labels.

  6. Make All Interactive Elements Accessible: Every button, link, and control should have a proper accessibility label.

  7. Use Descriptive Text: When possible, use actual text labels instead of just icons. This makes Voice Control more intuitive.

  8. Group Related Actions: For complex views, consider grouping related actions under a single accessible element.

  9. Test with Voice Control: Enable Voice Control (Settings > Accessibility > Voice Control) and try navigating your app using only your voice.

Testing Voice Control

To test your app with Voice Control:

  1. Go to Settings > Accessibility > Voice Control

  2. Turn on Voice Control

  3. A microphone icon appears when Voice Control is listening

  4. Say "Show numbers" to see numbered overlays on interactive elements

  5. Say "Show names" to see text labels on interactive elements

  6. Try saying "Tap [label]" for your buttons

Common voice commands:

  • "Tap [name]" - Activates a button or control

  • "Show numbers" / "Show names" - Displays overlays

  • "Scroll down" / "Scroll up" - Scrolls content

  • "Go home" - Returns to home screen

  • "Open [app name]" - Opens an app

Wrap up

Voice Control mostly works automatically if you've already handled VoiceOver. The main thing to add is .accessibilityInputLabels(_:) on elements where the visible label doesn't match what a user would naturally say.

Resources:

Read more

Share


Share Bluesky Mastodon Twitter LinkedIn Facebook