iOS UIPanGestureRecognizer: adjust sensitivity? - ios

My question: Is there a way to adjust the "sensitivity" of UIPanGestureRecognizer so that it turns on 'sooner', i.e. after moving a fewer number of 'pixels'?
I have a simple app with a UIImageView, and pinch and pan gesture recognizers tied to this so that the user can zoom in and draw on the image by hand. Works fine.
However, I notice the stock UIPanGestureRecognizer doesn't return a value of UIGestureRecognizerState.Changed until the user's gesture has moved about 10 pixels.
Example: Here's a screenshot showing several lines that I've attempted to draw shorter & shorter, and there is a noticeable finite length below which no line gets drawn because the pan gesture recognizer never changes state.
IllustrationOfProgressivelyShorterLines.png
...i.e., to the right of the yellow line, I was still trying to draw, and my touches were being recognized as touchesMoved events, but the UIPanGestureRecognizer wasn't firing its own "Moved" event and thus nothing was getting drawn.
(Note/clarification: That image takes up the entirety of my iPad's screen, so my finger is physically moving more than an inch even in the cases where no state change occurs to the recognizer. It's just that we're 'zoomed in' in terms of the tranformation generated by the pinch gesture recognizer, so a few 'pixels' of the image take up a significant amount of the screen.)
This is not what I want. Any ideas on how to fix it?
Maybe some 'internal' parameter of UIPanGestureRecognizer I could get at if I sub-classed it or some such? I thought I'd try to sub-class the recognizer in a manner such as...
class BetterPanGestureRecognizer: UIPanGestureRecognizer {
var initialTouchLocation: CGPoint!
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent) {
super.touchesBegan(touches, withEvent: event)
initialTouchLocation = touches.first!.locationInView(view)
print("pan: touch begin detected")
print(self.state.hashValue) // this lets me check the state
}
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent) {
super.touchesMoved(touches, withEvent: event)
print("pan: touch move detected")
print(self.state.hashValue) // this remains at the "began" value until you get beyond about 10 pixels
let some_criterion = (touches.first!.isEqual(something) && event.isEqual(somethingElse))
if (some_criterion) {
self.state = UIGestureRecognizerState.Changed
}
}
}
...but I'm not sure what to use for some_criterion, etc.
Any suggestions?
.
Other alternatives that could work, but that I'd rather not have to do:
I could simply attach my UIPanGestureRecognizer to some parent,
non-zoomed view, and then use affine transforms & such to remap the
points of the pan touches onto the respective parts of the image.
So why am I not doing that? Because the code is written so that
lots of other objects hang off the image view and they all get the
same gesture recognizers and....everything works just great without
my having keep track of anything (e.g. affine transformations), and the problem only shows up if you're really-really zoomed in.
I could abandon UIPanGestureRecognizer, and effectively just write my own using touchesBegan and touchesMoved (which is kind of
what I'm doing), however I like how UIPanGestureRecognizer
differentiates itself from, say, pinch events, in a way that I don't
have to worry about coding up myself.
I could just specify some maximum zoom beyond which the user can't go. This fails to implement what I'm going for, i.e. I want to allow for fine-detail level of manipulation.
Thanks.

[Will choose your answer over mine (i.e., the following) if merited, so I won't 'accept' this answer just yet.]
Got it. The basic idea of the solution is to change the state whenever touches are moved, but use the delegate method regarding simultaneous gesture recognizers so as not to "lock" out any pinch (or rotation) gesture. This will allow for one- and/or multi-fingered panning, as you like, with no 'conflicts'.
This, then, is my code:
class BetterPanGestureRecognizer: UIPanGestureRecognizer, UIGestureRecognizerDelegate {
var initialTouchLocation: CGPoint!
override init(target: AnyObject?, action: Selector) {
super.init(target: target, action: action)
self.delegate = self
}
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent) {
super.touchesBegan(touches, withEvent: event)
initialTouchLocation = touches.first!.locationInView(view)
}
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent) {
super.touchesMoved(touches, withEvent: event)
if UIGestureRecognizerState.Possible == self.state {
self.state = UIGestureRecognizerState.Changed
}
}
func gestureRecognizer(_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWithGestureRecognizer:UIGestureRecognizer) -> Bool {
if !(shouldRecognizeSimultaneouslyWithGestureRecognizer is UIPanGestureRecognizer) {
return true
} else {
return false
}
}
}
Generally setting that "shouldRecognizeSimultaneouslyWithGestureRecognizer" delegate to true always is what many people may want. I make the delegate return false if the other recognizer is another Pan, just because I was noticing that without that logic (i.e., and making the delegate return true no matter what), it was "passing through" Pan gestures to underlying views and I didn't want that. You may just want to have it return true no matter what. Cheers.

Swift 5 + small improvement
I had a case when accepted solution conflicted with basic taps on toolbar which also had this betterPanGesture so I added minimum horizontal offset parameter to trigger state changing to .changed
class BetterPanGestureRecognizer: UIPanGestureRecognizer {
private var initialTouchLocation: CGPoint?
private let minHorizontalOffset: CGFloat = 5
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
self.initialTouchLocation = touches.first?.location(in: self.view)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
if self.state == .possible,
abs((touches.first?.location(in: self.view).x ?? 0) - (self.initialTouchLocation?.x ?? 0)) >= self.minHorizontalOffset {
self.state = .changed
}
}
}

Related

Swift Gesture Recognizer That Tracks Only First Touch In Continuous Gesture

I'm trying to use a gesture recognizer to allow the user to resize a view on the screen by dragging it with one finger... as such, I want to track all changes to the touch. I initially tried to use a PanGestureRecognizer for this, but I've run into an issue when the user touches the view with multiple fingers. I only want to track the first touch (ie the first finger the user places on the screen during a gesture), but I can't find a way to prevent additional touches from interfering with my ability to track the first. My initial solution was to simply set
recognizer.maximumNumberOfTouches = 1
However, this causes the entire gesture to be cancelled in the event of a second touch (I want to continue tracking the first touch in this case). But, of course, not setting this value to 1 causes the gesture to actually track multiple touches. How can I make a gesture recognizer that tracks all changes to only the first touch in the gesture, and isn't cancelled by additional touches on the screen?
From your comments and answer, it sounds like your use of a pan gesture recognizer was always wrong and you were doing something wrong in your action handler. No correct action handler for a pan gesture recognizer would ever use location(in:) for anything. location(in:) gives you a centroid of all touches, which is exactly what you say you don't want — and in any case there is no need to consult it.
If your goal is to drag a view, you use translation(in:).
If your goal is to track the actual location of a particular touch, you use location(ofTouch:in:).
If you discover that a touch is being delivered and you don't want it tracked, call ignore(_:for:).
Here is a UIGestureRecognizer subclass that will track all changes the first touch the user places on the screen at the start of a gesture, and will ignore all other fingers without cancelling the gesture. Note that if you're using functions/properties of the gesture recognizer (such as location(in:)), you'll probably have to override those as well.
class FirstTouchGestureRecognizer: UIGestureRecognizer {
private var firstTouch: UITouch? = nil
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
if firstTouch == nil {
firstTouch = touches.first!
self.state = .began
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
if firstTouch != nil && touches.contains(firstTouch!) {
self.state = .changed
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
if firstTouch != nil && touches.contains(firstTouch!) {
firstTouch = nil
self.state = .ended
}
}
}

Custom UIGestureRecognizer for a two finger gesture

I have a custom UIGestureRecognizer for a two finger gesture that works perfectly except for it being very picky about how simultaneously the fingers have to touch the iOS-device for touchesBegan to be called with 2 touches. touchesBegan is often called with only one Touch even though I am trying to use two fingers.
Is there any way to make recognition for the number of Touches more forgiving in regards to how simultaneously you have to place your fingers on the touch screen?
I've noticed that a two finger tap is recognized even when you place first one finger and then another much later while still holding the first finger down.
Here is the code for my touchesBegan function:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
if touches.count != 2 {
state = .failed
return
}
// Capture the first touch and store some information about it.
if trackedTouch == nil {
trackedTouch = touches.min { $0.location(in: self.view?.window).x < $1.location(in: self.view?.window).x }
strokePhase = .topSwipeStarted
topSwipeStartPoint = (trackedTouch?.location(in: view?.window))!
// Ignore the other touch that had a larger x-value
for touch in touches {
if touch != trackedTouch {
ignore(touch, for: event)
}
}
}
}
For two-finger gestures, touchesBegan is most likely going to be called twice: once you put the first finger on the screen, and once for the second one.
In the state you keep, you should keep track of both touches (or for that matter, all current touches), and only start the gesture once both touches have been received and the gesture's start condition has been met.
public class TwoFingerGestureRecognizer: UIGestureRecognizer {
private var trackedTouches: Set<UITouch> = []
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
for touch in touches {
if self.trackedTouches.count < 2 {
self.trackedTouches.insert(touch)
}
else {
self.ignore(touch, for: event)
}
}
if self.trackedTouches.count == 2 {
// put your current logic here
}
}
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
}
public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
self.trackedTouches.subtract(touches)
}
public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
self.trackedTouches.subtract(touches)
}
public override func reset() {
super.reset()
self.trackedTouches = []
}
}
don't worry about touchesBegan:withEvent: instead use touchesEnded:withEvent: or touchesMoved:withEvent: if the end state does not contain both fingers, set it as .failed otherwise set it as .ended
tapping the screen with more than one finger simultaneously is impossible, so during touchesMoved:withEvent: you will find two fingers. I'm not sure about touchesEnded:withEvent: this one probably won't work since removing two fingers simultaneously is just as hard as applying two fingers simultaneously, but it's worth a try to see how it reacts.
I'd recommend making your code a little more forgiving. Although touchesBegan/Moved/Ended/Cancelled respond to events of "one or more touches" (as stated in the Apple docs) relying on the precision of a user to simultaneously touch the screen with 2 fingers is not ideal. This is assuming you have multi-touch enabled, which it sounds like you do.
Try tracking the touches yourself and executing your logic when you're collection of touches reaches 2. Obviously you'll also have to track when your touch amount becomes more or less and handle things accordingly, but I'd guess you're already handling this if you're gesture is meant to be for 2 fingers only (aside from the extra logic you'd have to add in touchesBegan).
Not sure why the other guys answering with using touchesMoved:withEvent did not answer your question, but maybe you need a github example.
Double touch move example.
Is touchesMoved an option in order for you to achieve the desire outcome? Also you could implement a counter before setting the state to failed
Don't forget to set isMultipleTouchEnabled = true
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if touches.count != 2 {
print("we do NOT have two touches")
if counter > 100 { // 100 "more fogiven"
state = .failed
}
counter += 1
return
} else {
print("we have to two touches")
}

How to set up a UIView to detect touches (not taps)

I am trying to make my interface feel more responsive. A UIView changes color on user touch and I want it to do so already when the View is touched.
I could implement a UITapGestureRecognizer but a tap is not what I am looking for, since it requires the touch to end before being recognized.
I imagine this to be quite simple. Or am I wrong?
Do I create a custom UIGestureRecognizer class?
Have you tried touchedBegan?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
// ...
}
super.touchesBegan(touches, with: event)
}

UIButtons in specific zone of the screen make delay on Touch Down event

I'm creating a Custom Keyboard for iOS. I have 4 rows of keys, each key have two actions: Touch Down to highlight button, and Touch Up Inside to unhighlight the button in 0.4 seconds.
But at the left edge of the screen there is a zone where Touch Down event of any button makes a delay for about quarter of second to show highlight.
See the image
So to see highlighted version, I had to hold the button, or swipe right from it. The buttons are the same, no difference at all. When I switch from letters to symbols, this left edge zone also makes the same delay. I've tried to move all the keys to the right, about 20px, and they worked fine, without delay. Ok, stick to the edge back, and delay came back also. Then I noticed, that pressing the button on its right edge, about 1-2 pixels made no delay at all. So, it seems like the problem is in this left side edge zone of the screen particularly.
By the way, I am running this app on my 5S, I've tried to run it on my friend's 5C, the same problem. But when I run it in the simulator, there is no such delay.
Use new iOS 11 feature to solve this problem definitely.
var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { get }
Documentation
I'm too creating a custom keyboard, and as far as I understand, that happens due to preferredScreenEdgesDeferringSystemGestures not working properly when overridden inside UIInputViewController, at least on iOS 13.
When you override this property in a regular view controller, it works as expected:
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
return [.left, .bottom, .right]
}
That's however not the case for UIInputViewController.
UPD: It appears, gesture recognizers will still get .began state update, without the delay. So, instead of following the rather messy solution below, you can add a custom gesture recognizer to handle touch events.
You can quickly test this adding UILongPressGestureRecognizer with minimumPressDuration = 0 to your control view.
Another solution:
My original workaround was calling touch down effects inside hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?, which is called even when the touches are delayed for the view.
You have to ignore the "real" touch down event, when it fires about 0.4s later or simultaneously with touch up inside event. Also, it's probably better to apply this hack only in case the tested point is inside ~20pt lateral margins.
So for example, for a view with equal to screen width, the implementation may look like:
let edgeProtectedZoneWidth: CGFloat = 20
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
guard result == self else {
return result
}
if point.x < edgeProtectedZoneWidth || point.x > bounds.width-edgeProtectedZoneWidth
{
if !alreadyTriggeredFocus {
isHighlighted = true
}
triggerFocus()
}
return result
}
private var alreadyTriggeredFocus: Bool = false
#objc override func triggerFocus() {
guard !alreadyTriggeredFocus else { return }
super.triggerFocus()
alreadyTriggeredFocus = true
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
alreadyTriggeredFocus = false
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
alreadyTriggeredFocus = false
}
...where triggerFocus() is the method you call on touch down event. Alternatively, you may override touchesBegan(_:with:).

Touch events are delayed near left screen edge on iOS 9 only. How to fix it?

I am developing a keybaord extension for iOS. On iOS 9 the keys react imediatelly except for keys along left edge of the keyboard. Those react with around 0.2 second delay. The reason is that the touches are simply delivered with this delay to the UIView that is root view of my keyboard. On iOS 8 there is no such delay.
My guess is that this delay is cause by some logic that is supposed to recognize gesture for opening "running apps screen". That is fine but the delay on a keyboard is unacceptable. Is there any way how to get those events without delay? Perhaps just setting delaysTouchesBegan to false on some UIGestureRecognizer?
This is for anyone using later versions of iOS (this is working on iOS 9 and 10 for me). My issue was caused by the swipe to go back gesture interfering with my touchesBegan method by preventing it from firing on the very left edge of the screen until either the touch was ended, or the system recognised the movement to not be that of the swipe to go back gesture.
In your viewDidLoad function in your controller, simply put:
self.navigationController?.interactivePopGestureRecognizer?.delaysTouchesBegan = false
The official solution since iOS11 is overriding preferredScreenEdgesDeferringSystemGestures of your UIInputViewController.
https://developer.apple.com/documentation/uikit/uiviewcontroller/2887512-preferredscreenedgesdeferringsys
However, it doesn't seem to work on iOS 13 at least. As far as I understand, that happens due to preferredScreenEdgesDeferringSystemGestures not working properly when overridden inside UIInputViewController, at least on iOS 13.
When you override this property in a regular view controller, it works as expected:
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
return [.left, .bottom, .right]
}
That' not the case for UIInputViewController, though.
UPD: It appears, gesture recognizers will still get .began state update, without the delay. So, instead of following the rather messy solution below, you can add a custom gesture recognizer to handle touch events.
You can quickly test this adding UILongPressGestureRecognizer with minimumPressDuration = 0 to your control view.
Another solution:
My original workaround was calling touch down effects inside hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?, which is called even when the touches are delayed for the view.
You have to ignore the "real" touch down event, when it fires about 0.4s later or simultaneously with touch up inside event. Also, it's probably better to apply this hack only in case the tested point is inside ~20pt lateral margins.
So for example, for a view with equal to screen width, the implementation may look like:
let edgeProtectedZoneWidth: CGFloat = 20
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
guard result == self else {
return result
}
if point.x < edgeProtectedZoneWidth || point.x > bounds.width-edgeProtectedZoneWidth
{
if !alreadyTriggeredFocus {
isHighlighted = true
}
triggerFocus()
}
return result
}
private var alreadyTriggeredFocus: Bool = false
#objc override func triggerFocus() {
guard !alreadyTriggeredFocus else { return }
super.triggerFocus()
alreadyTriggeredFocus = true
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
alreadyTriggeredFocus = false
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
alreadyTriggeredFocus = false
}
...where triggerFocus() is the method you call on touch down event. Alternatively, you may override touchesBegan(_:with:).
If you have access to the view's window property, you can access these system gesture recognizers and set delaysTouchesBegan to false.
Here's a sample code in swift that does that
if let window = view.window,
let recognizers = window.gestureRecognizers {
recognizers.forEach { r in
// add condition here to only affect recognizers that you need to
r.delaysTouchesBegan = false
}
}
Also relevant: UISystemGateGestureRecognizer and delayed taps near bottom of screen

Resources