Highlight
Apple adds Accessibility Preview to SwiftUI
accessibilityRepresentation, custom rotor, and accessibility focus APIs that allow developers to inspect, correct, and enhance the navigation experience for assistive technologies such as VoiceOver.
Core Content
Nathan Tannar made a financial management application prototype in his speech called Wallet Pal. The interface looks polished, and early test users like it. But VoiceOver users reported that the app was difficult to navigate and some parts were inaccessible.
The problem occurs beyond color, animation, and typography. VoiceOver reads a set of accessibility interfaces: element order, labels, traits, values, and executable actions. If a custom control only draws a shape without telling the system its semantics, assistive technologies will not be able to operate it correctly.
SwiftUI originally automatically generates a lot of accessibility information.Textwill become labeled static text, SF Symbols will have default labels, standardSliderThere will be adjustable values. But once the application is usedCanvas, custom gestures, overlay buttons, and complex lists, default inference is not enough.
Apple added several capabilities to SwiftUI at WWDC21. Accessibility Preview in Xcode 13 allows you to inspect elements directly in the preview.accessibilityRepresentationYou can let custom controls borrow the semantics of standard controls.accessibilityChildrenIt can add structure to the drawn graphics.accessibilityElement(children:)andaccessibilitySortPriorityNavigation order can be organized.accessibilityRotorandAccessibilityFocusStateThis gives complex interfaces a faster VoiceOver path.
The common goal of these APIs is clear: the visual interface continues to be customized to the needs of the product, and the accessibility interface individually complements the semantics and navigation rules. Developers don’t have to give up custom UIs, and they don’t have to make VoiceOver users swipe back and forth through meaningless elements.
Detailed Content
Accessibility Preview: See the accessibility tree first
(02:00)
Xcode 13 adds Accessibility Preview to SwiftUI Previews. It displays the accessibility elements generated by the current view, including labels, traits, and sorting. Use a simple speechVStackdemonstrates this.
struct ContentView: View {
var body: some View {
VStack {
Text("WWDC 2021")
.accessibilityAddTraits(.isHeader)
Text("SwiftUI Accessibility")
Text("Beyond the Basics")
Image(systemName: "checkmark.seal.fill")
}
}
}
Key points:
VStackevery one inTextwill automatically become an accessibility element. -Text("WWDC 2021")Originally a static text,.accessibilityAddTraits(.isHeader)Give it title semantics. -Image(systemName: "checkmark.seal.fill")Using SF Symbol, the system will automatically generate a default label for it. The default label for this symbol in speech isVerified.- Accessibility Preview shows these changes in real time, without running the full app.
This tool solves the first step problem: you first know what VoiceOver actually sees, and then decide which piece of semantics to add.
accessibilityRepresentation: Let custom controls borrow standard control semantics
(05:50)
Wallet Pal has a budget slider. Drawn with a custom shape and drag gesture, it looks like a slider visually, but VoiceOver doesn’t know it’s adjustable. SwiftUI providesaccessibilityRepresentation, making this custom slider behave as a standard for accessibilitySlider。
struct BudgetSlider: View {
@Binding var value: Double
var label: String
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(label)
Text(value.toDollars()).bold()
}
SliderShape(value: value)
.gesture(DragGesture().onChanged(handle))
.accessibilityRepresentation {
Slider(value: $value, in: 0...1) {
Text(label)
}
.accessibilityValue(value.toDollars())
}
}
}
}
struct SliderShape: View {
var value: Double
private struct BackgroundTrack: View {
var cornerRadius: CGFloat
var body: some View {
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
.foregroundColor(Color(white: 0.2))
}
}
private struct OverlayTrack: View {
var cornerRadius: CGFloat
var body: some View {
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
.foregroundColor(Color(white: 0.95))
}
}
private struct Knob: View {
var cornerRadius: CGFloat
var body: some View {
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
.strokeBorder(Color(white: 0.7), lineWidth: 1)
.shadow(radius: 3)
}
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
BackgroundTrack(cornerRadius: geometry.size.height / 2)
OverlayTrack(cornerRadius: geometry.size.height / 2)
.frame(
width: max(geometry.size.height, geometry.size.width * CGFloat(value) + geometry.size.height / 2),
height: geometry.size.height)
Knob(cornerRadius: geometry.size.height / 2)
.frame(
width: geometry.size.height,
height: geometry.size.height)
.offset(x: max(0, geometry.size.width * CGFloat(value) - geometry.size.height / 2), y: 0)
}
}
}
}
extension Double {
func toDollars() -> String {
return "$\(Int(self))"
}
}
Key points:
BudgetSliderKeep the original visual structure: label and value above, custom slider shape below. -SliderShape(value: value)Responsible for drawing tracks, progress and knobs. -.gesture(DragGesture().onChanged(handle))Continue to handle the drag and drop interaction of visual controls. -.accessibilityRepresentationIt does not change the visual UI, it only replaces the semantics exposed by accessibility.- in closure
Slider(value: $value, in: 0...1)Let assistive technology treat it like a standard slider. -.accessibilityValue(value.toDollars())Convert internal values into dollar amounts that the user can understand.
This API is suitable for custom controls. If you already have a visual control and know which standard control it corresponds to, use the standard control as an auxiliary function representation.
accessibilityChildren: Complement the structure for Canvas graphics
(09:40)
Wallet Pal also has budget history graphs. For chartsCanvasDraw a histogram. To VoiceOver, a drawing area has no natural structure. SwiftUI providesaccessibilityChildren, allowing developers to supplement graphics with accessible child elements.
struct Budget: Identifiable {
var month: String
var amount: Double
var id: String { month }
}
struct BudgetHistoryGraph: View {
var budgets: [Budget]
var body: some View {
GeometryReader { proxy in
VStack {
Canvas { ctx, size in
let inset: CGFloat = 25
let insetSize = CGSize(width: size.width, height: size.height - inset * 2)
let width = insetSize.width / CGFloat(budgets.count)
let max = budgets.map(\.amount).max() ?? 0
for n in budgets.indices {
let x = width * CGFloat(n)
let height = (CGFloat(budgets[n].amount) / CGFloat(max)) * insetSize.height
let y = insetSize.height - height
let p = Path(
roundedRect: CGRect(
x: x + 2.5,
y: y + inset,
width: width - 5,
height: height),
cornerRadius: 4)
ctx.fill(p, with: .color(Color.green))
ctx.draw(Text(budgets[n].amount.toDollars()), at: CGPoint(x: x + width / 2, y: y + inset / 2))
ctx.draw(Text(budgets[n].month), at: CGPoint(x: x + width / 2, y: y + height + 1.5*inset))
}
}
.accessibilityLabel("Budget History Graph")
.accessibilityChildren {
HStack {
ForEach(budgets) { budget in
Rectangle()
.accessibilityLabel(budget.month)
.accessibilityValue(budget.amount.toDollars())
}
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.foregroundColor(Color(white: 0.9)))
.padding(.horizontal)
}
}
Key points:
CanvasResponsible for drawing the monthly budget amount into a histogram. -.accessibilityLabel("Budget History Graph")Give the entire image a name. -.accessibilityChildrenProvides a set of accessibility sub-elements that do not affect the drawing results on the screen. -ForEach(budgets)Generate one for each monthRectangleas a semantic placeholder. -.accessibilityLabel(budget.month)Have VoiceOver read the month. -.accessibilityValue(budget.amount.toDollars())Have VoiceOver read the monthly amount.
The core information that charts convey to assistive technology is each data point.accessibilityChildrenHand over these data points to assistive technology.
accessibilityElement: Organize navigation methods for complex cells
(15:10)
A common problem in complex lists: a cell contains pictures, names, buttons and click gestures. VoiceOver steps into each inner element one by one, requiring the user to swipe many times to get past a line. Use SwiftUIaccessibilityElement(children:)Controls how parent and child elements are rendered.
struct User: Identifiable {
var id: Int
var name: String
var photo: String
}
struct FriendCellView: View {
var user: User
var body: some View {
ZStack(alignment: .topLeading) {
VStack(alignment: .center) {
Image(user.photo)
Text(user.name)
}
Button("Send Challenge", action: { /* ... */ })
.buttonStyle(
SymbolButtonStyle(
systemName: "gamecontroller.fill"))
}
}
}
struct FriendsView: View {
var users: [User]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(users) { user in
FriendCellView(user: user)
.accessibilityElement(children: .contain)
.onTapGesture { /* ... */ }
}
AddFriendButton()
Spacer()
}
}
}
}
struct AddFriendButton: View {
var body: some View {
Button(action: { /* ... */ }) {
Circle()
.foregroundColor(Color(white: 0.9))
.frame(width: 50, height: 50)
.overlay(
Image(systemName: "plus")
.resizable()
.foregroundColor(Color(white: 0.5))
.padding(15)
)
}
.buttonStyle(PlainButtonStyle())
}
}
struct SymbolButtonStyle: ButtonStyle {
let systemName: String
func makeBody(configuration: Configuration) -> some View {
Image(systemName: systemName)
.accessibilityRepresentation { configuration.label }
}
}
Key points:
FriendCellViewThere are also avatars, names and challenge buttons. -ForEach(users)Generate a group of friend cells in the horizontal scrolling area. -.accessibilityElement(children: .contain)Turn the cell into a container and keep the internal elements. -.onTapGestureStill bound to the cell, used to enter friend details or perform main operations. -SymbolButtonStyleuseaccessibilityRepresentationRestore icon-only buttons to buttons with text labels.
(16:20)
If the order of internal elements does not conform to user operating habits, you can useaccessibilitySortPriorityAdjust the order. In the speech, set the priority of the challenge button to-1, so that it comes after the name.
struct FriendCellView: View {
var user: User
var body: some View {
ZStack(alignment: .topLeading) {
VStack(alignment: .center) {
Image(user.photo)
Text(user.name)
}
Button("Send Challenge", action: { /* ... */ })
.buttonStyle(
SymbolButtonStyle(
systemName: "gamecontroller.fill"))
.accessibilitySortPriority(-1)
}
}
}
Key points:
Button("Send Challenge")is an executable action. -.buttonStyle(SymbolButtonStyle(...))Display it visually as an icon button. -.accessibilitySortPriority(-1)Adjust the ordering of this button in accessibility navigation.- This modifier is suitable for solving local sorting problems without changing the visual hierarchy.
(16:55)
If the internal details of a cell do not warrant item-by-item navigation, sub-elements can be combined into a single element.
ForEach(users) { user in
FriendCellView(user: user)
.accessibilityElement(children: .combine)
.onTapGesture { /* ... */ }
}
Key points:
.combinewillFriendCellViewThe child elements of are merged into one accessibility element.- Users can pass a friend with one swipe without having to enter every detail of avatar, name, and button.
-
.onTapGestureKeep the main operation of the cell. - Merge cells suitable for content preview. Buttons that require separate operations should be combined with caution.
accessibilityRotor: Add a quick channel to VoiceOver
(20:30)
The VoiceOver rotor is a navigation tool. Users can select a category and quickly jump between matches. SwiftUIaccessibilityRotorAllows applications to define rotors for their own content.
struct Alert: Identifiable {
var id: Int
var isUnread: Bool
var isFlagged: Bool
var subject: String
var content: String
}
struct AlertsView: View {
var alerts: [Alert]
var body: some View {
VStack {
ForEach(alerts) { alert in
AlertCellView(alert: alert)
.accessibilityElement(children: .combine)
}
}
.accessibilityElement(children: .contain)
.accessibilityRotor("Warnings") {
ForEach(alerts) { alert in
if alert.isWarning {
AccessibilityRotorEntry(alert.title, id: alert.id)
}
}
}
}
}
Key points:
AlertsViewDisplay a set of notifications or warnings.- each
AlertCellViewuse.combineCompose a navigable element. - Outer layer
VStackuse.containPreserve the collection structure. -.accessibilityRotor("Warnings")Create a file namedWarningsof rotor. -AccessibilityRotorEntry(alert.title, id: alert.id)Put the alerts that meet the conditions into the rotor.
(21:50)
When the rotor entry is not at the same level as the actual focusable element, namespace can be used to explicitly associate the entry with the target.
struct AlertsView: View {
var alerts: [Alert]
@Namespace var namespace
var body: some View {
VStack {
ForEach(alerts) { alert in
VStack {
AlertCellView(alert: alert)
.accessibilityElement(children: .combine)
.accessibilityRotorEntry(id: alert.id, in: namespace)
AlertActionsView(alert: alert)
}
}
}
.accessibilityElement(children: .contain)
.accessibilityRotor("Warnings") {
ForEach(alerts) { alert in
if alert.isWarning {
AccessibilityRotorEntry(alert.title, id: alert.id, in: namespace)
}
}
}
}
}
Key points:
@Namespace var namespaceCreate a matching space. -.accessibilityRotorEntry(id: alert.id, in: namespace)Mark the actual jump target. -AccessibilityRotorEntry(..., id: alert.id, in: namespace)Reference the same target in the rotor. -AlertActionsView(alert:)Can be placed together with the cell, but the rotor still jumps to the cell itself.
(22:20)
Text editors can also use rotor. The talk shows jumping between text ranges by email, link, phone number.
struct ContentView: View {
@State var note: Note
var body: some View {
TextEditor($text.content)
.accessibilityRotor("Email Addresses", textRanges: note.addressRanges)
.accessibilityRotor("Links", textRanges: note.linkRanges)
.accessibilityRotor("Phone Numbers", textRanges: note.phoneNumberRanges)
}
}
Key points:
TextEditorResponsible for editing the text. -textRanges:Receives a collection of text ranges. -note.addressRangesCorresponding email address range. -note.linkRangesCorresponding link range. -note.phoneNumberRangesCorresponding phone number range.
This design is suitable for information-intensive interfaces. Users do not need to read the entire text from the beginning, but can jump directly to the content they care about.
AccessibilityFocusState: Give focus to important notifications
(24:45)
The last paragraph talks about focus. When the app pops up notifications, low-priority content can be read out using announcements. High-priority content should receive accessibility focus directly. SwiftUI provides@AccessibilityFocusStateand.accessibilityFocused。
struct Notification: Equatable {
enum Priority {
case low, high
}
var content: String
var priority: Priority
}
struct AlertNotificationView<Content: View>: View {
@ViewBuilder var content: Content
@Binding var notification: Notification?
@AccessibilityFocusState var isNotificationFocused: Bool
var body: some View {
ZStack(alignment: .top) {
content
if let notification = $notification {
NotificationBanner(notification: notification)
.accessibilityFocused($isNotificationFocused)
}
}
.onChange(of: notification) { notification in
if notification?.priority == .high {
isNotificationFocused = true
} else {
postAccessibilityNotification()
}
}
}
func postAccessibilityNotification() {
guard let announcement = notification?.content else {
return
}
#if os(macOS)
NSAccessibility.post(
element: NSApp.accessibilityWindow(),
notification: .announcementRequested,
userInfo: [.announcement: announcement])
#else
UIAccessibility.post(notification: .announcement, argument: announcement)
#endif
}
}
struct NotificationBanner: View {
@Binding var notification: Notification?
@State var timer: Timer?
@AccessibilityFocusState var isNotificationFocused: Bool
var body: some View {
if let notification = notification {
Text(notification.content)
.accessibilityFocused($isNotificationFocused)
.onAppear { startTimer() }
.onDisappear { stopTimer() }
} else {
EmptyView()
}
}
func startTimer() {
timer = Timer.scheduledTimer(
withTimeInterval: 3,
repeats: true) { _ in
if !isNotificationFocused {
notification = nil
}
}
}
func stopTimer() {
timer?.invalidate()
}
}
Key points:
@AccessibilityFocusState var isNotificationFocusedSave accessibility focus state. -.accessibilityFocused($isNotificationFocused)Bind the focus state to the notification banner. -.onChange(of: notification)Perform judgment when notification changes. -notification?.priority == .highwhen, putisNotificationFocusedset totrue, allowing high-priority notifications to gain focus.- Low priority notifications go away
postAccessibilityNotification(),passNSAccessibility.postorUIAccessibility.postMake an announcement. -NotificationBannerUse timer to automatically hide notifications, but inisNotificationFocusedWhen true, retain the banner.
This mode is suitable for messages such as payment failure, connection disconnection, permission errors, etc. that need to be processed immediately. Normal prompts can be read out, and key prompts can be focused.
Core Takeaways
-
What to do: Add accessibility representation to custom price slider, rating control and progress control. Why it’s worth doing:
accessibilityRepresentationYou can let self-drawn controls reuse the VoiceOver behavior of standard controls. How to start: First use Accessibility Preview to check whether the control is adjustable, and then useSlider、StepperorToggleas representation. -
What to do: Add itemized readings to the chart. Why it’s worth doing:
CanvasThe drawn bar charts and line charts have no natural semantics;accessibilityChildrenYou can hand your data points to VoiceOver. How to start: Create a chart data modelIdentifiable,existaccessibilityChildrenUsed insideForEachGenerate label and value. -
What: Optimize the number of VoiceOver swipes for horizontal card lists. Why it’s worth doing:
.contain、.combineandaccessibilitySortPriorityYou can control the navigation level and order of cells. How to start: Open Accessibility Preview, count how many times the user has to slide through a card, and then decide whether to keep the child elements or merge them into a single element. -
What to do: Add custom rotors to emails, alerts, and order lists. Why it’s worth doing:
accessibilityRotorAllows VoiceOver users to jump directly to key items such as unread, warnings, failed orders, etc. How to start: Define filter conditions first, and then useAccessibilityRotorEntryAdd qualified models to the rotor. -
What: Let high-priority banners automatically get accessibility focus. Why it’s worth doing:
AccessibilityFocusStateYou can distinguish between ordinary announcements and prompts that must be dealt with immediately. How to start: Add priority field to notification model, high priority binding.accessibilityFocused, low priority callUIAccessibility.postorNSAccessibility.post。
Related Sessions
- Tailor the VoiceOver experience in your data-rich apps — An in-depth talk about how VoiceOver presents complex data, suitable for viewing together with custom rotor and chart semantics.
- Bring accessibility to charts in your app — Specifically speaking about chart accessibility, extension
accessibilityChildrenThe data expression problem behind it. - Support Full Keyboard Access in your iOS app — Pay attention to keyboard navigation, which is an accessible interaction quality issue like VoiceOver navigation.
- Direct and reflect focus in SwiftUI — Talking about SwiftUI focus system can supplement understanding
AccessibilityFocusStateusage scenarios. - Create accessible experiences for watchOS — Discuss accessibility in watchOS scenarios, which is suitable for reference when making cross-platform SwiftUI applications.
Comments
GitHub Issues · utterances