It turns out there is a good way to handle this: put @AccessibilityFocusState
in a custom modifier.
@available(iOS 15, *)
struct FocusModifier: ViewModifier {
@AccessibilityFocusState var focusTarget: AccessibilityFocusTarget?
@Environment(\.lastAccessibilityFocus) @Binding var lastFocus
// this is the value passed into the modifier that associates an enum value with this particular view
var focusTargetValue: AccessibilityFocusTarget?
init(targetValue: AccessibilityFocusTarget) {
focusTargetValue = targetValue
}
func body(content: Content) -> some View {
content.accessibilityFocused($focusTarget, equals: focusTargetValue)
.onChange(of: focusTarget) { focus in
if focus == focusTargetValue {
lastFocus = focusTargetValue
}
}.onReceive(NotificationCenter.default.publisher(for: .accessibilityFocusAssign)) { notification in
if let userInfo = notification.userInfo,
let target = userInfo[UIAccessibility.assignAccessibilityFocusUserInfoKey] as? AccessibilityFocusTarget,
target == focusTargetValue
{
focusTarget = target
}
}
}
}
public extension View {
// Without @ViewBuilder, it will insist on inferring a single View type
@ViewBuilder
func a11yFocus(targetValue: AccessibilityFocusTarget) -> some View {
if #available(iOS 15, *) {
modifier(FocusModifier(targetValue: targetValue))
} else {
self
}
}
}
where AccessibilityFocusTarget is just an enum of programmatic focus candidates:
public enum AccessibilityFocusTarget: String, Equatable {
case title
case shareButton
case favouriteButton
}
And I'm storing the last focused element as a Binding to AccessibilityFocusTarget in the environment:
public extension EnvironmentValues {
private struct LastAccessibilityFocus: EnvironmentKey {
static let defaultValue: Binding<AccessibilityFocusTarget?> = .constant(nil)
}
var lastAccessibilityFocus: Binding<AccessibilityFocusTarget?> {
get { self[LastAccessibilityFocus.self] }
set { self[LastAccessibilityFocus.self] = newValue
}
}
}
The .onReceive block lets us will take a notification with the AccessibilityFocusTarget value in userInfo and programmatically set focus to the View associated with that value via the modifier.
I've added a custom notification and userInfo key string:
extension Notification.Name {
public static let accessibilityFocusAssign = Notification.Name("accessibilityFocusAssignNotification")
}
extension UIAccessibility {
public static let assignAccessibilityFocusUserInfoKey = "assignAccessibilityFocusUserInfoKey"
}
Using this is simple. At the top of your SwiftUI View hierarchy, inject something into the binding in the environment:
struct TopView: View {
@State var focus: AccessibilityFocusTarget?
var body: some View {
FirstPage()
.environment(\.lastAccessibilityFocus, $focus)
}
}
And for any Views within the hierarchy that might be candidates for programmatic focus, just use the modifier to associate it with a AccessibilityFocusTarget enum value:
Title()
.a11yFocus(targetValue: .title)
ShareButton()
.a11yFocus(targetValue: .shareButton)
Nothing else is need in any of those child views - all the heavy lifting is handled in the modifier!