Voice Control
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
-
Use Clear, Speakable Labels: Choose labels that are easy to pronounce and remember. Avoid technical jargon when possible.
-
Provide Alternative Input Labels: Use
.accessibilityInputLabels(_:)to give users multiple ways to refer to the same element. -
Keep Labels Short: Shorter labels are easier to speak. "Save" is better than "Save Document to Cloud Storage".
-
Avoid Ambiguous Labels: Don't have multiple buttons with the same label. Voice Control users need unique identifiers.
-
Test Common Phrases: Think about what users might naturally say to interact with your elements and include those as input labels.
-
Make All Interactive Elements Accessible: Every button, link, and control should have a proper accessibility label.
-
Use Descriptive Text: When possible, use actual text labels instead of just icons. This makes Voice Control more intuitive.
-
Group Related Actions: For complex views, consider grouping related actions under a single accessible element.
-
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:
-
Go to Settings > Accessibility > Voice Control
-
Turn on Voice Control
-
A microphone icon appears when Voice Control is listening
-
Say "Show numbers" to see numbered overlays on interactive elements
-
Say "Show names" to see text labels on interactive elements
-
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
- async/await • 6 minutes reading time.
- JavaScriptCore • 6 minutes reading time.
- Larger Text • 3 minutes reading time.
Share
Share Bluesky Mastodon Twitter LinkedIn Facebook