4

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?

c_booth
  • 2,185
  • 1
  • 13
  • 22
  • This should be helpful https://stackoverflow.com/a/72933630/12299030 – Asperi Aug 24 '22 at 13:00
  • Apple mentioned this setup in one of the WWDC videos. It was a mistake that it ever worked in the first place. Any previous uses will be considered an error. You might have to create 2 views, one for iOS 15+ and one for the rest. – lorem ipsum Aug 24 '22 at 13:01

3 Answers3

3

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!

c_booth
  • 2,185
  • 1
  • 13
  • 22
  • I'm getting an error on the onReceive line ... Type 'Notification.Name' (aka 'NSNotification.Name') has no member 'accessibilityFocusAssign' – Jules Sep 27 '22 at 07:30
  • lastAccessibilityFocus also errors – Jules Sep 27 '22 at 07:35
  • I think something like this is missing too ... @EnvironmentObject var lastAccessibilityFocus – Jules Sep 27 '22 at 08:01
  • @Jules - the notification is a custom one - I've added the Notification extension to the answer. – c_booth Sep 27 '22 at 12:41
  • I'm using an environment value, so @environmentObject isn't necessary - but it would be an equally valid approach. You'd need to use a reference type to hold the lastAccessibilityFocus property to store it as an environment object, instead of directly holding a binding to it. If there were more than one property to store here, environmentObject would be better for sure. – c_booth Sep 27 '22 at 12:42
1

I am doing it with a Wrapper View:

@available(iOS 15, *)
public struct AccessibilityFocusableView<Content: View>: View {
    @AccessibilityFocusState private var isFocused: Bool
    private var requestFocusPublisher: AnyPublisher<Void, Never>
    private var content: Content

    public init(
        requestFocusPublisher: AnyPublisher<Void, Never>,
        @ViewBuilder content: () -> Content
    ) {
        self.requestFocusPublisher = requestFocusPublisher
        self.content = content()
    }

    public var body: some View {
        self.content
            .accessibilityFocused(self.$isFocused)
            .onReceive(self.requestFocusPublisher) {
                self.isFocused = true
            }
    }
}

And using it like that:

if #available(iOS 15, *) {
    AccessibilityFocusableView(shouldRequestFocus: myPublisher) {
      myView
    }
} else {
    myView
}
Apfelsaft
  • 5,766
  • 4
  • 28
  • 37
0

I ended up doing the following:

public extension View {
    @ViewBuilder
    func accessibilityFocused(isFocused: Binding<Bool>) -> some View {
        if #available(iOS 15, *) {
            self.modifier(EGAccessibilityVOFocusModifier(isFocused: isFocused))
        } else {
            self
        }
    }
}


private struct AccessibilityVOFocusModifier: ViewModifier {
    @AccessibilityFocusState(for: .voiceOver) private var focused: Bool
    @Binding private var isFocused: Bool

    init(isFocused: Binding<Bool>) {
        self._isFocused = isFocused
    }

    func body(content: Content) -> some View {
        content
            .accessibilityFocused($focused)
            .onChange(of: isFocused) { newValue in
                focused = newValue
            }
    }
}

This just allows me on the view to create a state variable and pass it into the modifier like this and get around the pesky minimum iOS that was set on my project:

struct ContentView: View {

@State private var isVOFocused = false 
var body: some View {
    Button(action: {
        isVOFocused = true
    }) {
        // Button label
        Text("Tap Me")
            .font(.title)
            .foregroundColor(.white)
            .padding()
            .background(Color.blue)
            .cornerRadius(10)
     }
 }.accessibilityFocused($isVOFocused)
}