iOS Accessibility Tutorial: Making Custom Controls Accessible

In this iOS accessibility tutorial, you’ll learn to make custom controls accessible using VoiceOver, elements group, custom action, traits, frame and more. By Andrew Tetlaw.

4.9 (12) ·

Download materials
Save for later

Apple’s accessibility credentials are among the best around. iOS is full of accessibility features that can elevate your app above the rest. So, why settle for a ten out of ten when you can take it to eleven?

In this iOS accessibility tutorial, you’ll learn to:

  • Apply accessibility enhancements to custom controls.
  • Organize accessibility elements for greater clarity and usability.
  • Notify the user of major changes in the user interface.

To get the most out of this tutorial, you’ll need an iOS device to make use of VoiceOver.

Note: This intermediate-level tutorial assumes that you’re comfortable building an iOS app using Xcode, UIKit, writing Swift and have a basic understanding of how iOS Accessibility features function, especially VoiceOver. If you need a primer on accessibility, I recommend reading iOS Accessibility Tutorial: Getting Started before starting getting started.

This tutorial uses the reusable knob control from How To Make a Custom Control Tutorial: A Reusable Knob.

Getting Started

Your app, Goes to 11, is nearly ready to launch to an eager audience of classic guitar amp lovers, all poised to share their ratings of classic guitar amps. Each amp fan can submit a rating from zero to eleven. However, you’re aiming to reach a wide audience and for that your app ought to be accessible.

To get started, download the starter project using the Download Materials button at the top or bottom of this tutorial.

Then, open the starter project. Build and run.

Starter app

You’ll see the app is fully functional. You can navigate forward and backward between each amp. You can see the brand, model, description and the average rating for each amp. Additionally, you can tap the Add your rating button to rate an amp. When you tap Add your rating, the screen simply unwinds to the previous screen.

The sample project seems fairly simple and straightforward, right?

Experiencing VoiceOver

To test VoiceOver, you’ll need an actual iOS device. With the Simulator you could use the Accessibility Inspector, but that does not speak accessibility information, so you can’t hear how your element descriptions will sound. Rather inconvenient!

You can configure your device’s Accessibility Shortcut to conveniently toggle VoiceOver on or off.

Do the following to set your Accessibility Shortcut:

  • First, open the Settings app.
  • Then, navigate to Accessibility ▸ Accessibility Shortcut.
  • Finally, select VoiceOver.

Now, you can activate or deactivate VoiceOver by triple-clicking the Home or Side button.

Before you start, with VoiceOver activated, triple-tap the screen with three fingers to enable Screen Curtain. With the screen off, you have to rely on VoiceOver’s announcements to navigate the app. If you need to, triple-tap the screen again to disable Screen Curtain.

Note: If you have configured your device so the three-finger triple tap does something other than control Screen Curtain, you must use a three-finger quadruple tap instead.

Build and run.

Now, activate VoiceOver and Screen Curtain on your device.

Using VoiceOver, try to navigate between amps, adjust the rating value and submit your amp rating.

When you swipe between elements on the screen, what do you hear?

It’ll be something close to this, with the hyphens represent a swipe:

Amp details, heading – Brand – Vox – Model – AC30 – 9 – 0 – 11 – Add your rating – description…

At the moment, getting to the Add your rating button requires eight swipes from the heading. Also, you have to swipe back and forth across all the elements to get the gist the current screen. The “9 – 0 – 11”, users may never work out what it is.

Your app can do much better than that!

Grouping Accessibility Elements

There are many labels that add no information when using VoiceOver and make it harder to get real information. First, open Main.storyboard. Then, under Amp Details Scene, select the following labels:

  • Brand.
  • Model.
  • Description.
  • Highest Value.
  • Lowest Value.

Selected labels

With each label selection, uncheck the Accessibility Enabled checkbox in the Identity inspector. This will hide those labels from VoiceOver’s announcements.

Accessibility field in Identity Inspector

Next, open AmpDetailsViewController.swift.

Then, right below the following code in onSelectedIndexUpdate():

ratingLabel.text = "\(amp.rating)"

Add this:

ratingLabel.accessibilityLabel = "Average rating is \(amp.rating) out of 11"

And, right below:

brandLabel.text = amp.brand

Add this:

brandLabel.accessibilityLabel = "\(amp.brand) \(amp.model)"

With the code added above, VoiceOver will read according to accessibilityLabel.

Now, open Main.storyboard.

Then, uncheck Model Label‘s Accessibility Enabled checkbox. This change ensures that VoiceOver will not read the brand out twice.

Now, build and run the project. Keep VoiceOver activated and Screen Curtain on. Hear the improvement:

Amp details, heading – Vox AC30 – Average rating is 9/11 – Add your rating …

In three swipes you can hear all the relevant information in between swipes and find the Add your rating button. That’s a tremendous improvement for very little work, wouldn’t you agree? Keep going though!

You can set the view controller’s accessibilityElements array to set the order for VoiceOver to read out the view elements.

First, open AmpDetailsViewController.swift. Then, add this line to the beginning of viewDidLoad():

accessibilityElements = [
  ].compactMap { $0 }

accessibilityElements is an optional array of Any. Because you’re setting it to an array of implicitly unwrapped optional UIViews they can’t be implicitly cast to Any.

You deal with this by doing safe and concise unwrapping of all the elements by calling .compactMap { $0 }.

Now, build and run. Try out VoiceOver and notice the order it reads out the elements. It reads the content first followed by interactive elements. You’re really finding your rhythm now.

Now, select the Next and Previous buttons with VoiceOver. Pay attention to what happens. Even though the screen updates with new amp details, the button remains focused.

This is not ideal.

Making Screen Updates

Still in AmpDetailsViewController.swift, find onSelectedIndexUpdate() and add this line at the end: .screenChanged, argument: brandLabel)

screenChanged is an accessibility notification you can post when a major part of the screen has changed. It makes that little boop sound.

The argument you pass can be an accessibility element, a String, or nil. Pass an element and that element gains the focus after the notification and VoiceOver reads out its label.

If you pass nil, the first accessibility element within the container gains focus. When you pass a string, VoiceOver reads out that text, followed by the first accessibility element in the container.

Build and run. Hear the good work you’ve achieved. You still have Screen Curtain enabled, right?

Smooth! It flows much nicer now. You can easily navigate through the whole collection of amps.

Working with Custom Accessibility Actions

Custom accessibility actions have been available since iOS 8. They’re shortcuts to common operations.

VoiceOver will announce that actions are available when a container has custom accessibility actions. You’ll swipe up or down to iterate actions selection. A double-tap will activate the action.

Navigating Next and Previous in VoiceOver mode will be even easier if you add custom actions. The actions relieve the user of the need to swipe through all the elements to find the buttons.

First, open AmpDetailsViewController.swift. Then, add these methods to the class:

@objc func nextAmpAction() -> Bool {
  guard selectedIndex != dataSource.endIndex - 1 else { return false }
  selectedIndex = min(selectedIndex + 1, dataSource.endIndex - 1)
  return true

@objc func previousAmpAction() -> Bool {
  guard selectedIndex != 0 else { return false }
  selectedIndex = max(selectedIndex - 1, dataSource.startIndex)
  return true

These are the methods your custom actions will call. They simply increment and decrement the selected index respectively, while making sure it’s never out of bounds. The accessibility API requires the methods to return a Bool and have the @objc attribute.

Now, in viewDidLoad(), add the following code to the bottom:

let next = UIAccessibilityCustomAction(
  name: "Next amp", 
  target: self, 
  selector: #selector(nextAmpAction))

let previous = UIAccessibilityCustomAction(
  name: "Previous amp", 
  target: self, 
  selector: #selector(previousAmpAction))

accessibilityCustomActions = [next, previous]

You can see that adding a custom action is a lot like setting the action for a UIButton. The difference is that VoiceOver reads out the custom action name from the accessibility custom actions selection instead of having a user look, find and tap the button.

Build and run.

Wait a moment after the first announcement. You’ll hear an announcement about your custom actions being available.

Swipe up and down to select a custom action. Then, double-tap to activate it.

They work like buttons, but you interact with them in a different way.

Navigate to either end of the list and you’ll see the grayed out previous or next buttons. Notice how nextAmpAction() and previousAmpAction() return false in this scenario. This causes the bonk sound to play, indicating the action is not possible.

Adding Your Rating Action

There’s one more custom action you should add. Since this is all about rating classic amps, you should make the rating action as easy as possible with a custom rating action.

Still inside AmpDetailsViewController.swift, add this method:

@objc func rateAmpAction() -> Bool {
  performSegue(withIdentifier: "AddRating", sender: ratingButton)
  return true

This method triggers the existing storyboard segue for going to the AddRatingViewController. All that’s left is to add the custom action to the list of accessibility actions.

To do so, first add the following below previous in viewDidLoad():

let rate = UIAccessibilityCustomAction(
  name: "Add rating", 
  target: self, 
  selector: #selector(rateAmpAction))

Then, replace:

accessibilityCustomActions = [next, previous]


accessibilityCustomActions = [rate, next, previous]

Now, build and run. Test out your new custom actions.

Time to face the music. The Add your rating experience is plain broken when using VoiceOver. You can’t even find the rating knob! But don’t fret, there is a solution.

Making the Dial Control Accessible

This calls for a better approach: subclassing UIAccessibilityElement. You can create your own accessibility element to represent a custom element for the accessibility system.

First, open AddRatingViewController.swift. Underneath the class, add the following new class:

class RatingKnobAccessibilityElement: UIAccessibilityElement {
  override init(accessibilityContainer container: Any) {
    super.init(accessibilityContainer: container)
    accessibilityLabel = "Rating selector"
    accessibilityHint = "Adjust your rating of this amp"

The initializer sets the accessibility label and hint properties with appropriate VoiceOver values.

Now, in AddRatingViewController‘s viewDidLoad(), add the following at the bottom:

let ratingElement = RatingKnobAccessibilityElement(accessibilityContainer: self)
accessibilityElements = [cancelButton, ratingElement, addRatingButton]
  .compactMap { $0 } .screenChanged, argument: ratingElement)

Here, you set the accessibilityElements array as you did for AmpDetailsViewController. The only difference is that one of the elements is an instance of your RatingKnobAccessibilityElement. You also post .screenChanged notification so that element for changing the rating gets focus first.

Notice that you pass the view controller as the accessibilityContainer? That’s important as you’ll see in a moment.

Now, open Main.storyboard. In Add Rating View Controller Scene, uncheck the Accessibility Enabled checkbox of Rating Label, Highest Value and Lowest Value. You don’t need them to be read out by VoiceOver.

Build and run. Activate the Add your rating action.

Once you’re at the Add Rating View Controller you can now swipe between Cancel, Rating Selector and Add your rating. The rating knob is now visible to VoiceOver thanks to your RatingKnobAccessibilityElement instance. It’s not very useful without being able to adjust the rating value though.

Hold tight, because the rating knob is about to get seriously cool.

Setting Accessibility Traits For Custom Controls

Open AddRatingViewController.swift, locate RatingKnobAccessibilityElement and add the following to init(accessibilityContainer:):

accessibilityTraits = [.adjustable]

When you add adjustable, you indicate that this custom element has a value you can increment or decrement. This is perfect for your rating knob.

Note: There are many more interesting accessibility traits available. You can find a complete list in Apple’s documentation. You only have to worry about them for your custom elements. All UIKit elements already have the appropriate traits set.

Build and run. Then activate Add rating. Swipe to the rating selector. You’ll hear a lot of new information:

Rating selector, adjustable, adjust your rating of this amp, swipe up or down with one finger to adjust the value.

However, if you swipe up or down, the rating value doesn’t get adjusted.

Supporting Accessibility Gestures

This is where you unleash the secret power of adjustable. All you need to do is override accessibilityIncrement() and accessibilityDecrement().

First, in AddRatingViewController, add these methods:

func incrementAction() {
  ratingKnob.setValue(ratingKnob.value + 1)

func decrementAction() {
  ratingKnob.setValue(ratingKnob.value - 1)

These are the methods the adjustable gestures will call. They simply adjust the rating value and update the label.

Then, in RatingKnobAccessibilityElement, add this helper property:

var ratingViewController: AddRatingViewController? {
  return accessibilityContainer as? AddRatingViewController

You’ll call ratingViewController often in the rest of the code, so this will save a lot of typing.

Next, add the following method overrides to RatingKnobAccessibilityElement:

override func accessibilityIncrement() {

override func accessibilityDecrement() {

Build and run. If you have Screen Curtain on, disable it for now so you can see if the screen is updating. You should find that when you swipe up and down to adjust the value, the knob is actually updating correctly.

However, there’s no VoiceOver announcement.

Overriding Accessibility Value

The VoiceOver announcement implementation for the knob rating’s value is simple: You need to override the accessibilityValue property. You can override this property in your subclass.

Add the following code to RatingKnobAccessibilityElement:

override var accessibilityValue: String? {
  get {
    guard let rating = ratingViewController?.rating else {
      return super.accessibilityValue

    return "\(rating)"

  set {
    super.accessibilityValue = newValue

Build and run. VoiceOver should now announce the rating value as you adjust it.

Accessibility Frame in Container Space

With Screen Curtain disabled, you may notice that as you switch focus to your custom element there’s no frame drawn around it. You need to override accessibilityFrameInContainerSpace to fix that.

First, in AddRatingViewController, add the following code:

var frameForAccessibilityElement: CGRect {
  return ratingKnobContainer.convert(ratingKnob.frame, to: nil)

The code above converts the knob’s frame to the coordinate space of the accessibility container or view controller’s view. You must do this because the knob is nested within another view. This will allow the accessibility focus frame to correctly position in the view controller’s view.

Then, override the following property in RatingKnobAccessibilityElement:

override var accessibilityFrameInContainerSpace: CGRect {
  get {
    guard let frame = ratingViewController?.frameForAccessibilityElement 
      else {
        return super.accessibilityFrameInContainerSpace

    return frame

  set {
    super.accessibilityFrameInContainerSpace = newValue

Build and run. Then, add a rating. Swipe to select the knob control. The frame should correctly surround the control.

Frame for selected knob

Supporting Reduced Motion

Since the knob control can animate into its next position, you can add support for the accessibility reduce motion feature, too. You’ll normally make use of this feature if your end users have sensitivity to motion effects or screen movement.

First, open Knob.swift.

In the class KnobRenderer find setPointerAngle(_:animated:), then replace:

if animated {


if animated && !UIAccessibility.isReduceMotionEnabled {

In the if statement, you decide whether to animate the knob rotation based on the device’s reduced motion accessibility setting.

To test this out, you need to go to your device’s accessibility settings and set Reduce Motion to On. You find this setting under Accessibility ▸ Motion.

Build and run. Now, navigate between amps. You should see the knob value change without the knob rotation animation.

Taking Accessibility to the Next Level

Now, add an amp rating and make it an eleven. Seems a bit anticlimactic right?

If an amp deserves the ultimate rating, the app should acknowledge this in some way.

Inside AddRatingViewController.swift, go to RatingKnobAccessibilityElement and add the following to the end of accessibilityIncrement():

if let rating = ratingViewController?.rating, rating == 11 {
  let message = NSAttributedString(
    string: "Whoa! This one goes to 11!",
    attributes: [.accessibilitySpeechPitch: 0.1,
                 .accessibilitySpeechQueueAnnouncement: true]) .announcement,
                       argument: message)

UIAccessibility.Notification.announcement is an accessibility notification you can use to make VoiceOver announce something. I suppose the name makes that obvious. You can pass an NSAttributedString annotated with special attributes for VoiceOver.

accessibilitySpeechPitch controls the pitch of the voice. It’s a value between 0.0 and 2.0. 1.0 is normal, 0.0 is the lowest pitch and 2.0 is the highest.

accessibilitySpeechQueueAnnouncement controls when the string is announced. Specify true to ensure VoiceOver queues the announcement after it finishes other current announcements.

If this is false, the notification can interrupt whatever VoiceOver is announcing when the app posts this notification. You don’t want to interrupt the value original announcement, but rather add this as a fun VoiceOver Easter egg when your user selects 11 on the rating knob.

Build and run. Try it out. Hopefully, it’ll be a fun surprise. :)

Note: There are many more attributed string accessibility attributes to explore. Have a look at them all in Apple’s documentation.

And that’s it! You now have an app that’s accessible. Navigating the app is a simple and clear experience, and it even includes an Easter egg for those using VoiceOver!

Great job! :]

Where to Go From Here?

You can download the final project using the Download Materials button at the top or bottom of this tutorial.

Of course, this is not the limit of what you can achieve with the iOS accessibility features. Here’s a couple of challenges you may like to try:

Implement accessibilityPerformMagicTap():
Magic tap is a special accessibility gesture that should act as a shortcut to the most likely action. For example, if there’s an incoming phone call, the magic tap will answer the call and then hang up if you perform the gesture again. Perhaps for your app, you might use it to trigger adding a rating or view the next amp.

Implement accessibilityPerformEscape():
This is another standard gesture for dismissing the current modal view on screen. This could be useful for dismissing AmpDetailsViewController.

Finally, start exploring. The iOS Accessibility features are extensive. Implementing any number of them will instantly elevate your app above all the apps that fail to consider them.

Apple’s documentation is a gold mine for ideas on improving the accessibility of your app. And, a great place to start is the Apple Accessibility Documentation.

I hope you enjoyed this iOS accessibility tutorial. If you have any questions or comments, please join the forum discussion below.