Defining Multiple Accessibility Actions in SwiftUI
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
- The issue
- Possible solutions
- Conclusion
- Links
- Update: Avoiding type erasure
- Update: iOS 16 API
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.
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 ignoredcontain
: Maintains the child views accessibility information as separate nested elementscombine
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.
Links
- Enhance the VoiceOver experience in your app
- Making Apps More Accessible With Custom Actions - WWDC 2019
- Accessibility in SwiftUI - WWDC 2019
- App accessibility for Switch Control - WWDC 2020
UIAccessibilityCustomAction
- View Accessibility
accessibilityActions
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))
}
}
}