2021 SwiftUI & UI FrameworksAccessibility & Inclusion
WWDC21 · 28 min · SwiftUI & UI Frameworks / Accessibility & Inclusion
SwiftUI Accessibility: Beyond the basics
Go beyond the basics to deliver an exceptional accessibility experience. Learn how to use the new SwiftUI Previews in Xcode to explore the latest accessibility APIs and create fantastic, accessible apps for everyone. Find out how you can customize the automatic accessibility built into SwiftUI to make your own custom controls accessible. Explore best practices and identify where to improve your app’s navigation experience using grouping and focus. And help supercharge navigation for VoiceOver users with the addition of rotors.
Watch at developer.apple.com ↗Code shown on screen · 16 snippets
Welcome to the Accessibility Preview
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")
}
}
} BudgetSlider
struct BudgetSlider: View {
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))
}
}
}
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))"
}
} Slider
struct StandardSlider: View {
var value: Double
var label: String
var body: some View {
Slider(value: $value, in: 0...1) {
Text(label)
}
}
} Accessible BudgetSlider
struct BudgetSlider: View {
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))"
}
} NavigationBarView
struct NavigationBarView: View {
var body: some View {
HStack {
Text("Wallet Pal")
.font(.largeTitle)
.bold()
Spacer()
Button("Edit Budgets", action: { ... })
.buttonStyle(
SymbolButtonStyle(
systemName: "slider.vertical.3"))
}
}
}
struct SymbolButtonStyle: ButtonStyle {
let systemName: String
func makeBody(configuration: Configuration) -> some View {
Image(systemName: systemName)
.accessibilityRepresentation { configuration.label }
}
} BudgetHistoryGraph
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)
}
} Composition
// See CompositionExample.swift in the referenced sample project FriendCellView
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 SymbolButtonStyle: ButtonStyle {
let systemName: String
func makeBody(configuration: Configuration) -> some View {
Image(systemName: systemName)
.accessibilityRepresentation { configuration.label }
}
} FriendsView
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)
.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 }
}
} FriendsView with Containers
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 }
}
} FriendCellView Sort Priority
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"))
.accessibilitySortPriority(-1)
}
}
} FriendsView with .combine
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: .combine)
.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 }
}
} AlertsView Implicit Rotor
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)
}
}
}
}
}
struct AlertCell: View {
var alert: Alert
var body: some View {
VStack(alignment: .leading) {
HStack {
if alert.isUnread {
Circle()
.foregroundColor(.blue)
.frame(width: 10, height: 10)
}
if alert.isFlagged {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.frame(width: 10, height: 10)
}
Text(alert.subject)
.font(.headline)
.fontWeight(.semibold)
Spacer()
Text("04/30/21")
.font(.subheadline)
.foregroundColor(.secondary)
}
Text(alert.content)
.lineLimit(3)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 8)
.foregroundColor(Color(white: 0.9))
)
}
} AlertsView Explicit Rotor
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 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)
}
}
}
}
}
struct AlertCell: View {
var alert: Alert
var body: some View {
VStack(alignment: .leading) {
HStack {
if alert.isUnread {
Circle()
.foregroundColor(.blue)
.frame(width: 10, height: 10)
}
if alert.isFlagged {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.frame(width: 10, height: 10)
}
Text(alert.subject)
.font(.headline)
.fontWeight(.semibold)
Spacer()
Text("04/30/21")
.font(.subheadline)
.foregroundColor(.secondary)
}
Text(alert.content)
.lineLimit(3)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 8)
.foregroundColor(Color(white: 0.9))
)
}
} TextEditor Rotors
struct ContentView: View {
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)
}
} AlertNotificationView
struct Notification: Equatable {
enum Priority {
case low, high
}
var content: String
var priority: Priority
}
struct AlertNotificationView<Content: View>: View {
var content: Content
var notification: Notification?
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 {
var notification: Notification?
var timer: Timer?
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()
}
} Resources
Related sessions
-
26 min -
40 min -
24 min -
40 min -
20 min -
23 min -
11 min