Search code examples
iosuistackviewvoiceoveruiaccessibility

UIStackView accessibility - Insert an accessibility view in default accessibleElements?


Now learning that UIStackView's accessibility behaves by default reads the arrangedSubviews in order when voice over is on. Assume I have this hierarchy:

UIView
   - UIStackView
       -view1
       -view2
       -view3
       -view4
   - collapsibleSpecialView

collapsibleSpecialView is anchored on top of view4:

        etc
|------------------|
|      view3       |
|------------------|
|    collapsible   |     <- when collapsed overlaps with vw3 to vw1
|------------------|
|      view4       |
|------------------|

I need voice over to read my elements in such order above. But since stackView needs to finish first all its accessible subviews my collapsible view is read last but I need it read before view4.

I cannot add it to stackview because when collapsibleView collapse it will push up everything above it. and I don't want to hack around auto-layout to get around that problem.

What I thought that could work is insert an invisible proxy UIView just before view4 and in that ProxyView override its methods to return accessible elements .

public class ProxyView: UIView {
    private var view: UIView?

    /// view is the collapsibleView
    @objc public convenience init(view: UIView) {
        self.init()
        self.isAccessibilityElement = true
        self.view = view
    }

    public override func accessibilityElementCount() -> Int {
        return 1
    }

    public override func accessibilityElement(at index: Int) -> Any? {
        guard let menu = view else { return nil }
        return menu
    }

    public override func index(ofAccessibilityElement element: Any) -> Int {
        return 0
    }
}

With this voiceover is able to read my ProxyView before the view4 but for some reason it does not enter the collapsibleView, swiping right shifts the focus to view4.

If my understanding with UIAccessibilityContainer is correct this should theoretically work but it's not. What am I missing? Thanks


Solution

  • I was able to solve this problem. My solution is pretty much the solution I have above with some fixes.

    class CollapsibleView: UIView {
         /// set isAccessibilityElement = true in init
        
         /// set it accessibilityElements in order you want
    
         /// this is to tell the  VoiceOver not to capture 
         /// our subviews because we are going to pass them to our proxy
         /// setting this to false will cause VO to loop back to 
         /// this view's subviews.    
    }
    
    class ProxyView: UIView {
        var lastFocusedElement: Any?
        
        private var view: CollapsibleView!
        
        /// Collapsible view is accessible element and so VO will still focus on it. We pass the focus to last focused element since we want view before collapsibleView be the last focuseable element.
    
        /// If you have other views to focus after your view, you have to figure out how to tell VO to skip collapsibleView without setting its isAccessibilityElement to false
        @objc func voFocused(notification: Notification) {
            guard let focused = notification.userInfo?[UIAccessibility.focusedElementUserInfoKey] else {
                return
            }
    
            if let asView = focused as? UIView, asView == view {
                ///change focus to last
                UIAccessibility.post(notification: .screenChanged, argument: lastFocusedElement)
            } else {
                lastFocusedElement = focused
            }
        }
    
        required init?(coder: NSCoder) {
            super.init(coder: coder) 
            /// This is an invisible container-only view. We
            /// It needs to be set to false to allow VO
            /// Recognize it as a container. 
            self.isAccessibilityElement = false
            NotificationCenter.default.addObserver(self, selector: #selector(voFocused(notification:)),
                                                   name: UIAccessibility.elementFocusedNotification,
                                                   object: nil)
    
        }
        
        func setView(vw: CollapsibleView){
            self.view = vw
            self.accessibilityElements = view.accessibilityElements
        }
    }
    

    All that's left is add ProxyView() to stack to whatever order we want it announced