It is my firm belief that every iOS app should be usable to everyone, and putting in the work to make your app function well no matter who is using it says a lot about the kind of developer you are.
Watch the video here, or read the article below
iOS has all sorts of assistive technologies, but at the core is VoiceOver – Apple’s screen reader system. This obviously primarily benefits blind and other vision-impaired users, but it does so much more: VoiceOver tells you how the system interprets your UI, in terms of what’s visible and also how it’s ordered.
For example, it’s really useful to find parts of your UI that are visually hidden (you can’t see them on screen), but still visible to the assistive technologies – UI you thought was off screen is often completely navigable using assistive technologies, which makes them a nightmare to use.
And so, testing your app thoroughly with VoiceOver enabled is one of the most important steps towards building a great app.
If you haven’t used VoiceOver before, I suggest you enable it now – you’ll need to use a physical device, because it doesn’t work in the simulator. You can find it under Settings > Accessibility > VoiceOver. Once enabled you’ll see a VoiceOver Practice button appear, but here’s the least you need to know:
When VoiceOver works, you’ll hear the immediate title of something first, then a description of what kind of control is selected (a button, an image etc), then after a short pause any further text the app developer added to give context to what the object does – known as the hint.
Last, but not least, it’s worth adding that most VoiceOver users set the voice pitch extremely fast – significantly faster than I can follow. Remember, for many people it’s the primary way of using your app, so they rely on the information being fast and easy to find.
If you get stuck: Listen, I’ve used VoiceOver a lot and feed comfortable with it, but I remember when I first tried it out I found it hard to use and struggled to turn it off. If you hit a similar problem, relax: tell Siri “turn off VoiceOver” and you’re done.
Once you activate VoiceOver and try navigating around, chances are the first problem you hit lies on the Home view: our little horizontal grid of projects navigates really strangely, with “10 items” being read in the first project, then “10 items” being read in the second, then both titles, then both progress views.
The easy fix here is to use a modifier called .accessibilityElement()
modifier, which allows us to combine the elements into one for assistive technologies. Add this to the projects’ VStack
, below the shadow:
.accessibilityElement(children: .combine)
If you try that now, you’ll see VoiceOver does a pretty good job already – it will say “10 items, Project 3, 50%” or similar. That’s quite good, but in this case the vertical reading isn’t ideal: it’s clear visually that the project name is the most important thing on that card, but that doesn’t translate to VoiceOver.
A better idea is to make a group that ignores the children, and provide your own label, like this:
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(project.projectTitle), \(project.projectItems.count) items, \(project.completionAmount * 100, specifier: "%g")% complete.")
You’ll need to modify your Localizable.strings files too. Add this for English:
"%@, %lld items, %g%% complete." = "%@, %lld items, %g%% complete.";
And this for Hungarian:
"%@, %lld items, %g%% complete." = "%@, %lld tétel, %g%% kész.";
This ensures users get the correct language for VoiceOver, as well as on-screen.
Over in ProjectsView
you’ll see a similar problem exists with the project headers, but this can be fixed by combining children – add this to the end of the HStack
in ProjectHeaderView
:
.accessibilityElement(children: .combine)
That will automatically read the progress view as a percentage, which will work great. Even better, it will make the editing button work across the whole group.
Almost all of our app uses basic iOS controls such as buttons, text, and navigation views, but one part does not and as a result it’s suffering. I encourage you to explore the app and try to spot the problem yourself – it’s good practice!
I’ll give you a moment, because I know it’s easy to read ahead by accident.
Still here? Okay, the problem is in our project editing view: we have lots of colors inside one row, and this becomes completely unavailable to iOS navigation – it relies exclusively on taps, and iOS won’t even recognize that our color squares should be selectable.
To fix this, we need to add some extra descriptive information to our UI: we need to make it clear that these are buttons and should be treated as such. Furthermore, we also need to make it clear which color is selected – we don’t want VoiceOver to read out the check mark image.
So, first open EditProjectView
and add this after the onTapGesture()
modifier so that SwiftUI doesn’t try to read inside the ZStack
:
.accessibilityElement(children: .ignore)
Next, we want to add either one or two traits - we always want this to be a button, but only the currently selected button should have the extra isSelected
trait. Add this modifier below:
.accessibilityAddTraits(
item == color
? [.isButton, .isSelected]
: .isButton
)
Of course, now that VoiceOver is reading out the names of each of our colors we need to localize the colors by adding another modifier:
.accessibilityLabel(LocalizedStringKey(item))
That means the string that gets displayed will be localized, while the underlying piece of data will remain in English.
With that in place, we can add the extra entries to Localizable.strings. Here’s the English:
"Select a project color" = "Select a project color";
"Pink" = "Pink";
"Purple" = "Purple";
"Red" = "Red";
"Orange" = "Orange";
"Gold" = "Gold";
"Green" = "Green";
"Teal" = "Teal";
"Light Blue" = "Light Blue";
"Dark Blue" = "Dark Blue";
"Midnight" = "Midnight";
"Dark Gray" = "Dark Gray";
"Gray" = "Gray";
And here’s the Hungarian:
"Select a color" = "Válassza ki a színt";
"Pink" = "Rózsaszín";
"Purple" = "Lila";
"Red" = "Piros";
"Orange" = "Narancs sárga";
"Gold" = "Arany";
"Green" = "Zöld";
"Teal" = "Zöldeskék";
"Light Blue" = "Világos kék";
"Dark Blue" = "Sötét kék";
"Midnight" = "Éjfél";
"Dark Gray" = "Sötét szürke";
"Gray" = "Szürke";
And that’s another piece of our app ready for everyone to use!
Now that we’ve fixed the navigation problems, let’s look at the labeling problems. There are three in particular I want to address:
ProjectsView
, there’s no audio indicator when an item is high priority or completed.ProjectsView
just says “Add” – it’s not clear what it actually does.None of those are terribly hard to fix, so let’s just dive in and see where we get to.
First, highlighting an award name should really read the award title, whether it’s locked or unlocked, and ideally also the description after a pause. We can do that with a label and a hint, and if users want the hint immediately they can just trigger the button’s action to show our existing alert.
Add these two below the award buttons in AwardsView
:
.accessibilityLabel(
Text(dataController.hasEarned(award: award) ? "Unlocked: \(award.name)" : "Locked")
)
.accessibilityHint(Text(award.description))
We already have those two strings localized, so we don’t need any further work here.
Second, we need to make ItemRowView
describe whether an item is completed or high priority as part of its item label, which we can do with another computed property:
var label: Text {
if item.completed {
return Text("\(item.itemTitle), completed.")
} else if item.priority == 3 {
return Text("\(item.itemTitle), high priority.")
} else {
return Text(item.itemTitle)
}
}
We can then attach it to our existing NavigationLink
like this:
NavigationLink(destination: EditItemView(item: item)) {
Label {
Text(item.itemTitle)
} icon: {
icon
}
.accessibilityLabel(label)
}
We don’t have those strings localized, so please add this to Localizable.strings for English:
"%@, completed." = "%@, completed.";
"%@, high priority." = "%@, high priority.";
The “%@“ part is the name of the award, which will be replaced at runtime.
You’ll also need to add this to the Hungarian strings:
"%@, completed." = "%@, elkészült.";
"%@, high priority." = "%@, elsőbbségi.";
Finally, we need to look at that + button in ProjectsView
. This is good enough for sighted users because it’s not attached to a project, but when mixed up amongst many other buttons it’s going to be confusing for VoiceOver.
What’s particularly confusing here is that VoiceOver reads “Add”, which isn’t actually used in our UI – we have a label with the text “Add Project” and the image “plus”, and the string “Add” doesn’t appear anywhere.
This is SwiftUI trying to be helpful: if that button was placed directly into a view, rather than in a toolbar, then it would be read as “Add Project”, exactly as you would expect. However, SwiftUI spots that it’s in a toolbar and takes matters into its own hand: it realizes the + button here must mean “add something new”, so it uses its own title.
Worse – at least as far as I can tell – it won’t honor any custom label or even a hint. Having spoken to the team at Apple it seems clear this is a bug, and perhaps it will even be resolved by the time you follow along in which case this problem won’t even exist for you. But right now it does exist for me, so until I find a better solution we’re just going to adapt our UI a little by replacing the button label with a condition:
if UIAccessibility.isVoiceOverRunning {
Text("Add Project")
} else {
Label("Add Project", systemImage: "plus")
}
If you find a better solution to this, or if you find a newer SwiftUI release solves this problem, let me know and I’ll update this article.
Anyway, we’ve covered quite a lot: the basics of using VoiceOver, grouping data, ignoring a view’s children, adding custom labels and hints, and even building custom UI to deliver a comparable experience when needed.
The end result is a user interface that anyone can navigate using assistive technology, that VoiceOver can read well, and that ultimately makes our app available to everyone.
Here's just a sample of the other tutorials, with each one coming as an article to read and as a 4K Ultra HD video.
Find out more and subscribe here
In this article we’re going to look at the map()
function, which transforms one thing into another thing. Along the way we’ll also be exploring some core concepts of functional programming, so if you read no other articles in this course at least read this one!
It’s not hard to make a basic property wrapper, but if you want one that automatically updates the body
property like @State
you need to do some extra work. In this article I’ll show you exactly how it’s done, as we build a property wrapper capable of reading and writing documents from our app’s container.
Getting ready for a job interview is tough work, so I’ve prepared a whole bunch of common questions and answers to help give you a jump start. But before you get into them, let me explain the plan in more detail…
UPDATED: While I’m sure you’re keen to get started programming immediately, please give me a few minutes to outline the goals of this course and explain why it’s different from other courses I’ve written.
SwiftUI gives us a modifier to make simple shadows, but if you want something more advanced such as inner shadows or glows, you need to do extra work. In this article I’ll show you how to get both those effects and more in a customizable, flexible way.
Phantom types are a powerful way to give the Swift compiler extra information about our code so that it can stop us from making mistakes. In this article I’m going to explain how they work and why you’d want them, as well as providing lots of hands-on examples you can try.
Generics are one of the most powerful features of Swift, allowing us to write code once and reuse it in many ways. In this article we’ll explore how they work, why adding constraints actually helps us write more code, and how generics help solve one of the biggest problems in Swift.
In this article I’m going to walk you through building a WaveView
with SwiftUI, allowing us to create beautiful waveform-like effects to bring your user interface to life.
Swift’s optionals are implemented as simple enums, with just a little compiler magic sprinkled around as syntactic sugar. However, they do much more than people realize, and in this article I’m going to demonstrate some of their power features that can really help you write better code – and blow your mind along the way.
Before you dive in to the first article in this course, I want to give you a brief overview of our goals, how the content is structured, as well as a rough idea of what you can expect to find.
Anyone can write Swift code to fetch network data, but much harder is knowing how to write code to do it respectfully. In this article we’ll look at building a considerate network stack, taking into account the user’s connection, preferences, and more.
Assertions allow us to have Swift silently check the state of our program at runtime, but if you want to get them right you need to understand some intricacies. In this article I’ll walk you through the five ways we can make assertions in Swift, and provide clear advice on which to use and when.
Trees are an extraordinarily simple, extraordinarily useful data type, and in this article we’ll make a complete tree data type using Swift in just a few minutes. But rather than just stop there, we’re going to do something quite beautiful that I hope will blow your mind while teaching you something useful.
In this article you’ll learn how memoization can dramatically boost the performance of slow functions, and how easy Swift makes it thanks to its generics and closures.
This challenge asks you to create a habit-tracking app, optionally with Codable
support and completion count. Let’s tackle it now…
UPDATED: Now that we have our basic data model configured and coded, we can put it to use by building a simple user interface to help make sure our data is in place and working correctly.
Most of the time the built-in iOS controls are great, but sometimes you want something just a little different. In this article I’m going to walk you through how you can take complete control over the way toggle switches work in SwiftUI, providing custom rendering and interactions.
When users scroll beyond the top of a scroll view the default behavior is to show some empty space, but many apps prefer to show a stretchy header area instead. In this article I’ll show you how to build that SwiftUI, making an image that stays fixed to the top no matter what.
Crashes are inevitable, at least when you’re in development, so learning how to find the source of a problem and getting it resolved is a key skill for any developer.
Large parts of Apple’s Weather app is about bringing little sparks of joy to an otherwise very serious, fact-driven experience, but none more so than the random little meteors that fly by on starry nights. They move so fast so you might be tempted to skip over them, but I think it’s definitely worth exploring and having some fun with!
Link copied to your pasteboard.