Chapters

Hide chapters

SwiftUI by Tutorials

Fourth Edition · iOS 15, macOS 12 · Swift 5.5 · Xcode 13.1

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

12. Accessibility
Written by Audrey Tam

Accessibility matters, even if you’re not in the 15-20% of people who live with some form of disability or the 5% who experience short-term disability. Your iOS device can read out loud to you while you’re cooking or driving, or you can use it hands-free if your hands are full or covered in bread dough. Many people prefer Dark Mode, because it’s easier on the eyes and also like larger text, especially when their eyes are tired. And there are growing concerns about smartphone addiction. A popular tip is to set your iPhone to grayscale! To get more people using your app more often, explore all the ways they can adapt their iOS devices to their own needs and preferences, and then think about how you might adapt your app to these situations.

Most app UIs are very visual experiences, so most accessibility work focuses on VoiceOver — a screen reader that lets people with low to no vision use Apple devices without needing to see their screens. VoiceOver reads out information to users about your app’s UI elements. It’s up to you to make sure this information helps users interact efficiently with your app.

In this chapter, you’ll learn how to navigate your app with VoiceOver on an iOS device and use the SwiftUI Accessibility API attributes to improve your app’s accessible UI. You’ll add labels that provide context for UI elements and improve VoiceOver information by reordering, combining or ignoring child elements.

Apple’s investing a lot of effort in helping you improve the accessibility of your apps. With SwiftUI, it’s easier than ever before. The future is accessible, and you can help make it happen!

Using VoiceOver on a device

Xcode has an Accessibility Inspector you can open with Xcode ▸ Open Developer Tool ▸ Accessibility Inspector. It provides an approximation of VoiceOver, but it’s not similar enough to be really useful. Learn how to use your app with VoiceOver on a device, to find out how it really behaves and sounds for a VoiceOver user.

Setting up VoiceOver shortcut

On your device, open Settings ▸ Accessibility ▸ Accessibility Shortcut, and select VoiceOver. This enables you to switch VoiceOver on and off by triple-clicking the device’s side button.

iPhone Settings: Accessibility shortcut includes VoiceOver.
iPhone Settings: Accessibility shortcut includes VoiceOver.

Note: I check more than one shortcut option to avoid accidentally turning on VoiceOver when I’m trying to use Apple Pay. You could turn on Settings ▸ Accessibility ▸ Touch ▸ Back Tap and set Double-tap or Triple Tap to VoiceOver. Or just ask Siri to turn VoiceOver on or off.

Using VoiceOver

After setting up your shortcut(s), go back to Settings ▸ Accessibility and use a shortcut to start VoiceOver. Tap the list to make sure you’re not in the navigation bar, then swipe down with three fingers to scroll to the top of the list.

Note: If your screen locks on an iPhone with Face ID: Wake iPhone and glance at it, then drag up from the bottom edge of the screen until you feel a vibration or hear two rising tones.

Now practice some basic navigation in VoiceOver.

First, Swipe right until you reach VoiceOver. Double-tap anywhere to activate this item.

A split-tap gesture is another way to activate an item: Touch and hold Speech with one finger, then tap the screen with another.

VoiceOver ▸ Speech
VoiceOver ▸ Speech

Make sure the Detect Languages option is on. You’ll soon hear it in action.

Next, with two fingers, do a Z gesture. This takes you back to the previous screen. You can also use it to dismiss an alert.

Now, activate Verbosity: VoiceOver users can customize what VoiceOver reads out. These are my settings:

VoiceOver verbosity settings used for this chapter
VoiceOver verbosity settings used for this chapter

If your device’s settings are different, your VoiceOver might say slightly different things.

Note: It’s OK to turn off VoiceOver while you select your Verbosity settings. ;]

If you’re on an iPhone with Face ID, drag up from the bottom edge of the screen until you feel a vibration or hear two rising tones. This takes you back to the home screen.

There are many more gestures, and links to more information about VoiceOver, at Learn VoiceOver Gestures on iPhone.

Accessibility in SwiftUI

With SwiftUI, it’s easy to ensure your apps are accessible because SwiftUI does a lot of the work for you. SwiftUI elements support Dynamic Type and are accessible by default. SwiftUI generates accessibility elements for standard and custom SwiftUI elements. Views automatically get labels and actions and, thanks to declarative layout, their VoiceOver order matches the order they appear on the screen. SwiftUI tracks changes to views and sends notifications to keep VoiceOver up to date on visual changes in your app.

When the accessibility built into SwiftUI doesn’t provide the right information in the right order, you’ll use the SwiftUI Accessibility API to make your accessible elements understandable, interactable and navigable:

  • Understandable: In this chapter, you’ll learn what SwiftUI generates for VoiceOver from SwiftUI element initializers. Then, to clarify or add context to accessible elements, you’ll override the generated default labels. You’ll customize the accessibility labels and values, hiding elements that provide unnecessary or redundant information, and moving some information to hints that the user hears only if they seem unsure.

  • Interactable: Aim to give your accessible elements appropriate default actions and create custom actions to simplify interaction for users of assistive technology. When your app has custom actions like context menus, double-tap-hold can display them. For example, in Maps with VoiceOver on, double-tap-hold an annotation to see the usual context menu.

In VoiceOver, double-tap-hold annotation to show context menu.
In VoiceOver, double-tap-hold annotation to show context menu.

  • Navigable: You’ll change the order that VoiceOver visits elements, and you’ll group elements to reduce the number of steps and speed up navigation for VoiceOver users.

The amount of work for each accessible element could be as little as a few words or lines of code. Or you might need to refactor or add code, or even change a navigation link or alert into a modal sheet.

Most of the time, you’ll add accessibility to your app without changing its appearance and behavior for users who aren’t using VoiceOver. But sometimes, something you do for VoiceOver will inspire an improvement to your visual UI.

SwiftUI: Accessibility by default

Look at this typical Toggle code:

Toggle(isOn: $rememberUser) { Text("Remember me") }

There’s no explicit accessibility code here, just the type of element — Toggle — and its label. VoiceOver reads this as Remember me, switch button, off, double-tap to toggle setting because SwiftUI generates an accessibility element:

  • The accessibility label defaults to the element’s label Remember me.

  • The accessibility value defaults to match the element’s value: 0, because the initial value of Remember me is false.

  • The accessibility trait defaults to Button because this element is a Toggle.

Accessibility API

Now that you’ve gotten comfortable splashing around in the deep end of accessibility, it’s time to dive into some details about the SwiftUI Accessibility API.

When a user of your app turns on an iOS assistive technology like VoiceOver, they’re actually interacting with an accessible user interface that iOS creates for your app. This accessible UI tells a VoiceOver user about the accessible elements of your UI — what they are and how to use them.

Every accessible UI element has these two attributes:

  • Frame: The element’s location and size in its CGRect structure.

  • Label: The default value of an element’s label — the label, name or text used to create the element. Apple’s programming guide provides guidelines for creating labels and hints.

Depending on its nature, a UI element might have one or more of these three attributes:

  • Traits: An element can have one or more traits, describing its type or state. The list of traits includes isButton, isModal, isSelected and updatesFrequently. SwiftUI views have default traits. For example, a Trait of Toggle is Button. You can add traits with accessibility(addTraits:) or remove them with accessibility(removeTraits:). VoiceOver reads out an element’s traits, so never include them in its label.

  • Value: A UI element has a value if its content can change. If the default value isn’t meaningful to VoiceOver users, use accessibilityValue(_:) to create a more useful value. For example, Slider values often need context to convey any meaning to your users.

  • Hint: This attribute is optional. If the user doesn’t do anything after VoiceOver reads a label, VoiceOver reads the hint. Use accessibilityHint(_:) to describe what happens if the user interacts with the element.

The accessible UI doesn’t change anything in your app’s visible UI, so you can add more information, in a different order, than what your other users see.

Note: There is one more accessibility attribute: identifier. This is only used in UITests. You would set an identifier for an element that doesn’t have an accessibility label, or if an element’s accessibility label is too long or ambiguous.

RGBullsEye

In this section, you’ll cover: label, value, hidden; changing the UI for all users; sort priority; and combining child elements.

Reducing jargon

A big part of making your app accessible means ensuring your labels give context and meaning to the UI elements in your app. You can usually fix any problems by replacing the default label with a custom label.

Open the starter RGBullsEye project. Customize it to run on your device: In the project window, select the iOS target. In the General tab, customize the bundle ID. Then, in the Signing & Capabilities tab, select a team.

Note: If you want to continue using your own RGBullsEye, copy these files from the starter project: SuccessView.swift, ColorExtension.swift (replace the one in your project), and grayText.colorset and wand.imageset from Assets.xcassets.

Now, connect your iOS device to your Mac and select it as the run destination. If necessary, adjust the iOS Deployment target in the target window, then build and run.

Start VoiceOver, then swipe up with two fingers to hear something like this:

R 3 question marks G 3 question marks B 3 question marks

R 127 grams 127 B 127 …

And quite a lot more that sounds pretty meaningless. Your first task is obvious.

The color value Text views need accessibility labels.

In ContentView, add a meaningful accessibility label to the target Text view:

// BevelText(text: "R ??? G ??? B ???", ...)
.accessibilityLabel("Target red, green, blue, values you must guess")

Note: I’ve included commented-out lines in code blocks to show you exactly where you need to add code. You can copy and paste the whole code block into your code without breaking anything. Don’t comment out the corresponding lines in your project.

You translate “???” to something that makes sense. The comma after “blue” isn’t grammatically correct, but it makes VoiceOver pause before saying “values”.

For the guess color, you’ll need a few computed variables to enable VoiceOver to say “Red”, “Green” and “Blue” instead of “R”, “G” (or “grams”) and “B”.

Now, in Model/RGB, replace var intString with the following code:

var rInt: Int {
  Int(red * 255.0)
}
var gInt: Int {
  Int(green * 255.0)
}
var bInt: Int {
  Int(blue * 255.0)
}

/// A String representing the integer values of an RGB instance.
var intString: String {
  "R \(rInt) G \(gInt) B \(bInt)"
}

var accString: String {
  "Red \(rInt), Green \(gInt), Blue \(bInt)."
}

You create computed variables for the red, green and blue integer values, then use these in the strings you display on screen (intString) and read out in the accessibility label (accString).

Now go back to ContentView and add this label to the guess Text view:

//BevelText(text: guess.intString, ...)
.accessibilityLabel("Your guess: " + guess.accString)

Build and run on your device to hear VoiceOver say exactly what you told it to say.

Next, listen to VoiceOver read out a slider. You must swipe right twice to hear all three components:

0. 50 per cent, adjustable, swipe up or down with one finger to adjust the value. 255

Note: The swipe up/down slider increments are too large to get a high score. To control the slider more accurately, tap a slider to select it, then double-tap and hold the slider thumb until you hear three rising tones. Now you can drag the slider in the usual way.

The issues here are:

  1. Users don’t need to hear “0” and “255”.
  2. The slider value is between 0 and 1, but the interface displays values between 0 and 255.

To solve these issues:

  1. Don’t read out “0” and “255”.
  2. Translate the slider value into an integer.

In ContentView, scroll down to struct ColorSlider and replace the contents of the HStack with the following code:

Text("0")
  .accessibilityHidden(true)
Slider(value: $value)
  .accentColor(trackColor)
  .accessibilityValue(
      String(describing: trackColor) +
      String(Int(value * 255)))
Text("255")
  .accessibilityHidden(true)

You hide the “0” and “255” Text views from VoiceOver and tell VoiceOver to read the slider color and integer slider value.

Note: Color conforms to the CustomStringConvertible protocol, so String(describing: trackColor) is “red”, “green” or “blue”.

Build and run to hear your improved Slider descriptions.

Now that each element makes more sense, you’ll organize them so VoiceOver reads out the more useful ones first.

Reordering navigation

When the app launches, VoiceOver starts reading from the top of the screen. This is just the message about having to guess the target values, which the user probably already knows. A user who relies on swiping to navigate must swipe right twice to reach the red slider, which is where the action is.

For someone playing this game, a more useful navigation order is to start with the sliders, then move to the guess string, and then to the button.

Here’s how you do this. In ContentView, replace the guess BevelText, sliders and button with the following:

BevelText(
  text: guess.intString,
  width: proxy.size.width * labelWidth,
  height: proxy.size.height * labelHeight)
  .accessibilityLabel("Your guess: " + guess.accString)
  .accessibilitySortPriority(2)
  ColorSlider(value: $guess.red, trackColor: .red)
    .accessibilitySortPriority(5)
  ColorSlider(value: $guess.green, trackColor: .green)
    .accessibilitySortPriority(4)
  ColorSlider(value: $guess.blue, trackColor: .blue)
    .accessibilitySortPriority(3)
Button("Hit Me!") {
  self.showScore = true
  self.game.check(guess: guess)
}
.accessibilitySortPriority(1)

You change the sort priority of these five elements. VoiceOver starts reading from the element with the highest sort value (5). The color Text views have the default sort priority 0.

This sort order lets the user immediately start moving the sliders. Then they listen to the full RGB values of their guess. And then they activate Hit Me!.

Build and run on your device. VoiceOver reads “Red 127”. Swiping right moves to “Green 127” then “Blue 127” then “Your guess: …” then “Hit me, button”.

Now what happens after the user activates Hit me?

Organizing information

Activate the Hit Me! button to show the alert. Swipe right twice to hear all three parts:

Alert, Your Score. 92. OK, Button.

There are two problems:

  1. You must swipe right to hear your score, which is the most important information, then again to select the OK button.
  2. The target Text view now shows the target’s color values, but there’s no way to get VoiceOver to read them.

It would be nice if you could combine the three parts of the alert into a single accessibility label, then add the target color values as an accessibility value or hint. Unfortunately, you can’t use accessibility modifiers with the SwiftUI Alert view.

Note: UIAlertController can set its view.accessibilityLabel and view.accessibilityValue, so one solution would be to use this instead of Alert. You’ll learn about integrating UIKit in “Complex Interfaces”.

Here’s a situation where you can change the UI to benefit all your users.

Modify the alert for all users.

In ContentView, in the body of ContentView, replace the first two arguments of Alert with these:

title: Text("You scored \(game.scoreRound)"),
message: Text("Target values: " + game.target.accString),

You include the score in title and present the target color values in message. You must use the accessible string so VoiceOver can read “Red”, “Green” and “Blue” instead of “R”, “G” and “B”.

OK, here’s a confession: The Figma design for RGBullsEye actually has a full-screen SuccessView modal sheet instead of the Alert. I didn’t implement it, back in “Diving Deeper into SwiftUI”, because it would have covered the guess and target color values, and the design didn’t include this information in the modal. But now that you’re including the target color values in the alert, you might as well do the same in SuccessView. And you can also show the user’s guess color values.

SuccessView is already in the starter project. It displays the target and guess color values on the backgrounds of those colors.

Note: Thanks to the nifty computed variable accessibleFontColor from Apple’s Scrumdinger app, the text colors are black or white, depending on the background colors. You’ll find this code in Model/ColorExtension.

Success view modal sheet
Success view modal sheet

So your next task is to replace the alert with SuccessView.

Refactor to use SuccessView modal sheet.

In ContentView, replace .alert(...) { ... } with the following:

.sheet(isPresented: $showScore) {
  SuccessView(
    game: $game,
    score: game.scoreRound,
    target: game.target,
    guess: $guess)
}

➤ Build and run on your device, then activate the Hit Me! button. Swipe right enough times to hear VoiceOver read something like this:

wand, Image. Congratulations! You scored 77 points on this color. Target: R 157 G 219 B 163. Guess: R 127 G127 B127. Try another one, Button.

The advantage of using a modal sheet instead of an Alert is you can now combine the Text views into a single readout. You can also attach accessibility modifiers to each component.

To make VoiceOver read the text elements as a single unit, the easiest solution is to combine them into a single accessibility element. Add this modifier to the first VStack in SuccessView:

.accessibilityElement(children: .combine)

All of the text elements are useful, so you just combine them to make VoiceOver read them without stopping after each one.

Exercise: In SuccessView, fix the remaining accessibility issues.

First, hide the “wand” image from VoiceOver. Remember to do this after the resizable() modifier.

Next, tell VoiceOver to read out the accessible strings for the target and guess colors.

The solution is in the challenge folder.

Note: Using Xcode 13, combining the VStack elements already omits the Image from VoiceOver. This might be a bug, so it’s safer to explicitly hide any elements you don’t want VoiceOver to read out.

Keep RGBullsEye open in Xcode. There are still a couple of things for you to see.

Adapting to user settings

Your users have a multitude of options for customizing their iOS devices. Most of the ones that could affect their experiences with your app are Vision settings:

Vision accessibility settings
Vision accessibility settings

For some of these options, your app can check if it’s enabled, then adapt itself. But for some options, there isn’t (yet?) an @Environment or UIAccessibility variable, so you might have to tweak your design to work for all your users.

To see how these accessibility settings affect your app, you could turn them on or off, in different combinations, directly in your device’s Settings. Oh, joy. Fortunately, Xcode provides three ways for you to quickly see the effect of many of these settings: in Accessibility Inspector, in Debug Preview and when the debugger is attached to your device. It’s much quicker and easier than going through the Settings app on your device, so you’re more likely to check, and therefore more likely to fix any problems sooner.

Build and run on your device. When it’s running, open Environment Overrides in the debug toolbar:

Debug toolbar: Environment Overrides
Debug toolbar: Environment Overrides

You can use this tool to check Dark Mode, Text size, Increase Contrast, Bold Text, On/Off Labels and Button Shapes. You must change the actual settings on your device to check Reduce Motion and Grayscale. The Smart Invert environment override inverts most colors, but it’s safer to use the actual setting on your device, just to be sure.

Dark screens are really popular and play an important role in eye health as well as with accessibility, so you definitely must ensure your apps look good in Dark Mode. You’ve already seen in “Diving Deeper Into SwiftUI” how to automatically adapt to Dark Mode by setting a Dark Appearance for your custom colors. You can also set light appearance and high-contrast versions. UIColor has system colors like systemBlue, but also semantic colors like label, systemFill, systemBackground and placeholderText that automatically adapt to Dark Mode.

You can also check Dark Mode and text size with the preview inspector.

In “Intro to Controls: Text & Image”, you switched from using a specific font size like font(.system(size: 30)) to using standard text styles like font(.headline) and font(.largeTitle). These respond to a user’s accessibility setting for Display & Text Size ▸ Larger Text, so your app automatically supports Dynamic Type.

The preview inspector makes it easy to check this, and Apple’s Typography documentation shows size and weight for standard text styles at different Dynamic Type sizes.

Use preview inspector to check your app supports dynamic type.
Use preview inspector to check your app supports dynamic type.

Now close RGBullsEye.

What can you do in your app to adapt to larger text sizes? One trick is to change an HStack to a VStack when the device uses accessibility text sizes. WWDC 2019 Session 412: Debugging in Xcode 11 provided this AdaptingStack:

struct AdaptingStack<Content>: View where Content: View {
  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }

  var content: () -> Content
  @Environment(\.sizeCategory) var sizeCategory

    var body: some View {
      switch sizeCategory {
      case .accessibilityLarge,
           .accessibilityExtraLarge,
           .accessibilityExtraExtraLarge,
           .accessibilityExtraExtraExtraLarge:
        return AnyView(VStack(
          content: self.content)
            .padding(.top, 10))
      default:
        return AnyView(
          HStack(alignment: .top,
            content: self.content))
      }
    }
}

This code uses the environment value sizeCategory. This is the font size you can set in Settings ▸ Accessibility ▸ Display & Text Size ▸ Larger Text. Some of the other environment values are:

  • Invert colors accessibilityInvertColors: The Smart Invert accessibility option reverses colors of the display and shouldn’t invert colors of images, media and some apps that use dark color styles. But it’s currently behaving more like Classic Invert, which reverses all colors. For elements you don’t want inverted, use accessibilityIgnoresInvertColors(true).

  • Increase contrast colorSchemeContrast: This accessibility option alters color and text styling, and adjusts dynamic type to the user’s preferred text size. In RGBullsEye, it darkens the slider track colors. If your app detects this option is enabled, it should ensure color contrast ratios are 7:1 or higher. Or consider designing your UI so color contrast ratios are 7:1 or higher for all users. You can check the contrast ratio of specific foreground and background colors at contrastchecker.com.

  • Reduce transparency accessibilityReduceTransparency: This accessibility option reduces the transparency and blurs on some backgrounds. If your app detects this option is enabled, it should ensure all alpha values are set to 1.0.

  • Reduce motion accessibilityReduceMotion: This accessibility option slows down, reduces or removes some animations, like the spinning Activity app awards. Check it on your device with Settings ▸ Accessibility ▸ Motion. Your app should run animations only if this option isn’t enabled:

@Environment(\.accessibilityReduceMotion) var reduceMotion
...
  if animated && !reduceMotion { /* animate at will! */ }
  • Bold Text legibilityWeight: This accessibility option displays all text in boldface characters, so large font text uses even more space.

  • On/Off Labels UIAccessibility.isOnOffSwitchLabelsEnabled: This accessibility option shows 1 or 0 in a toggle that is on or off. If this messes up your custom toggle, consider redesigning it. Or replace it with a standard toggle if this option is enabled.

  • Button Shapes UIAccessibility.buttonShapesEnabled: This accessibility option re-creates the outline around tappable elements from earlier iOS versions, before label-only buttons became the default. If this messes up your custom button, consider redesigning it for all users.

  • Grayscale UIAccessibility.isGrayscaleEnabled: This accessibility option turns on a color filter that shows only the relative luminance of colors. Check it on your device with Settings ▸ Accessibility ▸ Display & Text Size ▸ Color Filters ▸ Grayscale. Consider using higher contrast colors for elements so they’re still distinct in grayscale. You can check how specific foreground and background colors look in grayscale at contrastchecker.com.

Display & Text Size▸Color Filters▸Grayscale
Display & Text Size▸Color Filters▸Grayscale

Note: Color filters don’t show up in screenshots. I had to use another phone’s camera to take this photo of my phone.

  • Differentiate Without Color accessibilityDifferentiateWithoutColor: This accessibility option replaces UI items that rely on color to convey information with alternatives. You should always try to use shapes or additional text in addition to color.

There is also accessibilityEnabled: This is true if VoiceOver, Voice Control or Switch Control is enabled. Check UIAccessibility.isVoiceOverRunning or UIAccessibility.isSwitchControlRunning. There’s no way to check for Voice Control, unless the user has not enabled VoiceOver or Switch Control:

accessibilityEnabled == true
  && !UIAccessibility.isVoiceOverRunning
  && !UIAccessibility.isSwitchControlRunning

Note: This seems to be a reasonable test for VoiceControl, as VoiceOver and VoiceControl don’t work well together, and Switch Control users like Ian Mackay in this Tecla article might prefer to use Voice Control in quiet environments.

There are many other UIAccessibility properties in Apple’s documentation, listed under Capabilities. Check these values whenever you need them, to ensure you’re getting their current status.

You’ll see a few of these in action in the next two apps.

Kuchi

In this section, you’ll cover: keyboard input and changing the UI for VoiceOver users.

Note: If you want to continue using your Kuchi project, copy discardCard(to:) from the starter project’s Shared/Learn/CardView.

Open the starter Kuchi project and customize the bundle ID and team. Then connect your iOS device to your Mac and select the Kuchi (iOS) run target and your device. If necessary, adjust the iOS Deployment target in the target window, then build and run.

RegisterView

The first time Kuchi launches, it displays RegisterView.

Register view
Register view

Start VoiceOver, then swipe up with two fingers to hear something like this:

Welcome to, Kuchi. Type your name, ellipsis, Text field. 0. Remember me, Switch button, off. OK, dimmed; Button. welcome-background, Image, Diagram, Screenshot.

The words welcome-background, Image, Diagram, Screenshot don’t provide any useful information to a VoiceOver user, so this is how you stop VoiceOver from saying them.

In Shared/Welcome/Components/WelcomeBackgroundImage, hide the Image from VoiceOver:

//Image("welcome-background")
//  .resizable()
  .accessibilityHidden(true)

Reminder: I include commented-out lines in code blocks to show you exactly where you need to add code. You can copy and paste the whole code block into your code without breaking anything. Don’t comment out the corresponding lines in your project.

You add accessibilityHidden(_:) after resizable() because resizable() is an Image modifier and accessibilityHidden(_:) doesn’t return an Image.

Another unnecessary element is in the Welcome to Kuchi label: Sometimes VoiceOver reads the icon as “Logo other”.

Welcome to Kuchi label
Welcome to Kuchi label

So go to LogoImage to hide this image:

//Image(systemName: "table")
//  .resizable()
  .accessibilityHidden(true)

What’s next? In Shared/Welcome/RegisterView, in the text field’s placeholder text, VoiceOver reads “…” as “ellipsis”.

Text field and character counter
Text field and character counter

This provides no useful information to anyone, so just delete it for all users.

TextField("Type your name", text: $userManager.profile.name)

Next, you’ll add some descriptions.

VoiceOver reads out the number “0”, with no context. This Text view keeps count of the number of characters the user types in the TextField. The OK button is disabled while this number is less than 3. This isn’t obvious to non-VoiceOver users, but they can see, while they’re typing, when the OK button becomes enabled.

If a VoiceOver user can’t get past your registration page, you’ve lost a user! Help them out…

In the first HStack, add these accessibility modifiers to the Text view:

//Text("\(userManager.profile.name.count)")
  .accessibilityLabel("name has \(userManager.profile.name.count) letters")
  .accessibilityHint("name needs 3 or more letters to enable OK button")

You provide a more descriptive label for VoiceOver to read. If the user doesn’t immediately move on to another interface element, VoiceOver reads the hint.

The accessibility of the Remember me toggle is fine.

Remember-me toggle and OK button
Remember-me toggle and OK button

By default, VoiceOver reads its on/off state and tells the user what to do with it. Its label is a standard login option, so doesn’t need more explanation.

But the OK button needs work. VoiceOver should tell users what happens when they tap it. Tapping a button is a command for the app to act, and its accessibility label should tell the user what this command is. In this case, tapping OK “registers user”.

Now, add an accessibility label to the OK Button:

//Button(action: self.registerUser) {
//...
//}
.accessibilityLabel("OK registers user")
//.bordered()

Adding an accessibility label means VoiceOver won’t read the Text string, so you include “OK” in the accessibility label.

Just in case the user skipped listening to the character counter hint, also add it to the OK Button:

.accessibilityHint("name needs 3 or more letters to enable this button")

Finally, VoiceOver says the OK button is “dimmed”, but “disabled” would be more informative.

➤ Add an accessibility value to the OK Button:

.accessibilityValue(
  userManager.isUserNameValid() ? "enabled" : "disabled")

You’ve added a total of three accessibility modifiers to the OK Button.

Build and run the app on your device, then swipe up with two fingers to listen to VoiceOver read something like this:

Welcome to Kuchi. Type your name, Text field. Name has 0 letters. Remember me, Switch button, off. OK registers user, disabled, dimmed; Button, name needs 3 or more letters to enable OK button.

That’s better. What’s next?

Tap the text field. VoiceOver says something like this:

Type your name, Text field. Double-tap to edit

That’s clear enough. Go ahead and double-tap, to hear this:

Text field, is editing. Type your name, Character mode, insertion point at start. Use the rotor to access misspelled words

The keyboard appears:

Keyboard for text field input
Keyboard for text field input

To type your name, activate each key to enter it in the text field.

When you tap a key, VoiceOver repeats it and uses the NATO phonetic alphabet (Alpha, Bravo, Charlie, Delta, Echo, etc.) to made sure the user knows which letter they selected.

Note: Blind users would use Braille Screen Input.

Braille Screen Input
Braille Screen Input

Activate the keyboard’s done key to dismiss it, then tap OK to hear VoiceOver say:

OK registers user, enabled, Button, name needs 3 or more letters to enable this button.

And activate this button to navigate to WelcomeView.

WelcomeView

Welcome view
Welcome view

Tap the first line, then swipe right twice to hear something like this:

Hi, Audrey. Welcome to, Kuchi. Start, button.

This is satisfactory, so you don’t need to do anything here.

Activate the Start button to progress to the Learn tab.

Learn tab

Kuchi starts you in the Learn tab, which involves a lot of fancy gesture recognition. Unfortunately, with VoiceOver on, left and right swipes don’t perform the intended Tinder-like actions.

One solution is to provide buttons to implement the swipe-left and swipe-right gestures.

Note: This chapter’s starter Kuchi project encapsulates the DragGesture onEnded action in discardCard(to:).

In the body of Learn/CardView, embed the ZStack in a VStack, then add this conditional button stack just above the closing } of the VStack, below all the modifiers of the ZStack:

if UIAccessibility.isVoiceOverRunning {
  HStack {
    Button { discardCard(to: .left) } label: {
      Image(systemName: "checkmark.circle.fill")
        .foregroundColor(.green)
        .accessibilityLabel("Remembered")
    }
    Spacer()
    Button { } label: {
      Image(systemName: "questionmark.circle.fill")
        .accessibilityLabel("Read question")
    }
    Spacer()
    Button { discardCard(to: .right) } label: {
      Image(systemName: "xmark.circle.fill")
        .foregroundColor(.red)
        .accessibilityLabel("Forgot")
    }
  }
  .padding(45)
  .font(.largeTitle)
  .offset(self.offset)
} else {
  EmptyView()
}

If VoiceOver is running, you display two buttons to manually invoke the DragGesture action, and a middle button to focus on the question text. You provide accessibility labels that tell the user what the buttons do.

To implement the middle button’s action, add this property to CardView:

@AccessibilityFocusState var isQuestionFocused: Bool

Then fill in the action for the middle Button:

Button { isQuestionFocused = true }

And when VoiceOver is running, you don’t want to display the swipe left/right instructions.

In Learn/LearnView, make the Text view conditional:

if !UIAccessibility.isVoiceOverRunning {
  Text("Swipe left if you remembered"
       + "\nSwipe right if you didn’t")
    .font(.headline)
} else {
  EmptyView()
}

Build and run on your device.

Buttons for VoiceOver users
Buttons for VoiceOver users

The Settings/Accessibility/VoiceOver/Speech/Detect Languages option means VoiceOver reads out the card’s text in Japanese! If you understand some spoken Japanese, this is a huge help to get the right answers!

Note: Don’t tap the card itself! All the phrases are layered in the stack and tapping the card selects one more or less at random.

Try out the buttons. They’re a little clumsy, but they work. You need to activate the middle button at least twice to hear the question and its English translation. Tapping on the English text reads it out, but the tap target area is too small to be accessible.

You don’t have to fix absolutely everything for VoiceOver users. They can fix it themselves, in their Settings.

Per-App Settings

New in iOS 15, users can specify accessibility settings on a per-app basis, so they can increase font size just for Kuchi.

Open Settings ▸ Accessibility ▸ Per-App Settings, select Add App and scroll down to select Kuchi.

Per-App Settings: Select Kuchi.
Per-App Settings: Select Kuchi.

Open the detail view for Kuchi, select Larger Text, then increase font size by a couple of steps:

Set Larger Text for Kuchi.
Set Larger Text for Kuchi.

Build and run again to see Kuchi now uses larger font sizes:

Larger Text in Kuchi
Larger Text in Kuchi

And now it’s easier to tap the English translation text.

Close Kuchi.

MountainAirport

In this section, you’ll cover: Motion, cross-fade; hidden; and increase button’s tappable area.

The third starter project is a peek into the future. MountainAirport is the sample app for the next two sections of this book, where you’ll learn to draw and animate custom graphics in SwiftUI.

Open, build and run MountainAirport on your device with VoiceOver on. Swipe up with two fingers to hear:

Mountain Airport, Heading. welcome-background, image. Flight Status … button, sign. Search Flights … button, sign. Your Awards … button, sign. Flight Timeline, Flight Timeline, button, sign.

By now, you know you should hide the background image from VoiceOver. It isn’t just unnecessary information. It actually gets in the way when you want to tap the Flight Status or Search Flights button. You have to tap the lower part of the button, below where it overlaps the background image.

In WelcomeView, add this accessibility modifier to the Image:

.accessibilityHidden(true)

Remember to put it after resizable().

Build and run on your device again. Now you’re able to tap anywhere in Flight Status or Search Flights to select it.

Activate Flight Status.

Flight Status

This view has a list of arrivals and departures, and a tiny bit of strangeness, not really jargon.

To start, tap a flight to listen to VoiceOver.

VoiceOver reads out all three lines at once, and it doesn’t read out the icons. Great! Just one small issue: It reads out the “middle dot” punctuation between the airport and the gate number.

Fix this in FlightStatusBoard/FlightRow: In the last HStack, replace it with a hyphen:

Text("-")

A hyphen is more commonly used than middle-dot, so VoiceOver doesn’t read it out. It just treats it like a comma.

One more issue: There’s a tab bar at the bottom. If you didn’t know about it and just kept swiping right, VoiceOver wouldn’t read it until you’d gone through every item in the list!

You might think sort priority will help you here, but there’s nowhere to attach it.

About all you can do is provide a hint on the Hide Past Toggle in FlightStatusBoard, in navigationBarItems, down at the bottom of body:

//Toggle(...)
.accessibilityHint("Use the tab bar to show only arrivals or departures.")

Build and run again and check how this sounds now.

FlightDetails: Animations

Next, activate a list item to see its detail view, then activate Show Terminal Map.

Terminal map animation
Terminal map animation

When you create this animation in “Animations & View Transitions”, consider checking the environment value accessibilityReduceMotion. If the user’s device has this setting, your app should replace the map animation with a drawing and stop the airplane icons from swinging around.

Now, return to the welcome view: Activate the Back button or do a Z gesture with two fingers.

FlightSearch: View Transitions

There’s no accessibility setting to stop your animations, but VoiceOver users can control view transitions.

Activate the Search Flights button. Select the Departures filter then navigate down the list to a canceled flight and activate it.

Flight Details of canceled flight
Flight Details of canceled flight

This view has several view transitions, starting with the detail view itself, which is a modal view.

Activate each button to see its transition:

  • Rebook Flight displays an alert.
  • Check In for Flight displays an action sheet.
  • On-Time History displays a popover.

Close the On-Time History popover with a two-finger Z gesture, then Close the detail view.

Now open Settings ▸ Accessibility ▸ Motion and turn on Reduce Motion and Prefer Cross-Fade Transitions.

Return to MountainAirport. Select the canceled flight again and activate each button in turn.

The FlightSearchDetails modal sheet and On-Time History popover now cross-fade instead of sliding up. So you don’t have to do anything special to your app, as long as you use standard SwiftUI elements.

The alert and action sheet transitions don’t change, but hopefully don’t disturb much, as they’re much smaller views.

There’s one last issue with this view: The buttons are too small.

Here are two options:

  • Increase each button’s frame height: In SearchFlights/FlightSearchDetails, modify each button with .frame(height: 44.0)
  • Rewrite each button so its label is a trailing closure, then add padding to the Text view.

Also, it’s not obvious they’re buttons, but that’s an easy one. Just open Settings ▸ Accessibility ▸ Display & Text Size and turn on Button Shapes. Then return to MountainAirport and reopen the flight details view.

Button Shapes in action
Button Shapes in action

This setting re-creates the outline around tappable elements from earlier iOS versions, before label-only buttons became the default. Although against this background, the outline is barely visible, at least the outlines push the buttons further apart.

That’s enough fixing. Now for the final test.

Truly testing your app’s accessibility

Once again, return to the welcome view of MountainAirport.

To truly test whether a VoiceOver user can use your app, turn on the screen curtain: Triple-tap with three fingers.

Note: If you have the zoom accessibility feature enabled, you’ll need to quadruple-tap with three fingers.

This turns off the display while keeping the screen contents active. VoiceOver users can use this for privacy.

Now, navigate to the Flight Status list, turn on Hide Past, list only departures, then find the next (not canceled) departure that’s more than one hour away.

Note: Sometimes swipe-right/left doesn’t work. Swipe up with two fingers to get VoiceOver to read continuously from the top.

Did you succeed? Good for you!

Lastly, Triple-tap with three fingers to show the display, then triple-click the side button to turn off VoiceOver.

Congratulations, you’re well on your way to becoming an accessibility ninja!

Key points

  • Use VoiceOver on a device to hear how your app really sounds.
  • Accessibility is built into SwiftUI. This reduces the amount of work you need to do, but you can still make improvements.
  • Use semantic font sizes to support Dynamic Type. Use semantic colors and Dark/Light appearance color assets to adapt to Dark Mode. Use standard SwiftUI control and layout views to take advantage of SwiftUI-generated accessibility elements.
  • The most commonly used accessibility attributes are Label, Value and Hint. Use Hidden to hide UI elements from VoiceOver. Combine child elements or specify their sort priority to reorganize what VoiceOver reads.
  • Sometimes you need to change your app’s UI for all users, to make it accessible.
  • Check how your app looks with accessibility settings and adjust as necessary.
  • Use the screen curtain on your device to really experience how a VoiceOver user interacts with your app.

Where to go from here?

There are lots of resources to help you make your apps accessible. Here are just a few:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.