Update (2022): iOS 16 now supports this via the new accessibilityActions modifier. The contents of this post can be used as a reference for how this could be supported on older OS versions.

Overview

I’ve been experimenting with SwiftUI in a side project to get a chance to learn it by practice. An interesting pickle I found myself running into recently was how to go about defining multiple accessibility actions.

Background

Accessibility Actions

Making Apps More Accessible With Custom Actions is well worth a watch to learn more about accessibility actions. In summary they can be thought of as shortcuts to perform custom actions within an application that can be invoked from various assistive technologies like Voice Over and Switch Control.

For example, in the Reminders app each reminder item has dedicated accessibility actions to mark it as complete, delete it or view its details. This makes those actions easier to find and perform quickly when using assistive technologies.

Defining Accessibility Actions

In SwiftUI accessibility actions can be defined on views via the accessibilityAction modifier.

struct ContentView: View {
    var body: some View {
        Text("Custom View")
            .padding()
            .border(Color.red)
            .accessibilityAction(named: "Custom Action") {
                print(">> Custom Action")
            }
    }
}

Additional actions can be defined by leveraging the same modifier multiple times with different parameters.

struct ContentView: View {
    var body: some View {
        Text("Custom View")
            .padding()
            .border(Color.red)
            .accessibilityAction(named: "First Action") {
                print(">> First Action")
            }
            .accessibilityAction(named: "Second Action") {
                print(">> Second Action")
            }
    }
}

We can verify the actions are working via the accessibility inspector - hovering over our custom view should reveal we do indeed now have two custom actions, and performing them triggers the corresponding action.

Accessibility inspector

The issue

The current API works great when we have a list of static actions we know upfront, however it becomes challenging if we want this list of actions to be dynamically defined.

For example, if we are driving our view from a view model, we may want to also drive the accessibility actions from there too via a published array property.

final class ViewModel: ObservableObject {
    struct CustomAction: Identifiable {
        var id: String
        var name: String
    }

    @Published var title = "Custom View"
    @Published var actions: [CustomAction] = [
        CustomAction(id: "action_1", name: "Action One"),
        CustomAction(id: "action_2", name: "Action Two"),
    ]

    func perform(action: CustomAction) {
        // ...
    }
}

Seeing the API is defined via modifiers for individual actions, it makes defining all our accessibility actions from an array a bit awkward. Based on my limited experience with SwiftUI, I haven’t found a neat way to apply a modifier multiple times given an array of parameters.

We can’t use a regular for loop within a view builder to iterate over the list of actions as that is invalid.

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        Text(viewModel.title)
            .padding()
            .border(Color.red)
            // !! invalid syntax
            // for action in viewModel.actions {
	          //  .accessibilityAction(named: action.name) {
	          //      viewModel.perform(action: action)
	          //  }
            // }
    }
}

In SwiftUI, there’s a dedicated ForEach view that is typically used in cases where a loop is needed, however this only works at the view level rather than the modifier level. The following example will yield multiple custom views, one for each custom accessibility action rather than an individual view with multiple accessibility actions.

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        ForEach(viewModel.actions) { action in
            Text(viewModel.title)
                .padding()
                .border(Color.red)
                .accessibilityAction(named: action.name) {
                    viewModel.perform(action: action)
                }
        }
    }
}

Possible solutions

Attempt 1: Iteration

My first thought was, view builders are nice to use, but what’s stopping us from falling back to regular functions where we can leverage a for loop?

func addAccessibilityActions(from viewModel: ViewModel, to view: View) -> some View {
    var modifiedView = view
    for action in viewModel.actions {
        modifiedView = modifiedView
            .accessibilityAction(named: action.name) {
                viewModel.perform(action: action)
            }
    }
    return modifiedView
}

The first complication we face here is that View is a protocol with an associated type …

Protocol 'View' can only be used as a generic constraint because it has Self or associated type requirements

Which means we can’t use it like a regular type, rather it can only be used in a generic context.

func addAccessibilityActions<V: View>(from viewModel: ViewModel, to view: V) -> some View {
    var modifiedView = view
    for action in viewModel.actions {
        modifiedView = modifiedView
            .accessibilityAction(named: action.name) {
                viewModel.perform(action: action)
            }
    }
    return modifiedView
}

The next complication we face now we’re dealing with generics, the original view type is not the same type as the view with the modifier applied, as such we can’t simply assign it to a local variable.

Cannot assign value of type ‘ModifiedContent<V, AccessibilityAttachmentModifier>’ to type ‘V’

To get this working in this fashion, we sadly have to resort to type erasure by leveraging AnyView.

func addAccessibilityActions<V: View>(from viewModel: ViewModel, to view: V) -> some View {
    var modifiedView = AnyView(view)
    for action in viewModel.actions {
        modifiedView = AnyView(
            modifiedView.accessibilityAction(named: action.name) {
                viewModel.perform(action: action)
            }
        )
    }
    return modifiedView
}

This can then be used by our custom view:

var body: some View {
    addAccessibilityActions(
        from: viewModel,
        to: Text(viewModel.title)
            .padding()
            .border(Color.red)
    )
}

While not the most elegant it does the job. The code ergonomics can be improved slightly by leveraging an intermediate type and a custom modifier.

struct AccessibilityAction {
    var name: LocalizedStringKey
    var handler: () -> Void
}

struct AccessibilityActionsModifier: ViewModifier {
    let actions: [AccessibilityAction]

    func body(content: Content) -> some View {
        var modifiedView = AnyView(content)
        for action in actions {
            modifiedView = AnyView(
                modifiedView.accessibilityAction(named: action.name, action.handler)
            )
        }
        return modifiedView
    }
}

extension View {
    func accessibilityActions(_ actions: [AccessibilityAction]) -> some View {
        modifier(AccessibilityActionsModifier(actions: actions))
    }
}

Finally, using the new method.

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        Text(viewModel.title)
            .padding()
            .border(Color.red)
            .accessibilityActions(makeAccessibilityActions())
    }

    private func makeAccessibilityActions() -> [AccessibilityAction] {
        viewModel.actions.map { action in
            AccessibilityAction(
                name: "\(action.name)"
            ) {
                viewModel.perform(action: action)
            }
        }
    }
}

Attempt 2: Flattening accessibility elements

SwiftUI has other accessibility APIs, one of those is the .accessibilityElement(children:) modifier that can control the accessibility behaviour of nested child views.

The modifier takes an AccessibilityChildBehavior parameter which has one of three values:

  • ignore: All accessibility information within child views are ignored
  • contain: Maintains the child views accessibility information as separate nested elements
  • combine Flattens the accessibility information of all child views to a single accessibility element

The last option is of particular interest - earlier we discarded the use ForEach because it will end up creating a view for each accessibility action, however now with this extra modifier we can make it work!

Albeit a bit of a questionable approach - we can create a background view composed of multiple invisible views, each hosting an individual accessibility action, and finally combining them into a single accessibility element using the .accessibilityElement(children: .combine) modifier.

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        Text(viewModel.title)
            .padding()
            .border(Color.red)
            .background(
                ForEach(viewModel.actions) { action in
                    Color
                        .clear
                        .accessibilityAction(named: action.name) {
                            viewModel.perform(action: action)
                        }
                }
            )
            .accessibilityElement(children: .combine)
    }
}

As with the previous approach we can leverage a custom modifier to make the API a bit neater. A slight difference is we’ll need to make the AccessibilityAction type Identifiable in order for it to work with ForEach.

struct AccessibilityAction: Identifiable {
    var id: String
    var name: LocalizedStringKey
    var handler: () -> Void
}

struct AccessibilityActionsModifier: ViewModifier {
    let actions: [AccessibilityAction]

    func body(content: Content) -> some View {
        content.background(
            ForEach(actions) { action in
                Color
                    .clear
                    .accessibilityAction(named: action.name, action.handler)
            }
        )
        .accessibilityElement(children: .combine)
    }
}

extension View {
    func accessibilityActions(_ actions: [AccessibilityAction]) -> some View {
        modifier(AccessibilityActionsModifier(actions: actions))
    }
}

And finally, it can be used by our custom view as before.

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        Text(viewModel.title)
            .padding()
            .border(Color.red)
            .accessibilityActions(makeAccessibilityActions())
    }

    private func makeAccessibilityActions() -> [AccessibilityAction] {
        viewModel.actions.map { action in
            AccessibilityAction(
                id: action.id,
                name: "\(action.name)"
            ) {
                viewModel.perform(action: action)
            }
        }
    }
}

Conclusion

While I’m glad I found a few ways to get this working, I can’t help but feel those are merely workarounds. I have filed a feedback for this specific case (FB9071861) to suggest the addition of a modifier that can configure multiple accessibility actions accessibilityActions().

I suspect this sort of issue will crop up for other SwiftUI APIs that leverage modifiers. Aside from leveraging type erasure to AnyView, I wonder if there’s a more idiomatic and elegant approach to applying modifiers dynamically given a list of parameters.


Updates

Avoiding type erasure

Updated: April 16, 2021

A huge thanks to @IanKay for suggesting this neater alternative that doesn’t require any type erasure!

Type erasure was needed with the first approach to allow assigning two different types to the same results variable while iterating, the original View and it’s modified version ModifiedContent<View, AccessibilityAttachmentModifier>.

// pseudo code

// type: View
var modified = view 
for action in actions {
    // type: ModifiedContent<View, AccessibilityAttachmentModifier>
    modified = modified.accessibilityAction { ... } 
}

However if we were to have the initial value be the view with the modifier applied, we’d no longer have this issue as we’d only be assigning one type to the same variable.

// pseudo code

let first = actions.first
// type: ModifiedContent<View, AccessibilityAttachmentModifier>
var modified = view.accessibilityAction(named: first.name, first.handler) 
for action in actions.dropFirst() {
    // type: ModifiedContent<View, AccessibilityAttachmentModifier>
    modified = modified.accessibilityAction(named: action.name, action.handler) 
}

Piecing this together we can update our custom modifier

struct AccessibilityActionsModifier: ViewModifier {
    let actions: [AccessibilityAction]

    @ViewBuilder
    func body(content: Content) -> some View {
        if let first = actions.first {
            let initial = content.accessibilityAction(named: first.name, first.handler)
            actions.dropFirst().reduce(initial) { view, action in
                view.accessibilityAction(named: action.name, action.handler)
            }
        } else {
            content
        }
    }
}

Note the use of @ViewBuilder here, this allows the body method to return two different types even though it’s declaring an opaque return type of some View.

iOS 16 API

Updated: June 27, 2022

My feedback FB9071861 was addressed in iOS 16 🎉, many thanks to the engineers involved! accessibilityActions is now a public API in Swift UI and allows constructing a dynamic set of accessibility actions.

This alleviates the need for the workarounds described above. That said, to support older OS versions, the helpers previously described in this post could be extended and conditioned for iOS 16 as follows:

extension View {
    @ViewBuilder
    func accessibilityActions(_ actions: [AccessibilityAction]) -> some View {
        if #available(iOS 16.0, *) {
            accessibilityActions {
                ForEach(actions) { action in
                    Button(action.name, action: action.handler)
                }
            }
        } else {
            modifier(AccessibilityActionsModifier(actions: actions))
        }
    }
}