4 min read

Dev Notes #6 – VoiceOver Audit

How I resolved several accessibility issues with the new FoodNoms.

People like to talk about their task management workflows and tools. Mine is extremely chaotic. I tend to make a new todo list, check off 80% of the items, get distracted for some period of time, then start a new todo list and repeat. So I end up accumulating several long lists of todos and never actually finishing any single list. 😝

Anyways, I made a new list yesterday, titled "Blockers" and here's what it contains:

  • Paywall
  • Press Kit
  • VoiceOver Audit
  • Instrumentation
  • Typesense Cluster Upgrade

For whatever reason, testing and improving VoiceOver support sounded like fun, so I started with that.


I started testing VoiceOver with FoodNoms 2 last week. I discovered several issues:

  1. Many items on the screen, like the goal cards and food entry rows in the food log tab, were being read as separate items/labels.
  2. Several primary buttons and tabs were missing any sort of accessibility label. VoiceOver was trying its hardest by interpreting the label from the SF symbol, which is neat, but it also could be greatly improved with custom labels.
  3. When switching tabs in the tab bar, iOS would change accessibility focus to the new content on the screen. (The default behavior for tab bars is to keep the focus on the selected tab.)

#2 was the easiest, low hanging fruit. Only took me a few minutes to add some helpful labels and hints to key buttons in the app.

#1 was not difficult, but it was more time intensive. One interesting thing that I noticed is that the default behavior of collection view is different than table view cells. Table view cells seem to group their contents into a single accessibility element automatically. To achieve this same behavior with collection view cells, I did the following:

accessibilityElements = [primaryLabel, secondaryLabel, nutrientLabel]
isAccessibilityElement = true
accessibilityLabel = [
    configuration.foodEntry.food.name,
    configuration.foodEntry.food.brandOwner,
    configuration.foodEntry.portionDescription,
    nutrientLabel.accessibilityLabel,
]
    .compactMap { $0 }
    .joined(separator: ", ")

Issue #3 with the tab bar, was the trickiest to solve. But it ended up being the least amount of code to fix.

This was the behavior before the fix:

0:00
/
Incorrect behavior when switching tabs.

The issue: a standard UITabBar will not change the focus after selecting a new tab. It will just keep the focus on the selected tab.

FoodNoms 2 is using UITabBarController for controlling the switching and presentation of the main content, but instead of using the default UITabBar, it is rendering a custom tab bar.

I am very acquianted with manipulating the keyboard focus in web apps, but I'd never dealt with this issue before with a native app. I tried all sorts of configurations to try to get the tab bar controller from stop stealing focus from my custom tab bar. No luck.

From what I can tell and from what I've read, the fact that my tab bar is in a separate hierarchy, i.e. not a descendant of the UITabBarController, is causing the issue here. My guess is UITabBarController is firing a UIAccessibility.Notification.screenChanged notification and telling the system to focus on its content. There doesn't seem to be a way to stop or alter this behavior.

When experiencing difficulties with unfamiliar APIs, I often like to search GitHub for open source code that is using those APIs. I was happy to find a few examples of open source apps with custom tab bars.

From my reading, it seems like the "real" solution to my problem is to not mix a custom tab bar with UITabBarController, i.e. go "all in" or "all out." But this would take a lot of work and put more responsibility on me to "get things right" – when it comes to recreating the correct accessibility, performance, and other subtle behaviors.

By the time I almost gave up and punted this bug, I decided to try one more thing, which I originally had avoided because it felt hacky and brittle. But I tried it, and it seems to work great 100% of the time, which is good enough for me!

The solution is a simple one-liner. When the button changes selection state, I fire the same notification that I assume the UITabBarController is firing:

UIAccessibility.post(notification: .screenChanged, argument: self)

It seems like my notification takes priority for whatever reason. Perhaps because it is firing later? I don't know. I really wish the Accessibility Inspector app would show a log of accessibility notifications and events, because it's really hard to debug stuff like this…


That's it! Just a few hours of work and the next build of FoodNoms will be much more accessible for users that use VoiceOver.

I did more stuff later that day, but I think I'll save that for another dev notes post. 😉

By the way, here are two random VoiceOver tips that are helpful when testing:

  • Enable the triple-click side button Accessibility Shortcut to toggle VoiceOver on and off. Available from the Settings app > Accessibility > Accessibility Shortcut.
  • Enable the Caption Panel, available in Settings > Accessibility > VoiceOver. I am often comparing FoodNoms' behavior to other apps, particularly first-party Apple ones. The Caption Panel is useful to seeing how other apps format their accessibility labels.