How to prevent automatic scrolling of UICollectionView by VoiceOver? - ios

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

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

UITextInteraction + UIScrollView

I have a custom text input view that adopts the UITextInput protocol. This view is embedded within a UIScrollView. The UITextinput adopting view includes UITextInteraction. When I drag the selection handles to the edges (top or bottom) of the current visible area, the view should scroll and select text automatically (like UITextView). Does anybody know how to achieve this?
I was hoping that UITextInteraction might inform me via its delegate of necessary events, but it does not (or even takes care of this functionality automatically).
I have tried to intercept closestPosition(to point: CGPoint) -> UITextPosition?, which is called whenever the user touches the UITextInput adopting view. Therefore, I can use it to track the dragging operation of a selection handle. Once, the user reaches the top of the view, I scroll up. However, I cannot detect when the user lets go of the handle (when touch ends). When this happens, the scrollView should stop scrolling. In my case, the scroll view keeps scrolling to the top.
I also tried to intercept selectionRects(for range: UITextRange) -> [UITextSelectionRect], but it is called sporadically during a scrolling.
I also cannot detect touchesEnded(). The UITextInteraction seems to block the call. Further, I cannot implement my own pan gesture. UITextInteraction blocks this as well during a selection operation.
Has anybody successfully used UITextInteraction? It seems very pre-mature at this stage.
Here's the way I got this info out of UITextInteraction for the text insertion point placement. I have not yet attempted to track the selection handles, but will update the answer if I manage it
UITextInteraction has a property called gesturesForFailureRequirements. If you dump the content of these you'll notice one named UIVariableDelayLoupeGesture which is a subclass of UILongPressGestureRecognizer. This is the one we want when the use "picks up" the cursor with their finger
I add my own target/selector to this gesture recogniser when adding my text interaction like so:
for gesture in interaction.gesturesForFailureRequirements {
if gesture.isKind(of: UILongPressGestureRecognizer.self) {
gesture.addTarget(self, action: #selector(longPressEdgeScrollGesture(_:)))
}
}
Then in longPressEdgeScrollGesture you can get the coordinates of the cursor in your scroll view to activate your edge scrolling timer as necessary
#objc private func longPressEdgeScrollGesture(_ gesture: UILongPressGestureRecognizer) {
guard gesture.numberOfTouches > 0 else {
return
}
let location = gesture.location(in: gesture.view)
print(location)
//Start/stop your edge scrolling as required
}
I found a solution to my original problem. It is somewhat of a work around, but hopefully Apple will improve UITextInteraction.
Trying to intercept any functions in UITextInput led nowhere. Thankfully some very clever people on Twitter figured out what to do. You need to intercept the right UIGestureRecognizer that is added automatically. Much like Simeon explained in his answer. The recognizer in question is called UITextRangeAdjustmentGestureRecognizer. But you cannot find it via gesturesForFailureRequirements. You need to become a delegate of the gestures and then you can find mentioned gesture via the delegate method of shouldRecognizeSimultaneouslyWith otherGestureRecognizer.
Once you add a target + action to that gesture you can observe the dragging and handle edge scrolling.

Disabling Dock in iOS programmatically

Is it possible to disable the dock that pops up in iOS?
This is my View Controller. Notice that it has a draggable view controller in the footer.
But when I try to pull it up quickly, the dock shows up:
Is there any way to disable it?
I think the closest you can get is iOS 11's preferredScreenEdgesDeferringSystemGestures(), which will show an indicator at the bottom but not pull up the dock on the first swipe. For example, in your view controller:
override func preferredScreenEdgesDeferringSystemGestures() -> UIRectEdge {
return [.bottom]
}
In my experience it still eats the swipe gesture, but it still gives the user a second chance to hit the right target.
On iOS <11 however, this behavior can only be obtained by hiding the status bar.
Edit:
Usually when faced with implementing a design choice like this, I try to offer a second, non-interfering gesture as a backup, such as a tap in that area, that has the same effect.
As in iOS 11, you cannot disable the dock in an application, nor in Settings. I'd suggest providing a larger area for swiping up from the bottom.
Normally such conflicts should be avoided, as they degrade user experience: how do you know that the user does not actually want to use the dock?
But if you really want, you can override the preferredScreenEdgesDeferringSystemGestures() method in the root controller to specify which edges should NOT (immediately) trigger system gestures.
e.g.
override func preferredScreenEdgesDeferringSystemGestures() -> UIRectEdge {
return .bottom
}

Can't stop UITableView's scroll movement with another scroll pan if the original movement is too fast

I am using a UITableViewController with custom UITableViewCells. If I scroll relatively slow, the movement can easily be stopped with another small pan or tap. However, if the scrolling is faster, I need to make a much larger movement to reverse the direction of scrolling. Also, stopping it with a tap simply doesn’t work.
I want the default behavior back (like in the Phone app or simply any other application with a long UITableView). What can I do in order to have it again?
func gestureRecognizer(UIGestureRecognizer,shouldRecognizeSimultaneouslyWithGestureRecognizer:UIGestureRecognizer) -> Bool {
return false
}
Add Gesturedelegate in your code.
It turned out that my code inside tableView(_:willDisplay:forRowAt:) had a negative impact on the performance of my app. I got rid of the code and this issue went away.

Escaping from Scrollable areas in tvOS

I understand the concept that the focus engine will decide what can be selected. It also seems that it moves linearly vertically or horizontally and tries to find the next closest neighbor whose view intersects that vertical or horizontal line.
The problem I haven't solved just yet is how to set things up so that panning to switch between focusable subviews does not get prevented if one of them has a scrollable area (like a map).
I have two collection views that take up the width of the screen and sit one on top of the other. I can pan to switch between these just fine. Here is the code that overrides their shared custom UICollectionView class
override public func canBecomeFocused() -> Bool {
return true
}
public override func shouldUpdateFocusInContext(context: UIFocusUpdateContext) -> Bool {
return true
}
public override func didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
super.didUpdateFocusInContext(context, withAnimationCoordinator: coordinator)
}
In a separate view controller, I have a map view and a collection view above it. Both take up the width of the screen. I can pan to switch from the collection view down to the map view, but no matter how slow or fast I try to pan/swipe up, I cannot get the map view to lose its focus.
I did try adding some gesture recognizers and setting the delegate methods to try and make my GR win over the map view's scrolling GRs, but to no avail.
Any one else have similar experience? How do I get back out of the map view without having to add another dialogue or something to switch context back to the collection view?
Thank you in advance.
I'm sure there are a number of ways to solve the problem (like the one suggested in the comment by Eugene for example). In general, you'll probably need to determine when you want the focus to leave the map and trigger a focus update with setNeedsFocusUpdate(), updateFocusIfNeeded(). You would then override preferredFocusView (part of UIFocusEnvironment which is adopted by UIViewController and UIView among other things), to set the focus to whatever you want.
The real trick is to determine when it's appropriate to do this. A map is particularly hard because it's possible that it may scroll for a very long time prior to hitting a boundary (if it ever does). As such, you may need to utilize a button press as suggested by Eugene or perhaps by implementing some of the MKMapViewDelegate methods like mapView(_:regionWillChangeAnimated:) to determine when the map has moved a large distance. The "correct" answer would be determined by your desired behavior, of course.

Make CPPickerView scroll faster with large data sources

I'm using CPPickerView in my app to accomplish a horizontal UIPickerView, and it works great, but with large data sources (dozens of items) it scrolls very slowly which makes navigation before (especially considering a normal UIPickerView can go very fast through them).
I don't mean performance-wise, by the way, I mean the view decelerates very quickly, making traversal difficult.
It's just a subclass of UIScrollView with pagingEnabled set to YES. What can I do?
I looked in the source, and it seems CPPickerView is using a scroll view. Scroll views have a decelerationRate property. Play with that and see which value makes for the best result.
Don't fill CPPickerView with all data.
For example fill with first 20 items and if it reaches to the end add another 20.
Creator of CPPickerView here - I've recently updated CPPickerView to add an allowSlowDeceleration property, which should do what you're looking for. Check out the latest code on Github, or Cocoapods version 1.2.0.
For the purposes of documentation, here's how the solution works. Like you mentioned CPPickerView just a scrollview with pagingEnabled set to YES, so the solution I found was to disable paging when the user scrolls with enough velocity.
UIScrollViewDelegate has an optional method scrollViewWillEndDragging:withVelocity:targetContentOffset:, which is called when the user's finger is lifted after swiping/scrolling on the scrollview, and it's still called even when paging is enabled. Based on that value you can tell if the user was trying to scroll quickly through items, or just move one or two items.
I played around with the CPPickerViews in the Demo project, and found that a velocity of about 2.9f seems to be about the normal "fast swipe" threshold. So if the velocity is greater than this threshold (which I defined as kCPPickerDecelerationThreshold in CPPickerView.m) and allowSlowDeceleration is set to YES, CPPickerView now sets pagingEnabled to NO before the deceleration starts. This allows the picker to "coast" and decelerate like a normal scrollview.
It then catches the end of the deceleration, OR the user touching to stop the scroll, by the call to the scrollViewDidEndDecelerating: delegate method. The current item is determined (based on the offset of the scrollview), and then if the scrollview's pagingEnabled property is set to NO a call to the private method scrollToIndex:animated: is made with animation set to YES. This scrolls the CPPickerView to the current item, which necessary as it's unlikely the coasting scroll ended right on a page boundary.
Finally, when the animated scroll completes, the scrollViewDidEndScrollingAnimation: delegate method is called, at which point pagingEnabled is set back to YES.
If you find that you're having trouble getting it to recognize a "fast" swipe, try playing with the kCPPickerDecelerationThreshold value. In hindsight that maybe should be a customizable property, so perhaps I'll roll that into the next update.
As mentioned above you can use the decelerationRate property, setting it to UIScrollViewDecelerationRateNormal might help.
scrollView.decelerationRate = UIScrollViewDecelerationRateNormal;
If that still doesn't solve your problem you could also buffer your inputs into the scroll. See this:
Advanced scrollview techniques

Resources