Dragging views on a scroll view: touchesBegan is received, but no touchesEnded or touchesCancelled
Asked Answered
P

4

5

As an iOS programming newbie I am struggling with a word game for iPhone.

The app structure is: scrollView -> contentView -> imageView -> image 1000 x 1000 (here fullscreen):

Xcode screenshot

I think I have finally understood how to use an UIScrollView with Auto Layout enabled in Xcode 5.1:

I just specify enough constraints (dimensions 1000 x 1000 and also 0 to the parent) for the contentView and this defines the _scrollView.contentSize (I don't have to set it explicitly) - after that my game board scrolls and zooms just fine.

However I have troubles with my draggable letter tiles implemented in Tile.m.

I use touchesBegan, touchesMoved, touchesEnded, touchesCancelled and not gesture recognizers (as often suggested by StackOverflow users), because I display larger letter tile image with shadow (the bigImage) on touchesBegan.

My dragging is implemented in the following way:

  1. In touchesBegan I remove the tile from contentView (and add it to the main app view) and display bigImage with shadow.
  2. In touchesMoved I move the tile
  3. In touchesEnded or touchesCancelled I display smallImage with shadow again and - add the tile to the contentView or leave it in the main view (if the tile is at the bottom of the app).

My problem:

Mostly this works, but sometimes (often) I see that only touchesBegan was called, but the other touchesXXXX methods are never called:

2014-03-22 20:20:20.244 ScrollContent[8075:60b] -[Tile touchesBegan:withEvent:]: Tile J 10 {367.15002, 350.98877} {57.599998, 57.599998}

Instead the scrollView is scrolled by the finger, underneath the big tile.

This results in many big tiles with shadows sitting on the screen of my app, while the scroll view is being dragged underneath them:

problem screenshot

How to fix this please?

I know for sure that my structure of the app (with custom UIViews dragged in/out of a UIScrollView) is possible - by looking at popular word games.

I use tile.exclusiveTouch = YES and a custom hitTest method for the contentView - but this doesn't help:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    UIView* result = [super hitTest:point withEvent:event];

    return result == self ? nil : result;
}

UPDATE 1:

I've tried adding the following code to handleTileTouched:

_contentView.userInteractionEnabled = NO;
_scrollView.userInteractionEnabled = NO;
_scrollView.scrollEnabled = NO;

and then set it back to YES in handleTileReleased of ViewController.m - but this does not help and also looks more like a hack to me.

UPDATE 2:

Having read probably everything related to UIScrollView, hitTest:withEvent: and pointInside:withEvent: - on the web (for ex. Hacking the responder chain and Matt Neuburg's Programming iOS book), StackOverflow and Safari, it seems to me, that a solution would be to implement the hitTest:withEvent: method for the main view of my app:

If a Tile object is hit, it should be returned. Otherwise - the scrollView should be returned.

Unfortunately, this doesn't work - I am probably missing something minor.

And I am sure that a good solution exists - by studying popular word games for iOS. For example dragging and placement of letter tiles works very smooth in Zynga's Words with Friends ® app and in the screenshots below you can see them probably using UIScrollView (the scroll bars are visible in the corner) and displaying a tile shadow (probably in touchesBegan method):

app screenshot

UPDATE 3:

I've created a new project to test gesture recognizer suggested by TomSwift and it shows the problem I have with gesture recognizers: the tile size changes too late - it happens, when the user starts moving the tile and not at the moment he touches it:

app screenshot

Procurator answered 22/3, 2014 at 19:45 Comment(3)
Ahh Mr. Farber and the never-ending word game problems. :DSitula
:-) I had the same phase with my (mobile and desktop) card game and now (since 4y) it just works: facebook.com/appcenter/video-preferans I just have to pass a critical mass of stupid questions and I then am good. Please have some patience with my iOS newbiness.Procurator
I think I know what the problem is. If I comment out the notification post, it works "fine", no touch goes to the the scrollviewSitula
S
7

The problem here is that removing a view from the view hierarchy confuses the system, the touch is lost. It is the same issue (internally gesture recognizers use the same touchesBegan: API).

https://github.com/LeoNatan/ios-newbie/commit/4cb13ea405d9f959f4d438d08638e1703d6c0c1e (I created a pull request.)

What I changed was to not remove the tile from the content view when touches begin, but only move on touches end or cancel. But this creates a problem - when dragging to the bottom, the tile is hidden below the view (due to scrollview clipping to its bounds). So I created a cloned tile, add it as a subview of the view controller's view and move that together with the original tile. When touches end, I remove the cloned tile and place the original where it should go.

This is because the bottom bar is not part of the scrollview hierarchy. If it was, the entire tile cloning would not be necessary.

I also streamlined the positioning of tiles quite a bit.

Spasmodic answered 24/3, 2014 at 22:18 Comment(7)
+1 thank you, this works good. I will wait with pull request, while the bounty is opened. For the "real" tile you set the tile.alpha=0. Why doesn't this stop it from receiving touch events? From reading the docs I've got the impession, that when alpha < 0.01 then hitTest:withEvent: returns nil?Procurator
@AlexanderFarber I am not sure about that. From my experience, alpha 0 still causes things to function normally, while hidden causes them to skip certain stuff - that's why I went with alpha.Situla
Why did you add (twice) the condition CGRectContainsPoint(_contentView.frame, ptTransform) in the handleTileReleased? It seems to work the same without it? github.com/afarber/ios-newbie/blob/master/ScrollContent/…Procurator
@AlexanderFarber Zoom out and drag the tile outside of the content view. This makes sure that if the tile lands outside of the content view, it is returned to the stack.Situla
@AlexanderFarber I want to be a beta tester when you start feeling more comfortable with iOS and start making a real app. :-)Situla
Awesome, I plan iOS + Android + Facebook (for desktop, in Flex + jQuery) application... With notifications via Urban Airship + sockets... Do you think it's a good idea to move the touchesXXX methods from Tile.m to ViewController.m?Procurator
It is, yes. The controller should handle view logic.Situla
N
2

you could set the userInteractionEnabled of the scrollview to NO while you are dragging the tile, and set it back to YES when the tile dragging ended.

Neuro answered 22/3, 2014 at 20:17 Comment(8)
I've tried setting _scrollView.userInteractionEnabled to NO in handleTileTouched and to YES in handleTileReleased - it does not help (so I've commented it out again: github.com/afarber/ios-newbie/blob/master/ScrollContent/… ) Also, I suspect it to be a workaround... there must be a better solution (like delivering touch events to the dragged letter tile only).Procurator
You could try to use a UIPanGestureRecognizer instead. You can implement quickly using thisNeuro
With UIPanGestureRecognizer I couldn't show the bigImage with shadow (currently displayed in touchesBegan - please see my original question).Procurator
I see no reason you couldn't apply shadow or change the tile size when using a UIPanGestureRecognizer.Reveille
Because gesture recognition requires time (to recognize - is it swipe? is it long touch? etc.) while the shadow should be displayed immediately after user touches the tile.Procurator
You can add the shadow with a TapGesture, and move the tile with a PanGesture. This way you'll see the shadow immediatly.Neuro
Will tap recognizer be able to recognize the situation, when a user touches a tile and just keeps touching it (w/o releasing or moving)?Procurator
It should, you need to set your view controller as delegate and implement this. Returning YES in gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer: will let both of your gestures to be recognized.Neuro
R
1

You should really try using a gesture recognizer instead of the raw touchesBegan/touchesMoved. I say this because UIScrollView is using gesture recognizers and by default these will cede to any higher-level gesture recognizer that is running.

I put together a sample that has a UIScrollView with an embedded UIImageView. As with your screenshot, below the scrollView I have some UIButton "Tiles", which I subclassed as TSTile objects. The only reason I did this was to expose some NSLayoutConstraints to access/alter their height/width (since you're using auto layout vs. frame manipulation). The user can drag tiles from their starting place into the scroll view.

This seems to work well; I didn't hook up the ability to drag a tile once it is re-parented in the scrollview. But that shouldn't be too hard. For that you might consider placing a long-tap gesture recognizer in each tile, then when it fires you would turn off scrolling in the scrollview, such that the top-level pan gesture recognizer would kick in.

Or, you might be able to subclass the UIScrollView and intercept the UIScrollView's pan-gesture-recognizer delegate callbacks to hinder panning when the user starts from a tile.

@interface TSTile : UIButton
//$hook these up to width/height constraints in your storyboard!
@property (nonatomic, readonly) IBOutlet NSLayoutConstraint* widthConstraint;
@property (nonatomic, readonly) IBOutlet NSLayoutConstraint* heightConstraint;
@end

@implementation TSTile
@synthesize widthConstraint,heightConstraint;
@end

@interface TSViewController () <UIScrollViewDelegate, UIGestureRecognizerDelegate>
@end

@implementation TSViewController
{
    IBOutlet UIImageView*   _imageView;

    TSTile*                 _dragTile;
}

- (void)viewDidLoad
{
    [super viewDidLoad];

    UIPanGestureRecognizer* pgr = [[UIPanGestureRecognizer alloc] initWithTarget: self action: @selector( pan: )];
    pgr.delegate = self;

    [self.view addGestureRecognizer: pgr];
}

- (UIView*) viewForZoomingInScrollView:(UIScrollView *)scrollView
{
    return _imageView;
}

- (BOOL) gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    CGPoint pt = [gestureRecognizer locationInView: self.view];

    UIView* v = [self.view hitTest: pt withEvent: nil];

    return [v isKindOfClass: [TSTile class]];
}

- (void) pan: (UIGestureRecognizer*) gestureRecognizer
{
    CGPoint pt = [gestureRecognizer locationInView: self.view];

    switch ( gestureRecognizer.state )
    {
        case UIGestureRecognizerStateBegan:
        {
            NSLog( @"pan start!" );

            _dragTile = (TSTile*)[self.view hitTest: pt withEvent: nil];

            [UIView transitionWithView: self.view
                              duration: 0.4
                               options: UIViewAnimationOptionAllowAnimatedContent
                            animations:^{

                                _dragTile.widthConstraint.constant = 70;
                                _dragTile.heightConstraint.constant = 70;
                                [self.view layoutIfNeeded];
                            }
                            completion: nil];
        }
            break;

        case UIGestureRecognizerStateChanged:
        {
            NSLog( @"pan!" );

            _dragTile.center = pt;
        }
            break;

        case UIGestureRecognizerStateEnded:
        {
            NSLog( @"pan ended!" );

            pt = [gestureRecognizer locationInView: _imageView];

            // reparent:
            [_dragTile removeFromSuperview];
            [_imageView addSubview: _dragTile];

            // animate:
            [UIView transitionWithView: self.view
                              duration: 0.25
                               options: UIViewAnimationOptionAllowAnimatedContent
                            animations:^{

                                _dragTile.widthConstraint.constant = 40;
                                _dragTile.heightConstraint.constant = 40;
                                _dragTile.center = pt;
                                [self.view layoutIfNeeded];
                            }
                            completion:^(BOOL finished) {

                                _dragTile = nil;
                            }];
        }
            break;

        default:
            NSLog( @"pan other!" );
            break;
    }
}

@end
Reveille answered 24/3, 2014 at 21:27 Comment(0)
D
1

I also think you should use a UIGestureRecognizer, and more precisely a UILongPressGestureRecognizer on each tile that once recognized will handle pan.

For fine grained control you can still use the recognizers' delegate.

Darcee answered 26/3, 2014 at 1:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.