WWDC Quick Look đź’“ By SwiftGGTeam
Enhance the accessibility of your reading app

Enhance the accessibility of your reading app

Watch original video

Highlight

Apple provides text navigation APIs, including accessibilityNextTextNavigationElement and accessibilityLinkedGroup, plus the UITextInput protocol, so reading apps can give both discrete text views and custom-rendered text native VoiceOver line navigation, continuous Speak Screen page turns, and text selection.

Core Content

Developers building reading apps often run into the same problem: when a page is assembled from several independent text views, VoiceOver reaches the end of a paragraph and hits a wall. The user has to find the next focus target manually, and the reading experience is abruptly interrupted.

(06:43) The speaker demonstrates this with a travel guide app. Each paragraph is a separate UITextView, and VoiceOver’s Lines rotor gets stuck at the end of a paragraph with an error sound. Users cannot read line by line smoothly across paragraphs.

Apple’s solution has two tracks. The first is for standard text views: use navigation APIs to stitch discrete text elements together in the accessibility tree. The second is for custom-rendered text: fully implement the UITextInput protocol so nonstandard content such as scanned images and handwritten notes can get a native text experience.

Text navigation across paragraphs

(07:00) iOS 18 introduced text navigation APIs. In UIKit, set accessibilityNextTextNavigationElement and accessibilityPreviousTextNavigationElement on each text element, and VoiceOver can move seamlessly between paragraphs during line navigation.

(08:02) SwiftUI provides a more concise solution in iOS 27: the accessibilityLinkedGroup(id:in:) modifier. Text elements with the same ID inside the same namespace automatically gain cross-element navigation.

Continuous reading and automatic page turns

(09:35) Paginated content stops at the end of a page when Speak Screen reads it aloud. Apple provides the .causesPageTurn trait together with the accessibilityScroll method, letting assistive technologies turn the page automatically after finishing one page, creating an experience closer to an audiobook.

Text selection and custom actions

(11:16) Standard text views already support text selection with VoiceOver. Developers can also register business actions, such as “Save Recommendation,” in the edit rotor so users can execute them directly after selecting text.

Accessibility for custom text

(14:38) When an app uses scanned images, custom rendering, or other cases where standard text views cannot be used, fully implementing the UITextInput protocol gives that content the same accessibility experience as a native text view: touch exploration line by line, rotor navigation, and text selection.

Details

Connecting discrete text views in UIKit

(07:29)

import UIKit

class TravelGuidePageController: UIViewController {

    var paragraphs: [TravelGuideParagraph]

    func configureNavigationElements() {
        for (index, paragraph) in paragraphs.enumerated() {
            if index + 1 < paragraphs.count {
                paragraph.accessibilityNextTextNavigationElement = paragraphs[index + 1]
            }
            if index - 1 >= 0 {
                paragraph.accessibilityPreviousTextNavigationElement = paragraphs[index - 1]
            }
        }
    }
}

Key points:

  • accessibilityNextTextNavigationElement specifies the next target element for VoiceOver line navigation
  • accessibilityPreviousTextNavigationElement specifies the previous target element
  • Iterate through all paragraphs during controller setup and create bidirectional links for each paragraph
  • Boundary paragraphs, the first and last, only need one-way links

Linking text groups in SwiftUI

(07:59)

import SwiftUI

struct PageView: View {
    @Namespace private var pageNamespace
    var paragraphs: [String]
    var pageNumber: Int

    var body: some View {
        Text(paragraphs[0])
            .textSelection(.enabled)
            .accessibilityLinkedGroup(id: pageNumber, in: pageNamespace)

        Text(paragraphs[1])
            .textSelection(.enabled)
            .accessibilityLinkedGroup(id: pageNumber, in: pageNamespace)
    }
}

Key points:

  • @Namespace creates a namespace that limits the scope of the linked group
  • Text elements with the same id and the same namespace in accessibilityLinkedGroup(id:in:) are treated as one continuous text block
  • .textSelection(.enabled) enables text selection and is a prerequisite for the full accessibility experience
  • AppKit users can use accessibilitySharedTextUIElements for a similar result

Automatic page turns

(09:50)

import UIKit

class TravelGuidePageController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        self.lastParagraphView.accessibilityTraits.insert(.causesPageTurn)
    }

    override func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool {
        moveToPage(direction)
        var scrollString = "Page \(currentPage) of \(pages.count)"
        UIAccessibility.post(notification: .pageScrolled, argument: scrollString)
        return true
    }
}

Key points:

  • The .causesPageTurn trait marks the last element on a page, so Speak Screen can automatically trigger a page turn when it reaches that element
  • accessibilityScroll(_:) handles page-turn logic and returns true to indicate success
  • UIAccessibility.post(notification: .pageScrolled, argument:) announces the current page number so users know the page changed
  • This trait is available in both UIKit and SwiftUI

Custom actions in the edit rotor

(11:45)

import UIKit

class TravelGuideParagraph: UITextView {

    override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
        get {
            let saveAction = UIAccessibilityCustomAction(name: "Save Recommendation") { _ in
                self.saveRecommendation()
            }
            saveAction.category = UIAccessibilityCustomAction.editCategory
            return (super.accessibilityCustomActions ?? []) + [saveAction]
        }
        set { }
    }

    private func saveRecommendation() -> Bool {
        // Save the selected recommendation
        return true
    }
}

Key points:

  • UIAccessibilityCustomAction.editCategory registers the action in the edit rotor instead of the general actions menu
  • When users are selecting text, they can switch rotor modes and find this action
  • Keep super.accessibilityCustomActions so system default actions are not overwritten
  • The action closure returns Bool to indicate whether the action succeeded

Implementing UITextInput in a custom view

(16:10)

import UIKit

class ScannedPage: UIView, UITextInput {

    override init(frame: CGRect) {
        super.init(frame: frame)
        let interaction = UITextInteraction(for: .nonEditable)
        interaction.textInput = self
        addInteraction(interaction)
    }

    func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
        var rects: [UITextSelectionRect] = []

        let startLine = lineIndex(for: range.start)
        let endLine = lineIndex(for: range.end)

        for line in startLine...endLine {
            let rect = selectionRectFromImage(for: range, in: line)
            rects.append(rect)
        }

        return rects
    }

    func text(in range: UITextRange) -> String? {
        let nsRange = nsRange(from: range)
        guard let range = Range(nsRange, in: scannedText) else {
            return nil
        }
        return String(scannedText[range])
    }

    var tokenizer: any UITextInputTokenizer {
        CustomHandwritingTokenizer(textInput: self)
    }

    weak var inputDelegate: UITextInputDelegate?

    var selectedTextRange: UITextRange? {
        willSet { inputDelegate?.selectionWillChange(self) }
        didSet { inputDelegate?.selectionDidChange(self) }
    }
}

Key points:

  • UITextInteraction(for: .nonEditable) adds system-level selection interaction to non-editable text, automatically showing selection handles and highlights
  • selectionRects(for:) returns an array of rectangles for the selected region, which VoiceOver uses to draw the focus frame
  • text(in:) returns the text for the specified range, which assistive technologies use for speech
  • tokenizer provides a tokenizer that manages navigation granularity by line, sentence, word, and character
  • The willSet and didSet observers on selectedTextRange notify the system when selection changes and trigger visual updates
  • UITextInput must be fully implemented to get the complete accessibility experience

Key Takeaways

Add continuous cross-chapter reading to a novel reader

What to build: Use accessibilityLinkedGroup or the navigation APIs to connect paragraphs, then mark the end of a chapter with .causesPageTurn so VoiceOver and Speak Screen can continuously read an entire chapter.

Why it is worth building: Readers often split each chapter into multiple paragraph views for lazy loading. VoiceOver reaches the end of a paragraph and hits a wall, forcing users to find the next focus target manually. Cross-paragraph navigation gives blind and low-vision users a continuous reading experience closer to an audiobook.

How to start: In SwiftUI, use accessibilityLinkedGroup(id:in:). In UIKit, connect discrete text views with accessibilityNextTextNavigationElement and accessibilityPreviousTextNavigationElement.

Make scanned document apps support text selection

What to build: Fully implement the UITextInput protocol for scanned PDFs or handwritten notes so text inside images can be read line by line by VoiceOver, read continuously by Speak Screen, and selected and copied by users.

Why it is worth building: Scanned documents have long been inert objects for blind and low-vision users, with no interaction possible. After implementing UITextInput, this nonstandard content gains the same accessibility experience as native text views.

How to start: Make the custom view conform to UITextInput, add UITextInteraction(for: .nonEditable), and implement selectionRects(for:), text(in:), and tokenizer.

Add business actions to text editing workflows

What to build: In a notes app, add actions such as “Add to Favorites” or “Generate Summary” for selected text and register them in the edit rotor.

Why it is worth building: After selecting text, blind and low-vision users have a poor experience if they need to search through complex menus for business actions. The edit rotor places common actions in a shortcut location after text selection and reduces the action path.

How to start: Create a UIAccessibilityCustomAction, set its category to UIAccessibilityCustomAction.editCategory, and return it from accessibilityCustomActions.

Provide accessibility for mixed text-and-image layouts

What to build: Magazine-style pages often include complex layouts where text wraps around images. Link text blocks with accessibilityLinkedGroup, and set separate descriptive labels on images.

Why it is worth building: In complex layouts, assistive technologies may traverse content in the wrong order, and images without description labels may be skipped. Correct linking and labels let the whole page be traversed in reading order.

How to start: Add accessibilityLinkedGroup(id:in:) to text views to preserve reading continuity, and set accessibilityLabel on images to describe their content.

Test the accessible reading experience of an app

What to build: Turn on VoiceOver, use the Lines rotor to swipe line by line, and verify that movement continues across paragraphs. Turn on Speak Screen, using a two-finger swipe down from the top of the screen, and check whether reading turns pages automatically at the end.

Why it is worth building: Accessibility issues are often missed in normal visual testing. Every UI structure change can break the accessible reading experience, so regular testing catches problems early.

How to start: Enable VoiceOver and Speak Screen in device settings, test cross-paragraph navigation with the Lines rotor, and verify automatic page turns with .causesPageTurn.

  • 220 - Accessibility Controls — Introduces new accessibility control APIs that complement interaction controls for reading experiences
  • 370 - TextKit — A deep dive into text rendering and layout, useful for understanding the geometry needed when custom text implements UITextInput
  • 278 - UIKit modernization — New UIKit features and modernization practices, including accessibility improvements
  • 269 - SwiftUI — New SwiftUI APIs, including more uses of accessibility modifiers such as accessibilityLinkedGroup

Comments

GitHub Issues · utterances