WWDC Quick Look 💓 By SwiftGGTeam
SwiftUI Accessibility: Beyond the basics

SwiftUI Accessibility: Beyond the basics

Watch original video

Highlight

Apple adds Accessibility Preview to SwiftUIaccessibilityRepresentation, 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 closureSlider(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.
  • eachAlertCellViewuse.combineCompose a navigable element.
  • Outer layerVStackuse.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 awaypostAccessibilityNotification(),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 useSliderStepperorToggleas 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

Comments

GitHub Issues · utterances