VoiceOver is Apple's screen reader that enables blind and low vision users to navigate and interact with iOS, iPadOS, and macOS devices. In this post, we'll explore how to make your SwiftUI apps fully accessible to VoiceOver users by providing clear, descriptive labels and implementing proper accessibility support.

What is VoiceOver?

VoiceOver is a gesture-based screen reader that speaks aloud the elements on the screen, allowing users who are blind or have low vision to use their devices without seeing the display. Users navigate through interface elements sequentially, and VoiceOver announces what each element is and what it does.

VoiceOver is used by millions of people worldwide and is considered one of the most powerful assistive technologies built into modern operating systems. When a user touches an element on screen (or focuses it with keyboard navigation), VoiceOver announces:

  • What the element is (button, text field, image, etc.)

  • The element's label or content

  • The element's state (selected, disabled, etc.)

  • A hint about how to interact with it (if provided)

How to Implement VoiceOver Support in SwiftUI

SwiftUI provides excellent default VoiceOver support, but you can enhance it with accessibility modifiers to create an even better experience.

Basic Accessibility Labels

Use .accessibilityLabel(_:) to provide descriptive text for elements that don't have clear text content:

import SwiftUI

struct BasicAccessibilityView: View {
    var body: some View {
        VStack(spacing: 20) {
            // Bad: No context for icon-only button
            Button(action: shareContent) {
                Image(systemName: "square.and.arrow.up")
            }

            // Good: Clear label for VoiceOver users
            Button(action: shareContent) {
                Image(systemName: "square.and.arrow.up")
            }
            .accessibilityLabel("Share")
        }
    }

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

Accessibility Hints

Use .accessibilityHint(_:) to provide additional context about what will happen when the user interacts with an element:

struct HintExample: View {
    @State private var itemCount = 0

    var body: some View {
        VStack(spacing: 20) {
            Text("Items: \(itemCount)")

            Button("Add Item") {
                itemCount += 1
            }
            .accessibilityLabel("Add Item")
            .accessibilityHint("Increases the item count by one")

            Button(action: clearItems) {
                Image(systemName: "trash")
            }
            .accessibilityLabel("Clear all items")
            .accessibilityHint("Removes all items from your collection")
        }
    }

    func clearItems() {
        itemCount = 0
    }
}

Grouping Accessibility Elements

Use .accessibilityElement(children:) to group related elements so VoiceOver reads them as a single unit:

struct CardView: View {
    let title: String
    let subtitle: String
    let description: String

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)

            Text(subtitle)
                .font(.subheadline)
                .foregroundColor(.secondary)

            Text(description)
                .font(.body)
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(12)
        // Combine all text into one announcement
        .accessibilityElement(children: .combine)
    }
}

For more complex grouping:

struct ProfileCard: View {
    let name: String
    let role: String
    let isOnline: Bool

    var body: some View {
        HStack(spacing: 16) {
            Image(systemName: "person.circle.fill")
                .resizable()
                .frame(width: 50, height: 50)
                .accessibilityHidden(true) // Decorative, hide from VoiceOver

            VStack(alignment: .leading) {
                Text(name)
                    .font(.headline)

                Text(role)
                    .font(.subheadline)
                    .foregroundColor(.secondary)

                HStack {
                    Circle()
                        .fill(isOnline ? Color.green : Color.gray)
                        .frame(width: 8, height: 8)

                    Text(isOnline ? "Online" : "Offline")
                        .font(.caption)
                }
            }
        }
        .padding()
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(name), \(role), \(isOnline ? "Online" : "Offline")")
    }
}

Custom Controls and Views

For custom interactive controls, make sure to provide all necessary accessibility information:

struct CustomSlider: View {
    @State private var value: Double = 50

    var body: some View {
        VStack {
            Text("Volume: \(Int(value))%")

            Slider(value: $value, in: 0...100, step: 1)
                .accessibilityLabel("Volume")
                .accessibilityValue("\(Int(value)) percent")
                .accessibilityHint("Adjusts the volume level")
        }
        .padding()
    }
}

For custom views with complex interactions:

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

    var body: some View {
        HStack {
            ForEach(1...maxRating, id: \.self) { star in
                Image(systemName: star <= rating ? "star.fill" : "star")
                    .foregroundColor(star <= rating ? .yellow : .gray)
                    .onTapGesture {
                        rating = star
                    }
            }
        }
        // Combine all stars into one accessibility element
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("Rating")
        .accessibilityValue("\(rating) out of \(maxRating) stars")
        .accessibilityAdjustableAction { direction in
            switch direction {
            case .increment:
                if rating < maxRating { rating += 1 }
            case .decrement:
                if rating > 0 { rating -= 1 }
            @unknown default:
                break
            }
        }
    }
}

Form Fields

Properly label form fields and provide clear error messages:

struct AccessibleForm: View {
    @State private var email = ""
    @State private var password = ""
    @State private var emailError: String?

    var body: some View {
        Form {
            Section {
                TextField("Email", text: $email)
                    .textContentType(.emailAddress)
                    .keyboardType(.emailAddress)
                    .accessibilityLabel("Email address")
                    .accessibilityHint("Enter your email address")

                if let error = emailError {
                    Text(error)
                        .foregroundColor(.red)
                        .font(.caption)
                        .accessibilityLabel("Email error: \(error)")
                }

                SecureField("Password", text: $password)
                    .textContentType(.password)
                    .accessibilityLabel("Password")
                    .accessibilityHint("Enter your password")
            }

            Section {
                Button("Sign In") {
                    validateAndSignIn()
                }
                .accessibilityLabel("Sign In")
                .accessibilityHint("Submits the form to sign in")
            }
        }
    }

    func validateAndSignIn() {
        if !email.contains("@") {
            emailError = "Please enter a valid email address"
        }
    }
}

Lists and Dynamic Content

For lists with many items, provide meaningful labels:

struct MessageList: View {
    let messages: [Message]

    var body: some View {
        List(messages) { message in
            MessageRow(message: message)
                .accessibilityElement(children: .combine)
                .accessibilityLabel("""
                    Message from \(message.sender), \
                    received \(message.time), \
                    \(message.isRead ? "read" : "unread")
                    """)
                .accessibilityHint("Double tap to open message")
        }
    }
}

struct Message: Identifiable {
    let id = UUID()
    let sender: String
    let preview: String
    let time: String
    let isRead: Bool
}

struct MessageRow: View {
    let message: Message

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(message.sender)
                    .font(.headline)
                Text(message.preview)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }

            Spacer()

            VStack(alignment: .trailing) {
                Text(message.time)
                    .font(.caption)

                if !message.isRead {
                    Circle()
                        .fill(Color.blue)
                        .frame(width: 8, height: 8)
                }
            }
        }
    }
}

Best Practices for VoiceOver Support

  1. Be Descriptive but Concise: Labels should be clear and to the point. Avoid redundancy (don't say "button" - VoiceOver announces the element type).

  2. Provide Context: Use hints to explain what will happen when the user interacts with an element, especially for non-obvious actions.

  3. Hide Decorative Elements: Use .accessibilityHidden(true) for purely decorative images and icons.

  4. Group Related Content: Use .accessibilityElement(children: .combine) to group related elements that should be read as one unit.

  5. Update Accessibility Info Dynamically: When content changes, make sure accessibility labels and values update too.

  6. Test with VoiceOver: Always test your app with VoiceOver enabled. Enable it in Settings > Accessibility > VoiceOver, or use the triple-click home/side button shortcut.

  7. Announce State Changes: For dynamic content that changes without user interaction, consider using accessibility notifications to alert VoiceOver users.

  8. Avoid Long Labels: Keep accessibility labels under 2-3 sentences. Very long labels can be frustrating for VoiceOver users.

Wrap up

VoiceOver support starts with good labels and traits. Test with VoiceOver turned on — navigating your own app with it running is the fastest way to find gaps you wouldn't spot otherwise.

Resources:

Read more

Share


Share Bluesky Mastodon Twitter LinkedIn Facebook