SwiftUI Accessibility Testing: Dynamic Type and VoiceOver
- Author
- Ehsan Azish · 3NSOFTS
- Updated
- Read time
- 7 min read
- Level
- Intermediate
- Platform
- Xcode 15 or later, Swift 5.9 or later, iOS 17 or later
Implementation Notes
- ~/ What broke: A production edge case that generic tutorials skip.
- ~/ What to do: Ship the production fix with clear state, errors, and fallback behavior.
Quick Answer
Test SwiftUI accessibility at the component level and across complete user journeys. Use semantic text styles and flexible layouts for Dynamic Type, then inspect the spoken interface with VoiceOver. Add performAccessibilityAudit() to UI tests for repeatable checks on known screen states. Passing that audit does not prove that focus order, wording, or error recovery makes sense to a person.
Requirements
Use Xcode 15 or later, Swift 5.9 or later, and iOS 17 or later for these examples. The view uses SwiftUI. The automated audit belongs in an XCTest UI test target. Run a manual VoiceOver pass on a physical device as a separate check.
Use This Pattern When
- A row combines a title, explanatory text, and a separate action.
- Larger text causes clipping or pushes buttons outside their container.
- You need a repeatable accessibility gate for a release.
- Your automated tests currently cover only the default text size and happy path.
Implementation
1. Give the layout room to change
This illustrative row switches its arrangement for accessibility text sizes. It keeps the action as a native button, with enough context to distinguish it from other rows. It deliberately has no fixed row height or single-line title limit.
import SwiftUI
struct SavedDocumentRow: View {
@Environment(\.dynamicTypeSize) private var typeSize
let title: String
let detail: String
let open: () -> Void
private var layout: AnyLayout {
typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
: AnyLayout(HStackLayout(alignment: .center, spacing: 16))
}
var body: some View {
layout {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(detail).font(.body)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
Button(action: open) {
Label("Open", systemImage: "doc.text")
.padding(.vertical, 8)
}
.buttonStyle(.bordered)
.accessibilityLabel(Text("Open \(title)"))
.accessibilityIdentifier("openSavedDocument")
}
.padding()
}
}
Apple's Dynamic Type session demonstrates semantic fonts and layout changes for larger text. Here, only the descriptive text is grouped. Combining the whole row could make its separate action harder to discover. Check the resulting accessibility tree rather than assuming a modifier produces the intended reading experience.
The fixed identifier is suitable for a single-row fixture. In a real list, use a stable per-record identifier for UI tests. Accessibility identifiers are test hooks, not spoken labels; put useful language in the label itself.
2. Preview long content at a large text size
#Preview("Large text and a long title") {
ScrollView {
SavedDocumentRow(
title: "Home renovation receipts and service records",
detail: "Updated yesterday. Three documents need review.",
open: {}
)
}
.frame(width: 320)
.environment(\.dynamicTypeSize, .accessibility5)
}
Use the narrow preview as a stress case, then test the actual navigation container. Add previews for the default size, dark appearance, and your longest supported localized strings. Do not cap Dynamic Type globally to make one difficult row fit.
3. Model the states people must understand
Give loading, empty results, failure, and completion their own fixtures. If saving fails, show an explanation and an available retry action. Preserve the person's input. If opening a sheet changes focus, check where focus returns when it closes.
Avoid making every changing progress value a spoken announcement. Repeated announcements can interrupt the task. Decide which transitions need an announcement, then test them with VoiceOver running rather than inferring behavior from a visual status label.
Testing
Apple documents automated accessibility audits. This UI test checks the initial screen reached by your app. For a meaningful release gate, navigate to each critical populated screen and audit that state too.
import XCTest
final class AccessibilitySmokeTests: XCTestCase {
@MainActor
func testInitialScreenAccessibility() throws {
let app = XCUIApplication()
app.launch()
try app.performAccessibilityAudit()
}
}
Use deterministic test data and wait for a known screen element before auditing a screen reached asynchronously. Do not blanket-ignore audit failures to stabilize CI. Investigate each issue and document any narrowly scoped exception with reproduction evidence.
For the row example, manually check the following on a device:
- Navigate to the row using VoiceOver swipes, without exploring by sight.
- Hear the title and supporting information in a useful order.
- Reach the Open action, activate it, and confirm the correct document opens.
- Close the destination and check that focus returns to a useful location.
- Repeat with a long title and the largest text size.
Repeat an error path with the same settings. A successful open does not verify that a failed open is understandable or recoverable.
Common Mistakes
- Fixing a row height to match a screenshot and clipping larger text.
- Combining an entire container that includes independent controls.
- Naming every icon button “Open” without enough context.
- Auditing an empty screen while shipping a dense populated interface.
- Losing meaningful focus after dismissing a sheet. This transition can fail even when each screen looks correct in isolation.
- Treating an automated pass as proof of a usable journey.
Production Checklist
- Test long content on the smallest supported layout.
- Cover default and accessibility text sizes.
- Keep primary actions reachable when content grows.
- Confirm spoken labels and control roles on a device.
- Check focus after navigation, sheets, errors, and deletion.
- Confirm that color and motion are not the only signals of state.
- Run audits on populated, empty, and failed states.
- Record the build, OS, device, settings, and unresolved issues.
Related
- Accessibility release acceptance criteria
- SwiftUI layout and GeometryReader alternatives
- Production navigation and restoration
- iOS code review checklist