How to prevent automatic scrolling of UICollectionView by VoiceOver?

Viewed 1606

I have a horizontally scrolled UICollectionView with a title label above it and a UIPageControl below it.

UILabel
UICollectionView
UIPageControl

When I turn on the VoiceOver accessibility feature and start traversing the screen sequentially, the collection view scrolls to the beginning or end automatically. Making the page jump off suddenly. For example, if I scroll to the 2nd page using page control, and move back to the collection view, it shows and reads the last page unexpectedly. Since I'm using the page control for navigation in the accessibility mode, I'd like to prevent the automatic scrolling.

How do I prevent or counter that?

I found an issue that seems to describe the same problem, but there's no workaround suggestion: iOS 8.4: Scroll view resets contentOffset with Voice Over enabled shortly after view appear

I encountered it on iOS 13.4.1 iPhone 11 Pro

2 Answers

UIScrollViewDelegate.scrollViewDidScroll(_:)

A change in the accessibility focus that triggers an automatic scrolling also triggers a call to scrollViewDidScroll(_:) in your UIScrollViewDelegate. Use that to counter the automatic scrolling effect, f.i. by setting contentOffset the way you prefer it.

You may need to detect that the scrolling was actually triggered by accessibility features, and not the user dragging or pinching. UIAccessibility.isVoiceOverRunning and UIAccessibilityFocus.accessibilityElementDidBecomeFocused() are your friends here. Beware that changing contentOffset (or zoomScale or whatever is needed) may trigger another call to scrollViewDidScroll(_:), so you need to prevent an infinite recursion.

Using @pommy's suggestions, I was able to fix my similar issue. In the code I was working on, the most appropriate place to make the change ended up being CalendarCollectionView.setContentOffset(_:animated:), where CalendarCollectionView is a UICollectionView subclass. Specifically, it's a JTACMonthView subclass, but that should not be of any relevance to this answer.

From the name, you can see my use case: a calendar which shows a month at a time. It could have many months both into the future and the past, but the usual user focus is likely to start somewhere in the middle.

Like the OP, I found that swiping from an outer element to the collection view with VoiceOver enabled caused the focus to go to the first date in the calendar, in my case 1st January 1951 (Date.farPast, I believe.) An interesting aside: Switch Control navigation did not cause the same behaviour.

The underlying behaviour was that contentOffset was getting set to 0.0 in the dimension that the collection view scrolls. In my code, that direction is held in style, and changes based on configuration, but in most applications it's likely to be fixed.

My code simply blocks any offset changes to 0.0 when VoiceOver is enabled. This is pretty naïve, and won't be suitable for all apps, but gives a concrete example which I hope will help some others!

override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
    if shouldPreventAccessibilityFocusScrollback(for: contentOffset) {
        return
    }
    super.setContentOffset(contentOffset, animated: animated)
}

func shouldPreventAccessibilityFocusScrollback(for newContentOffset: CGPoint) -> Bool {
    if UIAccessibility.isVoiceOverRunning {
        switch style {
        case .horizontal:
            return newContentOffset.x == 0
        case .vertical:
            return newContentOffset.y == 0
        }
    }
    return false
}

I spent quite a long time trying to determine when UIAccessibilityFocus moved from something outside the collection view, to something inside the collection view, which is ideally the only time we want to block these automatic scrolls. I was unsuccessful, but I think that was mostly due to subclassing a third party collection view (the calendar). There's definitely more merit to that approach, if you can get it to work... but it will require some careful management of state.

Related