Chapters

Hide chapters

SwiftUI by Tutorials

Fifth Edition · iOS 16, macOS 13 · Swift 5.8 · Xcode 14.2

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 Appearance 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 app 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, scroll down, then use a shortcut to start VoiceOver. VoiceOver says it’s on, then says “Settings”. Tap the list to get out of 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.

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. ;]

To get back to the home screen, drag up from the bottom edge of the screen until you feel a vibration or hear two rising tones. If you hear three rising tones, you’ll get the app switcher.

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.

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.

SwiftUI: Accessibility by Default

Open your Kuchi app or open the project in the starter folder, then open Shared/Welcome/RegisterView and locate the “Remember me” Toggle:

Toggle(isOn: $userManager.settings.rememberUser) {
  Text("Remember me")
    .font(.subheadline)
    .foregroundColor(.gray)
}

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

Xcode has an Accessibility inspector, to the left of the Attributes inspector. Under the right conditions, it shows you an element’s accessibility settings, including what VoiceOver will say.

The conditions are:

  • A preview of the view appears in the canvas.
  • The canvas is in Selectable mode.
  • The element is a standard SwiftUI view.
  • You’re holding your breath and have crossed all your fingers and toes ;]. Sometimes, it helps to click another inspector then re-select the Accessibility inspector.

To try this out in RegisterView, refresh the preview and switch to Selectable mode. Select the toggle (not just its label) in the preview or click Toggle in the code editor, then show the Accessibility inspector:

Toggle: Accessibility inspector
Toggle: Accessibility inspector

  • Label defaults to the element’s label Remember me.
  • Value defaults to match the element’s value: 0, because the initial value of Remember me is false.
  • Traits default to .isButton because this element is a Toggle.
  • Actions default to activate — again, because this is a Toggle.

So VoiceOver reads out the label, the trait (with more detail than “button”), the value and activation instructions. You’ll soon see how this information changes instantly when you make changes to the element.

You can close Kuchi for now. You’ll be working with RGBullsEye for a while.

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 your RGBullsEye project or open the project in the starter folder. If necessary, customize it to run on your device: In the project window, select the iOS target. Then, in the Signing & Capabilities tab, select a team and customize the bundle ID. If necessary, adjust the project’s iOS Deployment Target and the target’s Minimum Deployment.

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, select it as the run destination, 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”.

In the Accessibility inspector, your change appears immediately:

Target label changes immediately.
Target label changes immediately.

Note: Xcode might have difficulty showing your phone in preview — switch your run destination back to what you had before.

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 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.
func intString() -> String {
  "R \(rInt) G \(gInt) B \(bInt)"
}

func 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())

Again, the Accessibility inspector shows your new label immediately:

Guess label changes immediately.
Guess label changes immediately.

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”.

In ContentView, select the red slider to see “0” and “255” are hidden, and the slider’s value is “red 127”:

Improved Slider description
Improved Slider description

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)  // add this line
  ColorSlider(value: $guess.red, trackColor: .red)
    .accessibilitySortPriority(5)  // add this line
  ColorSlider(value: $guess.green, trackColor: .green)
    .accessibilitySortPriority(4)  // add this line
  ColorSlider(value: $guess.blue, trackColor: .blue)
    .accessibilitySortPriority(3)  // add this line
Button("Hit Me!") {
  self.showScore = true
  self.game.check(guess: guess)
}
.accessibilitySortPriority(1)  // add this line

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 an older version of 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.

In SuccessView, refresh the preview, switch to Selectable mode, show the Accessibility inspector, then select the first VStack:

SuccessView elements
SuccessView elements

Now, 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.

See what’s happened in the Accessibility inspector:

Improved SuccessView description
Improved SuccessView description

Magical!

Exercise: In SuccessView, fix the remaining accessibility issues.

First, hide the “wand” image from VoiceOver. Do this after the resizable() modifier because resizable() is an Image modifier and accessibilityHidden(_:) doesn’t return an Image.

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 14, 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.

Trying Out Device Settings in Xcode

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 the 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/Light Appearance, Dynamic Type 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 Appearance. You’ve already seen in “Diving Deeper Into SwiftUI” how to automatically adapt to dark appearance 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 Appearance.

You can also check Dark/Light Appearance and Dynamic Type text size with the preview canvas Device Settings or Variants.

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.

Apple’s Typography documentation shows size and weight for standard text styles at different Dynamic Type sizes, and the preview Variants option makes it easy to check:

Use preview Variants mode to check your app supports dynamic type.
Use preview Variants mode to check your app supports dynamic type.

The largest two sizes need some tweaking.

Now close RGBullsEye.

Checking Environment Variables

What can you do in your app to adapt to larger text sizes? New in iOS 16 is AnyLayout, which makes it easy to switch between layout containers — from HStack to VStack — depending on the Dynamic Type setting:

struct ContentView: View {
  @Environment(\.dynamicTypeSize) var dynamicTypeSize

  var body: some View {
    let layout = dynamicTypeSize <= .medium ?
      AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())

    layout {
      Text("First label")
      Text("Second label")
    }
  }
}

The environment value dynamicTypeSize is the font size users can set in their device’s Settings ▸ Accessibility ▸ Display & Text Size ▸ Larger Text. Some 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! */ }

You’ll get to use this soon!

  • 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 fixing dark appearance.

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. Selected, OK, dimmed; Button. welcome-background, Logo Other, Outdoor (or Night Sky), Screenshot, Tachometer.

Hiding Unnecessary Elements

The words after “Button” — from welcome-background to Tachometer don’t provide any useful information to a VoiceOver user, so go ahead and 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)

Adding Descriptions

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”. So add an accessibility label to the OK Button:

//Button(action: 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. Selected. OK registers user, disabled, dimmed; Button. Name needs 3 or more letters to enable OK button.

That’s better, although VoiceOver might not read the hint unless you do nothing for quite a while. 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 reads it and, if you pause, uses the NATO phonetic alphabet (Alpha, Bravo, Charlie, Delta, Echo, etc.) to made sure you know which letter you tapped. When you activate a key, VoiceOver repeats it.

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.

Fixing Dark Appearance

If your phone is set to dark appearance, you’ve noticed a big problem: The name field is white, and you can’t see what you’re typing. There’s actually an easy fix.

TextField has a modifier .bordered(). Jump to its definition in BorderedViewModifier. BorderedViewModifier specifies the background color:

.background(Color.white)

Change this to the semantic color .background:

.background(.background)

Go back to RegisterView and use the canvas Device Settings to see how it now looks in Dark Appearance:

Dark appearance fixed
Dark appearance fixed

Note: Sometimes, you have to run the app in a simulator or on your device, then use Environment Overrides to see the real dark appearance.

Fun Fact

If you continue on to the Japanese phrases, you’ll get a delightful surprise: The Settings/Accessibility/VoiceOver/Speech/Detect Languages option means VoiceOver reads out the text in Japanese. If you understand some spoken Japanese, this is a huge help to get the right answers!

Close Kuchi.

MountainAirport

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

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. Aeroplane Mode, Image. Flight Status. Departure and arrival information, link-pattern, image. Aeroplane, Gift Card, Logo Other. Search, Image. Search. Search Flights. Search Upcoming Flights, link-pattern, image. Logo Other, Underwater. Your Awards, Favorite, Image, Star. Earn rewards for your airport interactions, link-pattern, image. Logo Other, Sign.

Not Animating

Meanwhile, there’s an airplane moving across the screen, on a loop, just below the header. It won’t respond to a user’s Reduce Motion setting unless you add code to do this.

In WelcomeView, add this environment variable to WelcomeView:

@Environment(\.accessibilityReduceMotion) var reduceMotion

Now, scroll down to body and replace WelcomeAnimation() and its modifiers with this conditional:

if !reduceMotion {
  WelcomeAnimation()
    .frame(height: 40)
    .padding()
} else {
  Image(systemName: "airplane")
    .resizable()
    .accessibilityHidden(true)
    .aspectRatio(1.0, contentMode: .fit)
    .frame(height: 40)
    .padding()
}

If the user’s device has Reduce Motion turned on, you show a still image of the airplane symbol — using the same modifiers as in WelcomeAnimation — but hide its label from VoiceOver.

On your device, go to Settings ▸ Accessibility ▸ Motion and turn on Reduce Motion:

Reduce Motion options
Reduce Motion options

Now, build and run on your device to see an unanimated airplane:

Unanimated airplane
Unanimated airplane

That was easy! Now to streamline what VoiceOver says…

Hiding & Combining Labels

By now, you know you should hide the background image — “link-pattern, image” — from VoiceOver.

In WelcomeButtonView, add this accessibility modifier to the Image:

// .background(
  // Image("link-pattern")
    // .resizable()
    .accessibilityHidden(true)

Remember to put it after resizable().

Also hide the label images by adding the same accessibility modifier to the first item in the HStack:

// Image(systemName: imageName)
  // .resizable()
  .accessibilityHidden(true)

Further improve your VoiceOver experience by adding these modifiers to the title and subTitle stack:

// VStack(alignment: .leading) {
  // Text(title)
    // .font(.title2)
  // Text(subTitle)
    // .font(.subheadline)
// }
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)

You combine the title and subTitle texts into a single label and ensure the user knows this is a button.

Build and run on your device again. You can navigate the three buttons much faster now, and it’s crystal clear that they’re buttons.

Next, activate Flight Status.

Flight Status: A Tweak for All Users

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 two small issues: It reads out the relative time interval as something like “open bracket, 3 hours ago, right paren”, and also reads out the “middle dot” punctuation between the airport and the gate number.

Fix both issues in FlightStatusBoard/FlightRow

In the VStack, change the first HStack to remove the parentheses:

HStack {
  Text(flight.flightStatus)
  Text(flight.localTime, formatter: timeFormatter)
  // Text("(") +
  Text(flight.localTime, formatter: relativeTimeFormatter)
  //+ Text(")")
}.foregroundColor(flight.statusColor)

Note: I moved the + after relativeTimeFormatter) to the next line.

Fix the second issue in the last HStack: Replace the middle dot 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 list only arrivals or departures.")

Build and run again and check how this sounds now. Much cleaner!

FlightDetails: Animations

MountainAirport is the sample app for the drawing and animation chapters so, of course, it has more animations than the airplane moving across the welcome view. Activate a Flight Status 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”, 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 an app’s animations, but users can control view transitions.

To see the view transitions in MountainAirport, go to Settings ▸ Accessibility ▸ Motion on your device and check that Prefer Cross-Fade Transitions is off.

Prefer cross-fade transitions is off.
Prefer cross-fade transitions is off.

Build and run on your device, then 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 a small 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:

Prefer cross-fade transitions is on.
Prefer cross-fade transitions is on.

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 Rebook Flight alert and Check in for Flight action sheet transitions don’t change, but hopefully don’t disturb much, as they’re much smaller views.

Making Buttons Larger

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 Appearance. 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.