Jacob’s Tech Tavern

Jacob’s Tech Tavern

Oh Sh*t, My App is Successful and I Didn’t Think About Accessibility

Speedrunning A11y in SwiftUI

Jacob Bartlett's avatar
Jacob Bartlett
Mar 18, 2024

When you have a genius app idea, a brand-new job, or a fresh injection of venture capital, the temptation to tunnel-vision on shipping new features can be overwhelming.

If you play your cards right, this focus pays off, rocketing your app to the top of the download charts. Not good enough.You won’t stop until everybody is using your app.

Upon achieving this absurd milestone, you realise a hard truth: 1 in 6 users think your app is broken.

Then it hits you.

Oh shit. 

My app is successful and I didn’t think about accessibility. 

16% of people have some kind of accessibility (a11y if you’re one of the cool kids) requirement. But when you’re flying by the seat of your pants, it’s easy to let accessibility fall by the wayside — particularly when surrounded by deadlines, with limited buy-in from leadership.

The more successful you become, the worse you’re perceived for failing to acknowledge the accessibility needs of your users. This is, unfortunately, hard to prioritise without somebody influential championing accessibility.

Today, I’m going to help. I’ll demonstrate how you can get your app up to speed with accessibility, and fast:

  • Audit accessibility in your SwiftUI app.

  • Get the app looking good at all text scales.

  • Make the app useable for screen-readers.

  • Convince stakeholders to prioritise accessibility.

We’re going to look at a dead simple, cat-themed, companion app I created especially for this article. An app which looks fine on the surface but, as soon as you start evaluating it from an accessibility perspective, is utterly broken.

If you want to fully understand the techniques, code along too! Start from the Before/ folder as we implement all the techniques detailed in this article (look at After/ for the final, improved, app). 

Auditing Accessibility in your App

To get a proper feel for accessibility, you should always test accessibility on a real device, as this is how all your users interact with your app.

With any accessibility work, you should first optimise Control Center. Seriously, this is a massive timesaver. 

Go to iPhone Settings → Control Center. Add the Text Size control and Accessibility Shortcuts. Text Size allows you to swipe down from anywhere in any app, and immediately select a text scaling to apply — you can even apply sizings on a per-app basis.

Modify Control Centre to include text sizing

Accessibility shortcuts allow you to apply various accessibility features on-the-fly, such as AssistiveTouch, VoiceOver, Colour Filters, and Reducing Motion. This is, however, probably easier to access by triple-pressing the lock button, which presents the same menu as an action sheet. 

Text size audit

There are 12 possible text sizes in iOS, from extraSmall to accessibilityExtraExtraExtraLarge (aka AX5), which is approximately 310%* larger than the default (large). 

*this is the scaling of body font, however different system text sizes scale by different amounts, for example a largeTitle is already quite big, so will not scale up as much as a caption, which is tiny. 

This will be important later on!

To make sure text sizes work, I like to run two audits: 

  1. Test the app on the largest non-accessibility font, extraExtraExtraLarge (aka xxxl), ideally with the Bold Textaccessibility setting enabled too. This is the biggest relatively common font, and also what my mum uses. The app should look good at this size.

  2. Test the app on the largest accessibility font, AX5. While, if you’re not used to it, it might look comically large, there are people for whom this is the only way they can interact with your app. While it’s tough to make the UI look perfect at this size, it’s blindingly obvious when a company hasn’t even checked if the app works at accessibility text scalings.

Let’s run through our app. 

Auditing my login screen with large (default), xxxLarge (and bold), and AX5 text scalings

Immediately, we find that our painstakingly crafted login UI falls flat on its face when we apply text scaling. We were sloppy, and we didn’t anticipate larger text sizes — so never thought to make the content scrollable.

What’s more? Somebody with colourblindness would not be able to read that login button, since the contrast is miniscule.

Let’s also check out look at the main list screen of our app.

Auditing my list screen with large (default), xxxLarge (and bold), and AX5 text scalings

This one is less ‘broken’, in that you can see everything, but it’s clear to anybody using an accessibility-sized font that you didn’t bother testing the app at this size — just look at the text labels warring for space, or that tiny ❤ icon next to a massive caption.

Screenreader audit

Some of our users are unaffected by text scaling — since they may require a screenreader to interact with your app.

iOS VoiceOver does an incredible job handling the heavy lifting here, so a little bit of thoughtfulness from your team goes a long way.

Screenreader audits, like text size audits, are also best done on a real device (press the lock button 3 times, remember!). There is also a developer tool built into Xcode which helps with VoiceOver— the Accessibility Inspector.

Finding the Accessibility Inspector developer tool in Xcode

Walking through the app, it’s less offensively broken compared to the text scaling, but large areas of the UI like our images are entirely invisible to our users because we didn’t bother to add accessibility labels.

I’m intentionally using hostile language here, because this is how your app is going to be perceived by users you left behind. Hostile.

Screen-reading the login screen tries it’s best, giving the name of the image resource

It’s doubly clear when somebody hasn’t been thoughtful thought about VoiceOver when every single UI element is iterated through by the screen-reader, including unlabelled icons.

Lazy use of a screenreader ends up listing every UI element individually

The situation gets worse with a big list. 

If you don’t give SwiftUI information about your content, you can end up with a situation where it takes 100s of swipes to move to the logout button at the bottom of your scrollable content. And don’t get me started on paging.

With many items, it takes a very long time to get to the bottom of the content.

It’s clear we’ve got a fair few issues to solve before we can call our app accessible. Fortunately, SwiftUI is packed to the gills in tools that allow you to get up to speed on accessibility extremely fast.

Speedrunning Accessibility

Now we know where the problems are, we can start to address them one by one. And quickly. 

Scrollable content

This is the most offensive issue — my app onboarding is completely broken, since the content doesn’t expand into a scroll view on larger text sizes.

In our audit, the button for our login screen was pushed offscreen as font size increased. The rest of the text content strained against each other, labels overlapping and truncating as they had no room to expand.

The natural solution is to make the content scrollable. But we don’t want to haphazardly allow every single screen to scroll when the content fits fine. We aren’t in the business of building Ionic apps *shudders with repressed memories*.

This issue can be addressed with a custom view modifier, what I call the a11yScrollView(). The goal of this view modifier is to wrap content in a scroll view, but only if it needs to — content only becomes scrollable if it doesn’t fit. I’ve added it to a small library, A11yUtils, so you can use it in your own code.

Github Link

Right now, this only achieves our dream behaviour in iOS 16.4 and above — applying scrollBounceBehavior to the scroll view to prevent it from scrolling if the content fits already.

xIn the future, I’d like to make it work with ViewThatFits* for iOS 16 and GeometryReader for iOS 15. 

Feel free to submit a pull request!

Since nobody is around to demand which OS version I target, I can apply this modifier to the VStack in our onboarding view.

// OnboardingView.swift

import A11yUtils
import SwiftUI

struct OnboardingView: View {
    
    @Binding var isLoggedIn: Bool
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) { /* ... */ }
              .padding(.horizontal)
              .a11yScrollView()
              .navigationTitle("Create account")
        }
    }
}

Now, the content scrolls nicely — looking good across xxxl, AX3, and AX5.

Our content now scrolls on all text scalings. For good measure, I fixed the contrast on the Log In button

*In my experience, the ViewThatFits approach works nicely, but has problems with text entry — the software keyboard actually changes the size of the view, causing the content to resize and the layout engine to redraw the content. If a @FocusState toggles the keyboard on appearance, this leads to an infinite redrawing and resizing loop!

Spacers are a code smell

While we’re working on the onboarding screen, there’s another simple improvement we can make:

VStack(spacing: 20) {
    // ...
    OnboardingReasonsText()
    Spacer()
    LoginButtonView()
}

When text size is large, limiting screen real estate, Spacer() can cause issues. The SwiftUI layout engine will diligently attempt to create space, even if there is no room for it, which can lead to your text getting truncated. 

In this scenario, even with the spacer compressed to a very narrow height, there will be 20 points above and below, as defined in our VStack.

We can swap out the Spacer() for a frame() modifier:

VStack(spacing: 20) {
    OnboardingReasonsText()
    LoginButtonView()
        .frame(maxHeight: .infinity, alignment: .bottom)
}

Frames behave in a more reliable manner: SwiftUI knows to give as much space as possible above this button, however it is clear that the space itself isn’t a critical part of the UI, so no unnecessary spacing is applied if there isn’t room.

This frame modifier has a clearer semantic meaning — that is, it makes our intent clear to SwiftUI. This is a critical concept to understand when it comes to accessibility, so we’ll cover it more later on.

Scaling images and icons

Thanks to dynamic type, iOS will scale the font in our app automatically based on the user’s chosen font size. This is also pretty straightforward to implement with a custom font.

This means when we use semantic font sizes in SwiftUI, text will automatically scale.

Text(cat.quote)
    .font(.body) // this works fine

Even better, when sticking to SFSymbols, we can get dynamic type on our icons!

Image(systemName: "heart.circle.fill")
    .font(.body) // this also works fine with SFSymbols

Unfortunately, most iOS apps tend to have an Android counterpart, which means your designer will be creating custom icons rather than allowing you to use SFSymbols everywhere. Most apps will also include custom images and media.

Therefore, for our Before/ example, I’ve treated the icon like any regular image we’d add to SwiftUI, using a hardcoded size.

Image(cat.image)
    .resizable()
    .aspectRatio(contentMode: .fit)
    .frame(width: 72, height: 72) // hardcoded size - it won't scale!
    .clipShape(Circle())

Image(systemName: cat.icon)
    .resizable()
    .aspectRatio(contentMode: .fit)
    .foregroundStyle(.secondary)
    .frame(width: 24, height: 24) // hardcoded size - it won't scale!

The result? At the largest font scaling, the content looks very mismatched.

Images and icons with a fixed size look strange on larger text scalings

SwiftUI has a nifty tool that gives us the power of dynamic type in our custom images and views: the @ScaledMetricproperty wrapper. 

@ScaledMetric(relativeTo: .largeTitle) private var imageSize: CGFloat = 72
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24

We can use @ScaledMetric on its own, which uses the default body font scaling. It’s even better to use relativeTo so SwiftUI knows how to scale it.

largeTitle scales a little bit, since it’s already quite large. A caption style font scales up far more. In this instance, the icon has body scaling so it matches the accompanying quote text.

Image(cat.image)
    .resizable()
    .aspectRatio(contentMode: .fit)
    .frame(width: imageSize, height: imageSize) // dynamically scales
    .clipShape(Circle())

Image(systemName: cat.icon)
    .resizable()
    .aspectRatio(contentMode: .fit)
    .foregroundStyle(.secondary)
    .frame(width: iconSize, height: iconSize) // dynamically scales

Now, our images look a lot more sensible alongside the text.

Image scaling using @ScaledMetric 

Subscribe for free to continue reading

Enter your email and get every post from Jacob’s Tech Tavern.
Already have an account? Sign in
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for writing, podcasting, and media founders.