Apple introduced the @FocusState
and @AccessibilityFocusState
and their respective APIs for iOS 15. Typically when I have an app that supports multiple versions and I need to use a new API, I would wrap the code with if #available (iOS x) {}
or use @available
.
For managing focus state, I need to declare a var with the @AccessibilityFocusState
property wrapper, and literally including the following code in a SwiftUI View will cause it to crash at runtime on an iOS 14 device, although the compiler has no complaints:
@available(iOS 15.0, tvOS 15.0, *)
@AccessibilityFocusState var focus: FocusLocation?
On tvOS, I can use the compiler directive #if os(tvOS) … #endif
to this compile conditionally, but this isn't an option for iOS versions which are handled at runtime.
To be clear, I know that I can’t use this API for iOS 14 devices, but dropping support for iOS 14 is another issue entirely
Is there anyway to use this iOS 15+ API for iOS 15+ VoiceOver users, and still allow general iOS 14 users to run the rest of the app?
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!
To set focus, just fire off a notification using NotificationCenter, with the focus target in userInfo:
public static func setAccessibilityFocus(_ target: AccessibilityFocusTarget, withDelayInSeconds delay: TimeInterval = 0) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
NotificationCenter.default.post(
name: .accessibilityFocusAssign,
object: nil,
userInfo: [UIAccessibility.assignAccessibilityFocusUserInfoKey: target as Any]
)
}
}