23
votes

I want both my UIScrollView and its subviews to receive all touch events inside the subview. Each can respond in its own way.

Alternatively, if tap gestures were forwarded to subviews, all would be well.

A lot of people are struggling in this general area. Here are a few of the many related questions:

How does UIScrollView steal touches from its subviews
How to steal touches from UIScrollView?
How to Cancel Scrolling in UIScrollView

Incidentally, if I override hitTest:withEvent: in the scroll view, I do see the touches as long as userInteractionEnabled is YES. But that doesn't really solve my problem, because:

1) At that point, I don't know if it's a tap or not.
2) Sometimes I need to set userInteractionEnabled to NO.

EDIT: To clarify, yes, I want to treat taps differently from pans. Taps should be handled by subviews. Pans can be handled by the scroll view in the usual way.

5
Setting userInteractionEnabled to NO and expecting user interaction data seems like a universe shattering paradox.borrrden
Do you want to destinguish between a touch and a scroll?shannoga

5 Answers

39
votes

First, a disclaimer. If you set userInteractionEnabled to NO on the UIScrollView, no touch events will be passed to the subviews. So far as I'm aware, there's no way around that with one exception: intercept touch events on the superview of the UIScrollView, and specifically pass those events to the subviews of UIScrollView. To be honest, though, I don't know why you would want to do this. If you're wanting to disable specific UIScrollView functionality (like...well, scrolling) you can do that easily enough without disabling UserInteraction.

If I understand your question, you need tap events to be processed by the UIScrollView and passed to the subviews? In any case (whatever the gesture is), I think what you're looking for is the protocol method gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer: in the protocol UIGestureRecognizerDelegate. In your subviews, whatever gesture recognizers you have, set a delegate (probably whatever class is setting the UIGestureReconginzer in the first place) on the gesture recognizer. Override the above method and return YES. Now, this gesture will be recognized along with any other recognizers that might have 'stolen' the gesture (in your case, a tap). Using this method you can even fine tune your code to only send certain kinds of gestures to the subviews or send the gesture only in certain situations. It gives you a lot of control. Just be sure to read about the method, especially this part:

This method is called when recognition of a gesture by either gestureRecognizer or otherGestureRecognizer would block the other gesture recognizer from recognizing its gesture. Note that returning YES is guaranteed to allow simultaneous recognition; returning NO, on the other hand, is not guaranteed to prevent simultaneous recognition because the other gesture recognizer's delegate may return YES.

Of course, there's a caveat: This only applies to gesture recognizers. So you may still have problems if you're trying to use touchesBegan:, touchesEnded, etc to process the touches. You can, of course, use hitTest: to send raw touch events on to the subviews, but why? Why process the events using those methods in UIView, when you can attach a UIGestureRecognizer to a view and get all of that functionality for free? If you need touches processed in a way that no standard UIGestureRecognizer can provide, subclass UIGestureRecognizer and process the touches there. That way you get all the the functionality of a UIGestureRecognizer along with your own custom touch processing. I really think Apple intended for UIGestureRecognizer to replace most (if not all) of the custom touch processing code that developers use on UIView. It allows for code-reuse and it's a lot easier to deal with when mitigating what code processes what touch event.

3
votes

I don't know if this can help you, but I had a similar problem, where I wanted the scrollview to handle double-tap, but forward single tap to subviews. Here is the code used in a CustomScrollView

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {

    UITouch* touch = [touches anyObject];
    // Coordinates
    CGPoint point = [touch locationInView:[self.subviews objectAtIndex:0]];

    // One tap, forward
    if(touch.tapCount == 1){
        // for each subview
        for(UIView* overlayView in self.subviews){
            // Forward to my subclasss only
            if([overlayView isKindOfClass:[OverlayView class]]){
                // translate coordinate
                CGPoint newPoint = [touch locationInView:overlayView];
                //NSLog(@"%@",NSStringFromCGPoint(newPoint));

                BOOL isInside = [overlayView pointInside:newPoint withEvent:event];
                //if subview is hit
                if(isInside){
                    Forwarding
                    [overlayView touchesEnded:touches withEvent:event];
                    break;
                }
            }
        }

    }
    // double tap : handle zoom
    else if(touch.tapCount == 2){

        if(self.zoomScale == self.maximumZoomScale){
            [self setZoomScale:[self minimumZoomScale] animated:YES];
        } else {
            CGRect zoomRect = [self zoomRectForScrollView:self withScale:self.maximumZoomScale withCenter:point];            

            [self zoomToRect:zoomRect animated:YES];
        }

        [self setNeedsDisplay];

    }
}

Of course, the effective code should be changed, but at this point you should have all the informations you need to decide if you have to forward the event. You might need to implement this in another method as touchesMoved:withEvent:.

Hope this can help.

3
votes

I was having this same problem, but with a scrollview that was inside UIPageViewController, so it had to be handled slightly differently.

By changing the cancelsTouchesInView property to false for each recognizer on the UIScrollView I was able to receives touches to buttons inside the UIPageViewController.

I did so by adding this code into viewDidLoad:

guard let recognizers = self.pageViewController.view.subviews[0].gestureRecognizers else {
     print("No gesture recognizers on scrollview.")
     return
}

for recognizer in recognizers {
    recognizer.cancelsTouchesInView = false
}
0
votes

If what you need is to differ between a touch and a scroll then you can test if touches has been moved. If this is a tap then touchHasBeenMoved will not be called then you can assume this is a touch.

At this point you can set a boolean to indicate if a movnent accoured and set this Boolean as a condition in your other methods.

I am on the road but if that's what you need I will be able to explain better later.

0
votes

A hackish way to achieve your objective - not 100% exact - is to subclass the UIWindow and override the - (void)sendEvent:(UIEvent *)event;

A quick example:

in SecondResponderWindow.h header

//SecondResponderWindow.h

@protocol SecondResponderWindowDelegate
- (void)userTouchBegan:(id)tapPoint onView:(UIView*)aView;
- (void)userTouchMoved:(id)tapPoint onView:(UIView*)aView;
- (void)userTouchEnded:(id)tapPoint onView:(UIView*)aView;
@end

@interface SecondResponderWindow : UIWindow
@property (nonatomic, retain) UIView *viewToObserve;
@property (nonatomic, assign) id <SecondResponderWindowDelegate> controllerThatObserves;
@end

in SecondResponderWindow.m

//SecondResponderWindow.m

- (void)forwardTouchBegan:(id)touch onView:(UIView*)aView {
    [controllerThatObserves userTouchBegan:touch onView:aView];
}
- (void)forwardTouchMoved:(id)touch onView:(UIView*)aView {
    [controllerThatObserves userTouchMoved:touch onView:aView];
}
- (void)forwardTouchEnded:(id)touch onView:(UIView*)aView {
    [controllerThatObserves userTouchEnded:touch onView:aView];
}

- (void)sendEvent:(UIEvent *)event {
    [super sendEvent:event];

    if (viewToObserve == nil || controllerThatObserves == nil) return;

    NSSet *touches = [event allTouches];
    UITouch *touch = [touches anyObject];
    if ([touch.view isDescendantOfView:viewToObserve] == NO) return;

    CGPoint tapPoint = [touch locationInView:viewToObserve];
    NSValue *pointValue = [NSValue valueWithCGPoint:tapPoint];

    if (touch.phase == UITouchPhaseBegan)
        [self forwardTouchBegan:pointValue onView:touch.view];
    else if (touch.phase == UITouchPhaseMoved)
        [self forwardTouchMoved:pointValue onView:touch.view];
    else if (touch.phase == UITouchPhaseEnded)
        [self forwardTouchEnded:pointValue onView:touch.view];
    else if (touch.phase == UITouchPhaseCancelled)
        [self forwardTouchEnded:pointValue onView:touch.view];
}

It's not 100% conforms to what your were expecting - because your second responder view does not handle the touch event natively via -touchDidBegin: or so, and has to implement the SecondResponderWindowDelegate. However this hack does allow you to handle touch events on additional responders.

This method is inspired by and extended from MITHIN KUMAR's TapDetectingWindow