Detecting horizontal swipes in a UIScrollView with a vertical scroll - ios

I have a UIScrollView that's around 600 pixels in height and 320 in width. So I'm allowing the user to scroll vertically.
I'm also trying to capture horizontal swipes on the view. The problem seems to be that when a user swipes horizontally with some accidental vertical movement, the UIScrollView scrolls and my touchesEnded delegate method never gets called.
Here's my code:
- (void)touchesEnded: (NSSet *)touches withEvent: (UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint currentPosition = [touch locationInView:self];
if (currentPosition.x + 35 < gestureStartPoint.x)
{
NSLog(#"Right");
}
else if (currentPosition.x - 35 > gestureStartPoint.x)
{
NSLog(#"Left");
}
else if (!self.dragging)
{
[self.nextResponder touchesEnded: touches withEvent:event];
}
[super touchesEnded: touches withEvent: event];
}
Does anyone know how I can get this to work even when there is vertical drag involved?

UIScrollView tries to figure out which touches to pass through to its contents, and which are scrolls, based on movement immediately after a touch begins. Basically, if the touch appears to be a scroll right away, it handles that gesture and the contents never see it; otherwise, the gesture gets passed through (with a very short delay for the first touch).
In my experience, I've been able to capture horizontal swipes in the contents of a UIScrollView that handled vertical-only scrolling -- it basically just worked by default for me. I did this by setting the contentSize to be the same as the width of the scroll view's frame, which is enough information to tell the UIScrollView that it won't be handling horizontal scrolling.
It sounds like you're having trouble with the default behavior, though. One hardware gotcha is that it's very hard to simulate a finger swipe on a laptop's trackpad. If I were you, I would test out the default UIScrollView setup using either a mouse or, preferably, on the device itself. I found that these input methods work much better for conveying swipes.
If that doesn't work, here is a very pertinent paragraph from Apple's UIScrollView docs:
Because a scroll view has no scroll bars, it must know whether a touch signals an intent to scroll versus an intent to track a subview in the content. To make this determination, it temporarily intercepts a touch-down event by starting a timer and, before the timer fires, seeing if the touching finger makes any movement. If the time fires without a significant change in position, the scroll view sends tracking events to the touched subview of the content view. If the user then drags their finger far enough before the timer elapses, the scroll view cancels any tracking in the subview and performs the scrolling itself. Subclasses can override the touchesShouldBegin:withEvent:inContentView:, pagingEnabled, and touchesShouldCancelInContentView: methods (which are called by the scroll view) to affect how the scroll view handles scrolling gestures.
In summary, you could try what they suggest by subclassing UIScrollView and overriding the suggested methods.

If #Tyler's method doesn't work, try putting a view right over the UIScrollView, and handle any horizontal swipes in that view's touchesBegan, and pass vertical ones to the next responder. You can be a little fuzzy and handle anything that has more of a horizontal movement than vertical as a horizontal swipe, and pass the more pure vertical swipes to the UISCrollView (via nextResponder).

Swift 4 solution
Register two gesture recognizers in viewDidLoad() in your view controller
let leftGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
leftGesture.direction = .left
leftGesture.delegate = self
view.addGestureRecognizer(leftGesture)
let rightGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
rightGesture.direction = .right
rightGesture.delegate = self
view.addGestureRecognizer(rightGesture)
Implement swipe action
#objc func handleSwipe(_ sender: UISwipeGestureRecognizer) {
// do swipe left/right based on sender.direction == .left / .right
}
In order to have simultaneous horizontal swipe and vertical scroll, let your view controller implement UIGestureRecognizerDelegate
class MyViewController: UIViewController, UIGestureRecognizerDelegate
...
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
The last step fixes the problem that when you move more than +/- 5 pixels on the y-axis the Scroll view takes over and starts scrolling vertically.

Related

Pan view using UIPanGestureRecognizer within a functional UIScrollView

The Problem
I have a UIScrollView containing a UIView that I wish to allow the user to pan using a UIPanGestureRecognizer.
In order for this to work as desired, users should be able to pan the view with one finger, but also be able to pan the scroll view with another finger - doing both at the same time (using one finger for each).
However, the scroll view ceases to work when the user is panning a view contained within it. It cannot be panned until the view's pan gesture ends.
Attempted Workaround
I tried to work around this by enabling simultaneous scrolling of both the pan view and the UIScrollView that contains it by overriding the following UIGestureRecognizerDelegate method:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
However, this makes it so that panning the view also moves the scroll view. Each element's panning gesture should be independent of the other, not linked.
Demo Project
I have created a simple demo project that should demonstrate this, here:
https://github.com/jeffc-dev/ScrollViewPannerTest
This project contains a scroll view with a square view that should be able to be panned independently of its containing scroll view, but can not.
Why I'm Doing This
The point of this is to make it easier/quicker for a user to find a destination to pan the view to. The is somewhat analogous to rearranging icons in Springboard: You can use one finger to pan an app icon while simultaneously panning between pages with another finger, quickly finding a place to drop it. I'm not using a paged scroll view - just a normal one - and I want it to be a seamless panning gesture (I don't need/want the user to have to enter a 'wiggle mode') but the basic principle is the same.
UPDATE: DonMag helpfully came up with the idea of using a UILongPressGestureRecognizer to move the view out of the scroll view for panning, which does seem promising. However, if I went that route I think I'd need to seamlessly transition to using a UIPanGestureRecognizer after doing so (as I do use some pan gesture recognizer-specific functionality).
I'm sure there are different ways to do this, but here is one approach...
Instead of using a UIPanGesture I used a UILongPressGesture.
When the gesture begins, we move the view from the scrollView to its superview. While we continue to press the view and drag it around, it is now independent of the scrollView. When we end the gesture (lift the finger), we add the view back to the scrollView.
While dragging, we can use a second finger to scroll the content of the scroll view.
The main portion of the code looks like this:
#objc func handleLongPress(_ g: UILongPressGestureRecognizer) -> Void {
switch g.state {
case .began:
// get our superview and its superview
guard let sv = superview as? UIScrollView,
let ssv = sv.superview
else {
return
}
theScrollView = sv
theRootView = ssv
// convert center coords
let cvtCenter = theScrollView.convert(self.center, to: theRootView)
self.center = cvtCenter
curCenter = self.center
// add self to ssv (removes self from sv)
ssv.addSubview(self)
// start wiggling anim
startAnim()
// inform the controller
startCallback?(self)
case .changed:
guard let thisView = g.view else {
return
}
// get the gesture point
let point = g.location(in: thisView.superview)
// Calculate new center position
var newCenter = thisView.center;
newCenter.x += point.x - curCenter.x;
newCenter.y += point.y - curCenter.y;
// Update view center
thisView.center = newCenter
curCenter = newCenter
// inform the controller
movedCallback?(self)
default:
// stop wiggle anim
stopAnim()
// convert center to scroll view (original superview) coords
let cvtCenter = theRootView.convert(curCenter, to: theScrollView)
// update center
self.center = cvtCenter
// add self back to scroll view
theScrollView.addSubview(self)
// inform the controller
endedCallback?(self)
}
}
I forked your GitHub repo and added a new controller to demonstrate: https://github.com/DonMag/ScrollViewPannerTest
You'll see that it is just a Starting Point for this approach. The view being dragged (actually, in this demo, you can use two fingers to drag two views at the same time) uses closures to inform the controller about the dragging...
Currently, "drag/drop" does not affect any other subviews in the scrollView. The only closure that does anything is the "ended" closure, at which point the controller re-calcs the scrollView's contentSize. The "moved" closure could be used to re-position views -- but that's another task.

UIScrollView scroll over background view

I have a UIScrollView (with a clear background) and behind it I have a UIImage that takes up about 1/3 of the devices height. In order to initial display the image which is sitting being the scroll view I set the scrollviews contentInset to use the same height as the image. This does exactly what I want, initialing showing the image, but scrolling down will eventually cover the image with the scroll views content.
The only issue is I added a button onto of the image. However it cannot be touched because the UIScrollView is actually over the top of it (even though the button can be seen due to the clear background). How can I get this to work.
Edit:
The following solved the problem:
//viewdidload
self.scrollView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: "onScrollViewTapped:"))
...
func onScrollViewTapped(recognizer:UITapGestureRecognizer)
{
var point = recognizer.locationInView(self.view)
if CGRectContainsPoint(self.closeButton.frame, point) {
self.closeButton.sendActionsForControlEvents(UIControlEvents.TouchUpInside)
}
}
Thanks for the screenshots and reference to Google maps doing what you're looking for, I can see what you're talking about now.
I noticed that the image is clickable and is scrolled over but there is no button showing on the image itself. What you can do is put a clear button in your UIScrollView that covers the image in order to make it clickable when you're able to see it. You're not going to be able to click anything under a UIScrollView as far as I can tell.
Please let me know if that works for you.
a simple solution is to reorder the views in the document out line. The higher the view in the outline, the lower the view is as a layer
Two things to test:
1) Make sure the image that contains the button has its userInteractionEnabled set to true (the default is false). Although, since the button is a subview and added on top of the ImageView (I assume) then this might not help.
2) If that doesn't help, can you instead add the button as a subview of the UIScrollView and set its position to be where the image is? This way it should stay on the image and will be hidden as the user scrolls down, but clickable since it is a child of the ScrollView.
Some code and/or images would help as well.
I think the way to do this is to subclass whatever objects are in your UIScrollView and override touches began / touches ended. Then figure out which coordinates are being touched and whether they land within the bounds of your button
e.g. in Swift this would be:
override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent?) {
println("!!! touchesBegan")
if var touch = touches.first {
var touchObj:UITouch = touch as! UITouch
println("touchesBegan \(touchObj.locationInView(self))") //this locationInView should probably target the main screen view and then test coordinates against your button bounds
}
super.touchesBegan(touches, withEvent:event!)
}
See :
https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIResponder_Class/index.html#//apple_ref/occ/instm/UIResponder/touchesBegan:withEvent:
And:
https://developer.apple.com/library/ios/documentation/UIKit/Reference/UITouch_Class/index.html#//apple_ref/occ/instm/UITouch/locationInView:
You should subclass UIScrollView and override -hitTest:withEvent: like so, to make sure it only eats touches it should.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView *const inherited = [super hitTest:point withEvent:event];
if (inherited == self) return nil;
return inherited;
}
Also make sure to set userInteractionEnabled to YES in your image view.
There is 2 way you can checked that weather touch event is fire on UIButton or not?
Option 1 : You need to add UITapGesture on UIScrollView. while tapping on UIScrollView. Tap gesture return touch point with respect to UIScrollView. you need to convert that touch point with respect to main UIView(that is self.view) using following method.
CGPoint originInSuperview = [superview convertPoint:CGPointZero fromView:subview];
after successfully conversation, you can checked that weather touch point is interact with UIButton frame or what. if it interact then you can perform you action that you are going to perform on UIButton selector.
CGRectContainsPoint(buttonView.frame, point)
Option 2 : Received first touch event while user touch on iPhone screen. and redirect touch point to current UIViewController. where you can check interact as like in option 1 describe. and perform your action.
Option 2 is already integrated in one of my project successfully but i have forgot the library that received first tap event and redirect to current controller. when i know its name i will remind you.
May this help you.

UIScrollView with touch interceptor for contentSize to control subviews

assume I've got the following view hierarchy;
Those views are actually ShinobiCharts, which are subclasses of UIView. View1 acts as a main chart that can be touched by the user (pinch, pan, long press and so on). This view in turn controls the other views (View2, View3 and so on) with regards to specific, touch dependent properties (the user pans the View1, so views 2 and 3... have to act accordingly and pan as well).
However, once the user scrolls the UIScrollView, View1 may disappear from the screen, leaving only views 2, 3 etc. visible, which don't have the corresponding gesture recognizers, so the user can't interact with the charts any more, bad user experience.
I sure could add recognizers to these additional charts as well, but during pinch, that would mean that both fingers would have to be located within a single view, the user can't just touch where he wants in order to pinch and zoom, again, bad user experience.
To cut things short, I need some sort of touch interceptor that covers the entire content area of the UIScrollView so that when a user pinches or pans, the corresponding touches will be forwarded to the main chart (View1) which in turn can update the other subviews.
Vertical scrolling the UIScrollView should be possible at all times.
First experiment:
I've tried adding a transparent UIView to the ViewController that covers the UIScrollView.
However, even with a direct reference to View1, the touches wouldn't get forwarded.
-(void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
[self.chartReference touchesBegan:touches withEvent:event];
}
I'm not so sure if this is the correct way to achieve this anyways.
Second experiment:
Disabling userInteraction on the chart subviews in UIScrollView and add my own pinch and pan gestures (in UIViewController) to the UIScrollView.
Issues;
1. The UIScrollView doesn't scroll any more.
2. How to forward these gestures to View1?
I'm afraid I can't provide more example code at this point since there isn't really much relevant code to show yet.
Edit:
Small note to experiment two;
The gesture recognisers have been added as follows;
UIPinchGestureRecognizer *pinchGestureRecognizer = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:#selector(pinchGesture:)];
pinchGestureRecognizer.cancelsTouchesInView = NO;
[scrollView addGestureRecognizer:pinchGestureRecognizer];
Simultaneous gesture recognition on the UIViewController has been enabled;
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}
Edit:
Problem 2 for experiment 2 has been solved. I didn't set the correct delegate for the pan/pinch gestures. D'oh!
What remains is how to forward the pinch/pan gestures.
You're close with placing a transparent view on top of the others. But, you need to override one more method.
You want to override hitTest:withEvent on the transparent view. This method is used while traversing the responder chain to see which view handles a touch in a particular area. If a view handles that touch, it returns Itself. If it wants to pass it on to the next view below it, it returns nil. If it knows another view handles that touch, it can return that view.
So, in your case, if the point is in the target area of your top transparent view, you return view1. View1's gesture recognizer should then be called.
Example:
InterceptorView is a transparent view which lies on top of the scrollView. TargetView is a view inside the scrollView, and has a TapGestureRecognizer attached to it.
class InterceptorView: UIView {
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
#IBOutlet weak var targetView1: UIView?
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
print("[Interceptor] Testing point: \(point) ")
if self.pointInside(point, withEvent: event) {
println("Hit")
return targetView1
}
else {
println()
return nil;
}
}
}
--
class TargetView: UIView {
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
#IBAction func handleGesture(gestureRecognizer: UIGestureRecognizer) {
let location = gestureRecognizer.locationInView(self)
print("[TargetView] Got gesture. Location \(location) ")
if (pointInside(location, withEvent: nil)) {
println("Inside");
}
else {
println("Outside");
}
}
}
Here's the project:
https://github.com/annabd351/GestureForwarding
(there's some other stuff in there too, but it works!)

UITableViewCell should stop Forwarding Touch Events to ScrollView

i have a custom UITableViewCell with a DrawingView as a subview. if the user try to draw something on that view everytime the touch events are forwarded to the underlying ScrollView (UITableView) and than the view scrolls. How can i disable the forwarding from the touch/scroll-events to the scrollView, that the user can draw on the DrawingView?
Any idear's? I tests the exclusiveTouch property, methods like hitTest or touchBegan to captcher the events and stop the scrolling, but nothing helped. Thanks for helping!
The caveat here is that a 'drawing' motion could very easily be interpreted as a scrolling motion.
What you need to do is override pointInside on your cell.
Effectively:
- (BOOL)pointInside:(CGPoint)point
withEvent:(UIEvent *)event
{
if (CGRectContainsPoint(drawingView.frame, point)) {
// Use the point to do the drawing
[drawingView drawAtPoint:point];
// Disable scrolling for good measure
self.tableView.scrollView.scrollEnabled = NO;
return NO;
}
// Enable scrolling
self.tableView.scrollView.scrollEnabled = YES;
return [super pointInside:point withEvent:event];
}
What this means is that as long as the user touches your cell inside the drawingView, scrolling won't happen.
If you're looking to scroll and draw at the same time from within the drawingView, that's going to be a lot kludgier to pull off.
See if this works. You may have to do some extra work like forwarding the point to your drawingView to draw something at the point.
Be careful that even if your finger is touching the same point, the pointInside method could be called multiple times so take care of duplicate events being called.

touches methods not getting called on UIView placed inside a UIScrollView

I have a Custom Scroll View, subclassing UIScrollView. I have added a scroll view in my viewcontroller nib file and changed its class to CustomScrollView. Now, this custom scroll view (made from xib) is added as a subview on self.view.
In this scroll view, I have 3 text fields and 1 UIImageView(named signImageView) added from xib. On clicking UIImageView (added a TapGestureRecogniser), a UIView named signView is added on the custom scroll view. I want to allow User to sign on this view, So I have created a class Signature.m and .h, subclassing UIView and implemented the touches methods (touchesBegan, touchesMoved and touchesEnded) and initialised the signView as follows:
signView = [[Signature alloc]initWithFrame:signImageView.frame];
[customScrollView addSubview:signView];
But when I start signing on the signView, the view gets scrolled and hence the touches methods don't get called.
I have tried adding signView on self.view instead of custom scroll view, but in that case the view remains glued to a fixed position when I start scrolling. (Its frame remains fixed in this case)
Try setting canCancelContentTouches of the scrollView to NO and delaysContentTouches to YES.
EDIT:
I see that similiar question was answered here Drag & sweep with Cocoa on iPhone (the answer is exactly the same).
If the user tap-n-holds the signView (for about 0.3-0.5 seconds) then view's touchesBegan: method gets fired and all events from that moment on go to the signView until touchesEnded: is called.
If user quickly swipes trough the signView then UIScrollView takes over.
Since you already have UIView subclassed with touchesBegan: method implemented maybe you could somehow indicate to user that your app is prepared for him to sign ('green light' equivalent).
You could also use touchesEnded: to turn off this green light.
It might be better if you add signImageView as as subView of signView (instead of to customScrollView) and hide it when touchesBegan: is fired). You would add signView to customScrollview at the same place where you add signImageView in existing code instead.
With this you achieve that there is effectively only one subView on that place (for better touch-passing efficiency. And you could achieve that green light effect by un-hiding signImageView in touchesBegan:/touchesEnded:
If this app-behaviour (0.3-0.5s delay) is unacceptable then you'd also need to subclass UIScrollView. There Vignesh's method of overriding UIScrollView's touchesShouldBegin: could come to the rescue. There you could possibly detect if the touch accoured in signView and pass it to that view immediately.
When ever you add a scrollview in your view hierarchy it swallows all touches.Hence you are not getting the touches began. So to get the touches in your signon view you will have to pass the touches to signon view. This is how you do it.
We achieved this with a UIScrollView subclass that disables the pan gesture recogniser for a list of views that you provide.
class PanGestureSelectiveScrollView: UIScrollView {
var disablePanOnViews: [UIView]?
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let disablePanOnViews = disablePanOnViews else {
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
let touchPoint = gestureRecognizer.location(in: self)
let isTouchingAnyDisablingView = disablePanOnViews.first { $0.frame.contains(touchPoint) } != nil
if gestureRecognizer === panGestureRecognizer && isTouchingAnyDisablingView {
return false
}
return true
}
}

Resources