'ADA - Programmatically shifting VoiceOver focus to another SwiftUI view - compatible with IOS 13+

Overview:

I'm having difficulty understanding how to properly programmatically shift accessibility focus in a SwiftUI view compatible with IOS 13+.

This means using the newer SwiftUI focus management API available in IOS 15+ is not possible unfortunately.

Most of the suggestions I've seen are similar to the following post:

iOS change accessibility focus.

where it suggests to change focus by using UIAccessibility.post(notification: .screenChanged/layoutChanged) argument: View)

However, my attempts have been unsuccessful.

I have been able to successfully use UIAccessibility.post(notification: .announcement) argument: String) in the same way as the code below and it does announce the sting as expected. It takes considerably longer than the asyncAfter timer (Maybe 8 seconds), but it seemed to indicate the post() call is kind of working.

Code Example:

A custom expand/collapse with a chevron up or down depending on if it is expanded or collapsed.

I want to programmatically be able to control where focus goes when it is expanded. For instance, I may want to be able to have VoiceOver focus on 'Menu Item 3' in the expanded menu on open.

I was thinking that something close to the code below might work, but I have a lack of understanding of how the argument view in the post is working and not sure if there is a way to use a SwiftUI view or if I need to create a UIKit view and use UIViewRepresentable or wrap it using UIHostingController?

Current Behavior

When the menu is expanded, VoiceOver moves to the first item. When the menu is collapsed, the VoiceOver focus shifts to the last text view.

Desired Behavior

I would like to be able to definitively decide where VoiceOver focus goes when I expand a menu and be able to more focus to a specific view when a view appears

I've also considered other approaches with the tools available in SwiftUI/IOS 13+. For example, I've played with accessibility(sortPriority:) and accessibilityElement(children: .combine/.contain/.ignore) to achieve the desired result, but those efforts have proved unsuccessful as well.

Code Search:

The following GitHub search currently produced 10 and 13 results respectively:

https://github.com/search?q=UIAccessibility.post%28notification%3A+.layoutChanged+View+%22import+swiftui%22&type=code

https://github.com/search?q=UIAccessibility.post%28notification%3A+.screenChanged+View+%22import+swiftui%22&type=code

And of those results, the ones used within a SwiftUI view have use an argument of 'nil' (supposedly shifting the focus to the first item on the screen)

This makes me wonder if I'm really off-track.

Any guidance/insight in to how best approach this issue is greatly appreciated!

SwiftUI View:

struct ExpandingMenu: View {
     @State var expanded: Bool = false

     var body: some View {
         ScrollView {
             VStack(spacing: 12) {
                 Text("Some views above the menu")
                     .accessibility(addTraits: .isStaticText)
                     .accessibility(identifier: "Some view identifier")
                 if self.expanded {
                     VStack(spacing: 12) {
                          Text("Menu Item 1")
                          Text("Menu Item 2")
                          Text("Menu Item 3")
                             .onAppear(perform: {
                                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
                                     UIAccessibility.post(notification: .screenChanged, argument: self)
                            }

                        })
                    Image(systemName: "chevron.up")
                    FullDivider()
                    Spacer()
                }
                .accessibility(addTraits: .isButton)
                .accessibility(identifier: "Menu Expanded")
                .accessibility(removeTraits: .isStaticText)
                .onTapGesture {
                    self.expanded.toggle()
                }
                } else {
                    VStack(spacing: 12) {
                        FullDivider()
                        Image(systemName: "chevron.down")
                        FullDivider()
                        Spacer()
                    }
                    .accessibility(addTraits: .isButton)
                    .accessibility(identifier: "Menu Collapsed")
                    .accessibility(removeTraits: .isStaticText)
                    .onTapGesture {
                        self.expanded.toggle()
                }
            }
            Text("Some views below the menu")
                .accessibility(addTraits: .isStaticText)
                .accessibility(identifier: "Some Text 3")
        }
    }
}

Example collapsed menu: Collapsed menu

Example expanded menu: Expanded menu



Solution 1:[1]

Please try my SwiftUI / UIKit solution to set a focus on accessibility without needing the new focus management API (iOS 15+):

import SwiftUI

struct LabelFocusable: View {
    var text: String

    @State private var height: CGFloat = .zero
    
    @Binding var focused: Bool

    var body: some View {
        LabelView(text: text, dynamicHeight: $height, focused: $focused)
            .frame(minHeight: height)
                               .fixedSize(horizontal: false, vertical: true)
    }

    private struct LabelView: UIViewRepresentable {
        var text: String
        
        @Binding var dynamicHeight: CGFloat
        
        @Binding var focused: Bool

        func makeUIView(context: Context) -> UILabel {
            let label = UILabel()
            label.numberOfLines = 0
            label.lineBreakMode = .byWordWrapping
            label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
            label.font = .systemFont(ofSize: 15, weight: .bold)
            label.isAccessibilityElement = true
            label.textAlignment = .center
            return label
        }

        func updateUIView(_ uiView: UILabel, context: Context) {
            uiView.text = text
            
            DispatchQueue.main.async {
                dynamicHeight = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height
            }
            
            if focused {
                DispatchQueue.main.async {
                    focused = false
                    
                    UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: uiView)
                }
            }
        }
    }
}

Example:

import SwiftUI

struct ContentView: View {
    
    @State private var label1Focused = false
    
    @State private var label2Focused = false
    
    var body: some View {
        VStack(spacing: 64) {
            Text("Headline")
                .font(.largeTitle)
            
            LabelFocusable(text: "Label 1", focused: $label1Focused)       
            
            LabelFocusable(text: "Label 2", focused: $label2Focused)

            Button {
                label1Focused = true
            } label: {
                Text("Focus Label 1")
            }
            .buttonStyle(.borderedProminent)
            
            Button {
                label2Focused = true
            } label: {
                Text("Focus Label 2")
            }
            .buttonStyle(.borderedProminent)
        }
        .padding(.horizontal)
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1