Recently, I’ve been working on making a custom tab bar in our app accessible. That is, make it work just like a native, out-of-the-box UITabBarController.

Whilst possible, it was far from straightforward. In this post, I want to talk about matching the built-in behavior, by supporting the Large Content Viewer as well as the magical (as you will see later) .tabBar trait.



You can find the code from this blog post on GitHub.

Large Content Viewer

To start off, let’s take a look at what our custom tab bar looks like, and how we’ve built it.

A blank iPhone screen showing a custom tab bar with camera, bookmark, compose and screwdriver buttons.

What we’re looking at is a blank, plain UIViewController that contains a UIStackView. That stack view has four buttons added as arranged subviews.

Grand. So these are buttons, which we can tap and react on. Now, we want to make sure we can long-press these to show the large content viewer; something that comes out-of-the-box with UITabBarController. To do so, we first set the font size to one of the accessibility sizes; it will not appear otherwise.

To do so, navigate to the debug bar at the top of the debug area in Xcode, activate “Environment Overrides”, enable “Text” and set the Dynamic Type to something in the Accessibility category.

The Environment Overrides in the Xcode debug bar.

Alternatively, you can do the same in the Accessibility Inspector’s “settings” tab.

The Accessibility Inspector's settings tab.

Alright, so… here we go! Long press on a button…

… and observe nothing happens. What gives?

Not All Buttons Are Created Equal

Now, Apple recommends to always prefer elements that can grow to smaller or bigger sizes. In many cases, that’s what you want — and you will not need to add support for the large content viewer, as the elements themselves grow.

But as you can imagine, this becomes tricky in certain scenarios, and a tab bar is one of them. Growing the tab bar means we’re taking up more and more screen real estate, meaning we have less space to show the rest of our app.

Hence why we want to support the Large Content Viewer for these buttons in our case.

To do so, we use the showsLargeContentViewer API that is available on UIView.

button.showsLargeContentViewer = true

Alternatively, we can also set a largeContentTitle to go alongside the viewer, indicating the title of our tab/button.

button.largeContentTitle = NSLocalizedString(
    "Camera", 
    comment: "The title describing the `Camera` tab."
)

Build and run the app and…

Oh no, it still does not work?!

Documentation to the Rescue, Kind of

showsLargeContentViewer’s documentation mentions:

For this property to take effect, the view must have a UILargeContentViewerInteraction.

… yet largeContentTitle nor largeContentImage do not. OK, so let’s add the interaction:

bar.addInteraction(UILargeContentViewerInteraction())

Tada!

The custom tab bar showing the large content viewer for the camera button.

One More Thing…

Now, there’s one more thing we can do to improve this. The largeContentImage is picked up by the UIButton’s image, but it may not really grow to take up the space in the large content viewer. Even though that’s quite a big part of why we have this in the first place. Make sure that if you need this and enable it, you may want to “preserve vector data” so that the image doesn’t get blurry when scaled up.

button.scalesLargeContentImage = true

All of the above is also neatly summed up and touched upon by Sommer Panage in the WWDC video Large Content Viewer - Ensuring Readability for Everyone.

The Tab Bar Trait

The other part of making a custom tab bar accessible, is making sure it is seen as a tab bar by assistive technologies like VoiceOver. If you’re unsure what that feels like, try out a standard tab bar in an app by navigating through it with VoiceOver; it’ll add a bunch of great information, like which tab you’re on, and that we’re dealing with a tab bar in the first place — as well as making a container element for it so it’s easier to navigate to.

Whilst in theory this seemed straightforward in my head — add a trait to those buttons — it wasn’t as easy as I’d hoped.

First of all, you don’t add a trait to the buttons themselves; instead you add a trait to the parent view — the “tab bar” if you wish. Which feels… weird. It’s certainly not something common in terms of assigning traits.

Anyway. So the documentation further mentions that

If an accessibility element has this trait, return false for isAccessibilityElement.

When I read this, on top of the unusual way of adding a trait to the parent view, a visualization of my brain would’ve been this:

A person looking confused, with question marks around their face.

Anyhow, I tried doing what the documentation said:

tabBar.accessibilityTraits.insert(.tabBar)
tabBar.isAccessibilityElement = false

… and ran the app.

The Accessibility Inspector showing details for the camera button.

Womp womp. Nothing tab bar related, even though that is what we’d expect given the documentation. Even the spoken output that is supposed to mimic VoiceOver just speaks “Camera, button”.



So, this was not… completely unexpected? I was (still) confused about how this was supposed to work in the first place. Or as I described it in the pull request once I Sommer finally found out what was going on:

The documentation says that this is how you set up a custom tab bar. You set the parent element to have the .tabBar trait, and then you set isAccessibilityElement on said parent to false. Which makes like, zero sense. The API “contract” is to not listen to anything that has isAccessibilityElement = false in terms of accessibility. Yet here it does mean something and has a side effect.

But so apart from that, things still did not seem to work. Which was like half expected, as per what I just noted. The inspector says “no bueno”, not adding the internal “tab” trait. So no idea how to debug this or look into it.

Turns out, it does actually work… only on device. Even if inspecting the device with Accessibility Inspector, it’ll still pretend that things don’t work (read: no tab trait), yet VoiceOver reads everything correctly.

sigh time to write a blog post and file a bunch of radars on this.

… seems like it all “worked” after all. Thanks (again) to Sommer (and someone at Apple) for helping me stay sane here.



Now the only thing you’d have left to do is to insert or remove the .selected trait of the “active” tab bar item.

Conclusion

Phew. Though there are a bunch of gotchas when it comes to making a custom tab bar accessible, it is certainly possible. The good thing that it all works for the end user. The not so great thing is that implementing it isn’t straightforward, and the usual tools like the Accessibility Inspector will give you wrong information.

Hopefully this post has been helpful if you have a custom tab bar in your app that you want to make accessible!

Let me know your thoughts on this post, and if you have any questions, I’d love to help!



You can find the code from this blog post on GitHub.