Search code examples
iosswiftuiaccessibilityproperty-wrapperversion-compatibility

Using iOS 15+ API (@AccessibilityFocusState) without dropping support for earlier iOS versions


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?


Solution

  • 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]
                )
            }
        }