All Articles

VoiceOver for Custom SwiftUI Controls on macOS

I shipped a macOS build where a custom SwiftUI canvas looked correct but VoiceOver announced many interactive pieces as “group” and focus moved unpredictably during heavy UI updates. The issue was not visual — it was missing accessibility semantics and missing runtime notifications that assistive technologies rely on. This note shows pragmatic fixes to reduce these failures reaching customers.

Why This Matters

When you move AppKit controls into SwiftUI, implicit semantics can be lost. A visually correct control that lacks proper accessibility role mapping will behave incorrectly for VoiceOver users and can produce support incidents late in a release.

Explicit accessibility contracts — roles, labels, focus behavior, and deterministic updates — reduce maintenance overhead and make behavior more predictable for assistive technologies. Treat accessibility as part of the control’s runtime contract, not an afterthought.

1. Map Semantics Explicitly

Set Roles And Core Properties

Prefer explicit semantics over implicit inference. Use SwiftUI modifiers such as accessibilityLabel, accessibilityValue, and accessibilityHint for simple widgets. For nonstandard interactive controls or custom hit-testing, bridge to AppKit and set NSAccessibility properties directly.

When the control is a standard element with stable semantics, pure SwiftUI modifiers will often be sufficient. If you need deterministic role behaviors, custom hit-testing, or explicit platform notifications, implement an NSViewRepresentable bridge.

Testing note: verify role and trait presence with UI tests rather than relying only on visual QA. Missing a role like NSAccessibility.Role.slider is easy to miss visually but can break VoiceOver workflows.

2. Bridge To AppKit When Needed

Provide A Deterministic Accessibility Element

Anti-pattern → preferred: sprinkling accessibilityHidden(true) to silence subviews can hide real interactive targets. Instead, expose a single accessibility element with a clear role and controlled announcements.

If you need deterministic announcements, implement an NSViewRepresentable that owns an NSView subclass and configures isAccessibilityElement and accessibilityRole appropriately.

Example bridge that exposes a role and posts updates:

import SwiftUI
import AppKit

final class AccessibleBoxView: NSView {
  private var _label: String?

  override var isAccessibilityElement: Bool { true }

  override func accessibilityRole() -> NSAccessibility.Role? {
    return .button
  }

  override func accessibilityLabel() -> String? {
    return _label ?? "Action"
  }

  func update(label: String) {
    _label = label
    NSAccessibility.post(element: self, notification: .valueChanged)
  }
}

struct AccessibleBox: NSViewRepresentable {
  var label: String

  func makeNSView(context: Context) -> AccessibleBoxView {
    let v = AccessibleBoxView(frame: .zero)
    v.wantsLayer = true
    v.layer?.backgroundColor = NSColor.controlAccentColor.cgColor
    return v
  }

  func updateNSView(_ nsView: AccessibleBoxView, context: Context) {
    nsView.update(label: label)
  }
}

Debounce NSAccessibility.post when updates are frequent to avoid repeated focus or announcement storms that can overwhelm assistive tools during rapid UI changes.

3. Control Focus And Announcements

Use SwiftUI Focus APIs When Appropriate

Use SwiftUI focus APIs (for example, accessibilityFocused) to express focus flows when the view hierarchy is SwiftUI-driven. When updates must be announced immediately — live-region–style changes — post platform accessibility notifications such as valueChanged or announcementRequested from the AppKit side.

When modal flows or keyboard navigation require deterministic placement, move focus programmatically. Avoid programmatic focus for purely cosmetic changes to reduce the risk of focus traps.

For rollout safety, instrument focus moves and announcements so you can detect regressions that affect assistive technology behavior.

4. Compose Labels Carefully

Build Stable, Reviewable Strings

Anti-pattern → preferred: concatenating UI fragments into labels at render time. Use reviewed copy sources and extract strings to a dedicated resource to avoid accidental regressions.

Prefer accessibilityLabel, accessibilityValue, and accessibilityHint instead of relying on visual text composition. When content is dynamic, post an accessibility notification so assistive technologies have a chance to announce the change.

String assertions in UI tests are brittle. Prefer assertions on role, trait, presence, and partial string matches for dynamic content.

Accessibility is not a checkbox; it’s a contract between UI code and assistive technologies that must be actively tested and observed.

Tradeoffs And Pitfalls

  • Tradeoff: accessibilityLabel and traits are fast to add but may not cover custom hit-testing; an NSViewRepresentable bridge gives control at the cost of extra maintenance.
  • Failure mode: frequent state updates without notifications mean VoiceOver users may not hear changes; excessive notifications can produce focus or announcement storms during rapid UI updates.
  • Maintainability risk: hard-coded strings distributed through view code lead to churn when copy changes; centralize strings and include reviewers in copy edits.
  • Debugging complexity: simulator behavior can differ from hardware VoiceOver; validate on a macOS machine with VoiceOver enabled before release.

Validation & Observability

Combine tests, signposts, and runtime logs to catch regressions early.

  • Use UI tests with async expectations to assert element presence, accessibilityFocused transitions, and trait flags.
  • Instrument async boundaries with signposts to correlate app updates and accessibility notifications.
  • Use structured logs to record when you post accessibility notifications and when focus changes occur.
  • Gate rollouts behind feature flags and capture telemetry so you can detect accessibility regressions during a rollout.
  • For profiling, validate responsiveness and stability on hardware; simulator accessibility behavior can differ from a real device.

Encode flaky timing in tests using reasonable timeouts and avoid exact label matches; record failures in CI and block merges when accessibility flows regress.

Practical Checklist

  • Inventory custom controls and list expected NSAccessibility role, accessibilityLabel, and keyboard flow.
  • Add accessibilityLabel, accessibilityValue, and accessibilityHint for visible interactive elements.
  • For controls with custom hit-testing or gestures, implement an NSViewRepresentable bridge and set NSAccessibility properties.
  • Add UI assertions for element presence, role/trait checks, and accessibilityFocused transitions.
  • Debounce and post accessibility notifications for dynamic updates; validate with VoiceOver manual scenarios.
  • Instrument updates with structured logs and signposts; gate rollouts behind telemetry that can flag accessibility regressions.

Closing Takeaway

Treat accessibility as a runtime contract: map roles explicitly, implement an AppKit bridge when SwiftUI modifiers are insufficient, and verify behavior with automated tests and runtime observability. Investing early in deterministic semantics and notifications reduces the risk of customer-facing accessibility incidents and emergency rollbacks.

Swift/SwiftUI Code Example

import SwiftUI
import AppKit

struct CanvasItem: Identifiable {
    let id = UUID()
    let title: String
    var value: Int
}

struct AccessibleCanvas: View {
    @State private var items: [CanvasItem] = [
        .init(title: "Brush", value: 1),
        .init(title: "Eraser", value: 0)
    ]

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Tools").font(.headline)
                .accessibilityHidden(false)
                .accessibilityLabel("Tool palette")
                .accessibilityHint("Contains drawing tools")
                .accessibilityAddTraits(.isHeader)

            ForEach($items) { $item in
                HStack {
                    Text(item.title)
                    Spacer()
                    Text("\(item.value)")
                }
                .padding(8)
                .background(RoundedRectangle(cornerRadius: 6).fill(Color.windowBackgroundColor.opacity(0.1)))
                // Explicit semantics for each interactive piece:
                .accessibilityElement(children: .ignore)
                .accessibilityLabel(item.title)
                .accessibilityValue("\(item.value)")
                .accessibilityHint("Double press to edit \(item.title)")
                .accessibilityAddTraits(.isButton)
                .accessibilitySortPriority(Double(items.firstIndex(where: { $0.id == item.id }) ?? 0))
                .onTapGesture {
                    item.value = (item.value + 1) % 10
                    // Notify assistive technologies of a layout/state change.
                    if let win = NSApp.keyWindow {
                        NSAccessibility.post(element: win, notification: .valueChanged)
                    }
                }
            }
        }
        .padding()
        .onAppear {
            // Ensure VoiceOver sees the canvas role explicitly.
            NSAccessibility.attributeNames(for: NSApp)
        }
    }
}

References