-
Enhance the accessibility of your reading app
Learn how to create robust reading experiences for VoiceOver, Speak Screen, and more. Find out how to provide intuitive text selection, clear navigation between lines and paragraphs, and continuous reading across individual elements and multiple pages.
Chapters
- 0:01 - Introduction
- 1:26 - Characteristics
- 3:45 - Standard views
- 14:05 - Custom text
Resources
- accessibilityNextTextNavigationElement
- editCategory
- accessibilityLinkedGroup(id:in:)
- causesPageTurn
- UITextInput
- Accessibility for UIKit
Related Videos
WWDC19
-
Search this video…
Hi! My name is Josh, and I'm a Software Engineer on the Accessibility team. Today, I'm going to talk about how to make your long form text or reading app accessible to everyone on Apple platforms.
Reading long-form content is fundamentally different from navigating UI: it's about moving fluidly through text, not just moving between UI elements like controls.
Apple's frameworks come built-in with accessible text in mind. But, there's more work you can do as a developer to enrich and extend the accessibility experience with long form text.
Today, I'm going to share some best practices and techniques for you to consider as you build your long-form content.
First, I'll talk about what characteristics make up a great reading experience for someone using VoiceOver, or another assistive technology. Then, I'll show how you can use and extend views from UIKit and SwiftUI using rich APIs designed specifically for the reading experience.
And last, I'll cover how you can make custom text in your app accessible to VoiceOver, Speak Screen, or the Accessibility Reader.
Starting off, I'll discuss what makes a great accessible experience in an app that displays long form content. Today, I want to build an app so that I can share recommendations and travel tips for one of my favorite cities: Chicago.
My app has paginated content, with multiple paragraphs and text that wraps across multiple lines. I want to make sure that anyone using an assistive technology has a great experience with this app.
In this session, I'll focus on two popular assistive technologies built into Apple platforms: VoiceOver and Speak Screen.
VoiceOver is Apple's built-in screen reader, designed for individuals who are blind or low vision. When I activate it, I can hear whatever is highlighted by the cursor. Morning. Heading. We started out our morning in Lincoln Park, strolling through the trails and admiring the views of the Chicago skyline.
Speak Screen is designed to read aloud all of the content on a given page, from top to bottom, highlighting while it is speaking. When turned on, I can initiate it by dragging down with two fingers from the top of the screen.
Midday. At lunchtime, we walked along the Chicago river. The river-front path gave us great views of the city's magnificent architecture. Our favourite view was from the middle of the DuSable Bridge, where we could look straight down the river.
Keeping these technologies in mind, I've made three goals to improve the interaction between those features and my app. Specifically, I want to make sure my app offers granular text navigation, so that VoiceOver and Speak Screen can fluidly move through the text. I also want to make sure I'm developing a continuous reading experience, so someone using an assistive technology doesn't encounter any interruptions. Lastly, I want to make sure my app provides comprehensive text selection.
I'm going to focus on these main goals during the rest of this video, and make sure that my travel app satisfies them all.
Apple's frameworks provide many text components that are accessible right out of the box, so now I'll focus on what those are, what they provide you, and how you can extend them with additional functionality.
Both UIKit and SwiftUI provide accessible text views, that allow for line, word, and character navigation with VoiceOver and Speak Screen, alongside accessible text selection.
You might already be familiar with UIAccessibilityReadingContent, which is a great way to make full page content accessible. While I'm not going to focus on that protocol, you can still use and adopt it on top of everything I'll discuss today. To learn more, check out "Creating an Accessible Reading Experience". Today, I'm going to focus on UITextInput, a higher fidelity protocol that native text views use, and one that you can adopt as well on custom views.
Standard text views across the system adopt the UITextInput protocol. With UIKit using UITextView on iOS will give you a rich text experience from the get go, as will TextEditor in SwiftUI. You can even use a simple SwiftUI Text view with selection enabled and benefit from these features on all Apple platforms. For those of you building macOS apps, using AppKit's NSTextView, or the SwiftUI Views discussed, will give you these benefits as well. When the constraints of your app allow, you should always try to use these components.
In my Travel Guide app, I chose to use UITextView's for each individual paragraph for its accessible properties. The unique layout I designed required me to use separate text views for each paragraph, rather than one that contained multiple paragraphs. I'll first assess how I'm doing with my goal of providing granular text navigation.
VoiceOver has a setting that allows someone to choose what granularity of text is read when touching their finger on the screen. I have my preference set to lines, so with VoiceOver on, I am able to tap on any line on the screen and hear the line read aloud. We started out our morning in Lincoln Park, strolling through the trails and admiring... VoiceOver also provides options to change the way it moves, through a feature called rotors. The active rotor can be changed using a two finger rotation gesture to switch modes. Now, I will switch into the lines rotor using that gesture, and swipe down with one finger to find the next line on the page.
Lines.
...the views of the Chicago skyline.
Now, I'll try moving from the end of this paragraph to the first line of the next one.
Right now, because each of these paragraphs are separate views, VoiceOver is stuck navigating by line within the paragraph, so someone can't fully explore the page by line and is why that sound is played.
To allow VoiceOver to move between paragraphs seamlessly, iOS 18 introduced the text navigation APIs. For each text element you want to connect, return the next and previous accessible text element that VoiceOver should navigate to.
For example, if I have two paragraph views, I can return Paragraph 2 from Paragraph 1's accessibilityNextTextNavigationElement method, and Paragraph 1 from Paragraph 2's accessibilityPreviousTextNavigationElement.
Here, I have the controller for the pages in my Travel Guide app. During setup, when the configureNavigationElements codepath is run, I set the the proper navigation element in each direction, where applicable.
Now that I've implemented it, VoiceOver can move past the end of one paragraph and onto the first line of the next. Before we left the park, we made sure to stop in the free zoo to check out all of the...
And if you are using SwiftUI, starting in iOS 27, linking multiple text elements together using the accessibilityLinkedGroup modifier will achieve the same effect. For example, I have an equivalent page view here with two selectable text elements. By linking them both with accessibilityLinkedGroup using the same id and namespace, they will get the text navigation behavior.
And if you are using AppKit on Mac, check out accessibilitySharedTextUIElements for a similar result. Now I know that VoiceOver can navigate around the pages of my app with different text granularities, without any unexpected gaps. But, I also set out to make sure that the continuous reading experience of my app is as smooth as possible.
Paginated content, by nature, requires swiping between pages. The goal is for assistive technologies to interact with this content seamlessly, without the pages getting in the way. VoiceOver and speak screen both have features that allow someone to read all content, from start to end, without having to swipe.
I'll explore my current app experience with Speak Screen. To do a read all, I'll swipe down from the top of my screen with two fingers. Midday. At lunchtime, we walked along the Chicago river. The river-front path gave us great views of the city's magnificent architecture. Our favorite view was from the middle of the DuSable Bridge, where we could look straight down the river.
You'll notice that Speak Screen stopped reading when it got to the bottom of the page. With paginated content, the best experience for a read all would be to move through all of the pages, advancing when appropriate, similar to an audiobook.
Here I have my apps page view controller again. In my viewDidLoad override, I can apply the causesPageTurn trait to the last paragraph on my page, which is available in both UIKit and SwiftUI. And when paired with accessibilityScroll, Speak Screen and VoiceOver will automatically scroll the page when it reaches the end.
I'll try using Speak Screen with that trait applied to my last paragraph.
Midday. At lunchtime, we walked along the Chicago river. The river-front path gave us great views of the city's magnificent architecture. Our favorite view was from the middle of the DuSable Bridge, where we could look straight down the river. Evening. To end the day, we walked along the lakefront alongside groups of runners and cyclists. This gave us another great view of the skyline, towering over the waters of Lake Michigan.
Great! Speak Screen automatically moved focus to the next page when it finished reading, just like I'd expect.
If you recall from earlier, the last behavior I want to validate is how text selection works with VoiceOver.
In my app, I added a feature to save selected content for referencing later by using a button in the toolbar. I need to make sure this feature is accessible.
I'm using a UITextView here, which already has accessible selection. You'll get the same experience by using TextEditor or text with selection enabled in SwiftUI. But, I also want people to be able to discover this 'Save recommendation' feature for their selected text. Visually, I added this button to my toolbar to save the current selection, but I can make this even more discoverable to VoiceOver through the edit rotor.
To do this, I can create a custom action and add it to VoiceOver's edit rotor by specifying the edit category when building my action. In my case, I'll override accessibilityCustomActions on my paragraph UITextView subclass, and add my Save Recommendation custom action alongside any actions from the super implementation.
Be sure to use the edit category when you have a custom action that would be associated with text selection, rather than a generic action.
Now, I'm going to turn VoiceOver on to try it out.
To select text, I'll switch to the text selection rotor, switch to word edit mode, and increase my selection by swiping right.
Text selection. Swipe right to expand selection. Swipe left to shrink selection. Word selection. "Our favorite view was from the DuSable Bridge..." selected.
With the text selected, I'll switch to the edit rotor, and activate the Save Selection action to save it.
Lines. Words. Characters. Edit.
Save selection. Text saved. Great! Now I have an accessible app experience using system text views, and unlocked a new set of accessible reading features by adopting APIs. Line navigation across elements, continuous reading, and text selection all work as I'd expect.
And the best part is that VoiceOver and Speak Screen aren't the only technologies to benefit from these changes. Since iOS 26, someone can open the Accessibility Reader, a tool designed to display text content for easier consumption. I have added the reader control to my control center, so pressing that opens my app's content in the Accessibility Reader. Implementing accessible text practices like I've shared so far will make the reader experience better for your content as well.
That's how you make standard text views accessible for reading content, and while I'd always recommend reaching for those views first, not every situation allows for them. Now I'm going to focus on what you should do when you are using custom text, or custom text elements, in your app to make them accessible.
Using custom text is a common pattern seen in dedicated reading apps to support advanced typography, share the code across a developer's applications, or displaying of scanned pages. When I travel, I like to take hand written notes on the places I go, so I've decided to replace my text views with scanned in pages from my notebook to give it a more personal touch. Unfortunately, this means that I've lost the accessibility behavior that UITextView gave me for free, including the most basic thing: reading out the text. Morning. Heading. Image.
The best way to make this content accessible is by using the UITextInput protocol, which can be adopted on any accessibility element. This protocol can make rendered text or text in images, for example, just as accessible as if they were in standard text views.
Fully implementing UITextInput gives you the same text experience you'd get as if you were using a native text view. You'll get line-by-line touch exploration with VoiceOver, granular navigation with the VoiceOver rotor and Speak Screen, and text selection.
To implement this protocol, you will have to solve for a few problems. You'll need to manage the geometry of your text, and compute selection rectangles for a given range, for example in the selectionRects method.
When an assistive technology queries for a range in your view, you'll need to be able to return just that portion of text. And importantly, you'll need to provide a tokenizer, which will help manage navigation by line, sentence, word, or character.
And these are just a few things you'll need to implement. To get all of the accessibility benefits of this protocol, make sure you implement it in its entirety.
In my app, I've implemented this protocol on my accessibility elements to make the text accessible. Here, I've implemented the selectionRects method from this protocol, which determines how VoiceOver and other assistive technology 'highlight' my content.
Since I'm working with handwriting from an image, I can use the known height and width of each line to compute the approximate rects for a given range using a custom function, selectionRectFromImage. I then use this information to build out my array of selection rects, which I'll return from this method.
I'll also complete implementations for the rest of the methods in the protocol, such as grabbing the right substring for textInRange and providing a tokenizer. In my case, I've subclassed the UITextInputStringTokenizer provided by UIKit to create a custom tokenizer that works with my implementation, so I'll return that.
Lastly, I want my selection experience to feel complete visually with selection handles and highlights. To do this, I added a UITextInteraction to my page view, and call the input delegate when my selection changes, so the system knows to update the visuals. This isn't required as part of the UITextInput implementation, but rounds out my app by matching expectations about the experience in a standard text view.
UITextInput also works great in conjunction with the causesPageTurn and navigation element APIs, so I've made sure to implement those as well in the new version of my app.
I've finished updating my app, taking the time to carefully implement the rest of the UITextInput protocol on my scanned in text and ensuring that I've implemented all of the APIs necessary. So, now I am going check out the VoiceOver experience.
First, I'm going to navigate by line.
Lines. We started out our morning in Lincoln Park, admiring the views of the Chicago skyline. The zoo had lots of animals.
Now, I will select some text by switching into the text selection rotor and swiping right. Text selection. Swipe right to expand selection. Swipe left to shrink selection. Line selection. "The zoo had lots of animals" selected. Lines. Words. Characters. Edit. Save selection. Text saved.
And finally, I can try a read-all. Morning. Heading. We started out our morning in Lincoln Park, admiring the views of the Chicago skyline. The zoo had lots of animals. Midday. Heading. At lunchtime, we walked along the Chicago river. The view from the DuSable Bridge was perfect for photos! Amazing! It all works seamlessly.
Now that I've covered what makes a great reading experience, and the APIs that make it possible, it's time for you to examine your own app.
Audit your app with VoiceOver on, trying out the read all gesture, navigate using the lines rotor, and selecting text. If you are using standard text views, consider adopting causesPageTurn and the text navigation element APIs for a smooth cross-page reading behavior. If you use custom rendered text, adopt UITextInput. Doing this work will enable a great experience for everyone who downloads your app. Happy reading!
-
-
7:29 - Link text elements together with navigation APIs
// Link text elements together with navigation APIs 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] } } } } -
7:59 - Link text elements together with a linked group
// Link text elements together with a linked group 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) } } -
9:50 - Turn pages automatically after reading
// Turn pages automatically after reading 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 } } -
11:45 - Add actions to the editor rotor
// Add actions to the editor rotor 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 { ... return true } } -
16:10 - Adopt UITextInput
// Adopt UITextInput 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? { // Update visuals when assistive technologies change selection willSet { inputDelegate?.selectionWillChange(self) } didSet { inputDelegate?.selectionDidChange(self) } } }
-
-
- 0:01 - Introduction
What makes reading apps an accessibility challenge distinct from UI navigation, and what the session covers — the characteristics of a great reading experience, extending UIKit and SwiftUI text views, and making custom text accessible.
- 1:26 - Characteristics
Reading apps present unique accessibility challenges distinct from standard UI navigation, requiring fluid movement through text for technologies like VoiceOver and Speak Screen. This session covers three goals — granular navigation, continuous reading, and text selection — using UIKit, SwiftUI, and AppKit APIs.
- 3:45 - Standard views
UITextView, SwiftUI's TextEditor and selectable Text, and NSTextView on macOS all adopt UITextInput automatically, providing line, word, and character navigation and accessible text selection. The accessibilityNextTextNavigationElement and accessibilityPreviousTextNavigationElement APIs (and the new accessibilityLinkedGroup for SwiftUI) connect separate text elements so VoiceOver can move between them seamlessly, while the causesPageTurn trait provides page turning automatically during read-all gestures.
- 14:05 - Custom text
When using custom or custom-rendered text — such as scanned images — adopting the full UITextInput protocol gives VoiceOver and Speak Screen the same granular navigation and selection capabilities as native text views. This requires implementing text geometry methods like selectionRects(for:), a tokenizer, and text range methods, and can be paired with UITextInteraction for visible selection handles.