Draggable UIView stops posting touchesBegan after being added to UIScrollView - ios

In Xcode 5.1 I have created a simple test app for iPhone:
The structure is: scrollView -> contentView -> imageView -> image 1000 x 1000 on the top.
And on the bottom of the single view app I have seven draggable custom UIViews.
The dragging is implemented in Tile.m with touchesXXXX methods.
My problem is: once I add a draggable tile to the contentView in my ViewController.m file - I can not drag it anymore:
- (void) handleTileMoved:(NSNotification*)notification {
Tile* tile = (Tile*)notification.object;
//return;
if (tile.superview != _scrollView && CGRectIntersectsRect(tile.frame, _scrollView.frame)) {
[tile removeFromSuperview];
[_contentView addSubview:tile];
[_contentView bringSubviewToFront:tile];
}
}
The touchesBegan isn't called for the Tile anymore as if the scrollView would mask that event.
I've searched around and there was a suggestion to extend the UIScrollView class with the following method (in my custom GameBoard.m):
- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView* result = [super hitTest:point withEvent:event];
NSLog(#"%s: %hhd", __PRETTY_FUNCTION__,
[result.superview isKindOfClass:[Tile class]]);
self.scrollEnabled = ![result.superview isKindOfClass:[Tile class]];
return result;
}
Unfortunately this doesn't help and prints 0 in debugger.

The problem is, partly, because user interactions are disabled on the content view. However, enabling user interactions disables scrolling as the view captures all touches. So here is the solution. Enable user interactions in storyboard, but subclass the content view like so:
#interface LNContentView : UIView
#end
#implementation LNContentView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView* result = [super hitTest:point withEvent:event];
return result == self ? nil : result;
}
#end
This way, hit test passes only if the accepting view is not self, the content view.
Here is my commit:
https://github.com/LeoNatan/ios-newbie

The reason Tile views don't get touches is that scroll view's pan gesture recogniser consumes the events. What you need is, attach a UIPanGestureRecongnizer to each of your tiles and configure them as follows:
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(pan:)]; // handle drag in pan:method
[tile addGestureRecognizer:pan];
UIPanGestureRecognizer *scrollPan = self.scrollView.panGestureRecognizer;
[scrollPan requireGestureRecognizerToFail:pan];
Here you let scroll view's pan gesture recogniser know that you only wish scrolling to happen if none of the tiles are bing dragged.
I've checked the approach — it does work indeed. Regarding your code, you'll need to handle all touches in the gesture recogniser rather than Tile view because touch events may be consumed/delayed by hit-tested view's gesture recogniser before they reach the view itself. Please refer to UIGestureRecognizer documentation to learn more about the topic.

It looks as ir one of the views in the hierarchy is capturing the events.
Have a look at the section
The Responder Chain Follows a Specific Delivery Path
Of the Apple doc's here
Edit:
Sorry I was writing from memory. This is how i resolved a similar issue in an app of myself:
I use UITapGestureRecognizer in the view(s) that I want to detect the touch. Implement the following delegate method of the UITapGestureRecognizer:
- (void) touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event
The touches' set contains all the objects (views) that received the event.

Related

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

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):
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:
In touchesBegan I remove the tile from contentView (and add it to the main app view) and display bigImage with shadow.
In touchesMoved I move the tile
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:
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):
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:
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.
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.
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
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.

How to allow sibling UIViews to handle different gestures?

I have a UIView which has two subviews, one is a UIScrollView and the other is a container view for a few other subviews. The container view is covering the scroll view completely.
Views that need to handle gestures:
UIScrollView - should handle the default pinch and pan gestures
Container view - none
Container view subviews - should handle tap gesture
Now in order for the tap gestures to be handled by the container view subviews I implemented pointInside:withEvent: for the container view. If it recognises the point is inside one of its subviews it returns YES. This works fine. The problem is that when I pinch or pan and my finger initially touches one of the container view subviews it doesn't work. When I pinch or pan on an empty area of the container view it works as it should.
Any suggestions how to make it work?
EDIT:
I've implemented hitTest:withEvent: for the main view and got the same behavior.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView *hitTestView;
for (UIView *subview in [self.subviews reverseObjectEnumerator])
{
hitTestView = [subview hitTest:[self convertPoint:point
toView:subview]
withEvent:event];
if (hitTestView && ![hitTestView isKindOfClass:[ContainerView class]])
{
break;
}
}
return hitTestView;
}
On the bottom line the question here is how does one view only handles some gestures and passes on other gestures so an underlying view could handle them.
I've read quite a lot about the subject and tried different approaches but couldn't find a straightforward solution to what seems like a pretty common issue.
You don't actually need to handle pinch and pan gesturese on UIScrollView manually, it's going to happen automatically.
For handling container view subviews you can use UITapGestureRecognizer. For each view you need to handle tap use:
UITapGestureRecognizer* tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(handleTapFirstSubview:)];
[firstSubview addGestureRecognizer:tapRecognizer];
Handler method:
- (void)handleTapFirstSubview:(UITapGestureRecognizer *)tapRecogmizer
{
// handle tap here
}

UIView handle and pass touch events to next sibling view

I'm building an iOS app that has a custom UIView upon a UIScrollView which in turn has a subview.
Here's the layout structure:
Note that the custom UIView(called "Detected Object Hint View") is not a subview of ScrollView, it's a sibling view of UIScrollView. And I want to respond to tap gesture on the custom UIView, so I've added UITapGestureRecognizer to the UIView, and it works for tap, but the UIScrollView will never get any touch events (not responding to scroll or zoom gesture).
I've googled a while, and a lot of people pointed out that in order for other view to respond to the touch events, I should implement the following method:
- (id)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitView = [super hitTest:point withEvent:event];
if (hitView == self){
return nil;
}
else {
return hitView;
}
}
But once I've added this method to my custom UIView, it will not respond to tap gesture either (of course).
So I'm wondering how can I handle the tap gesture on my custom UIView and pass the touch events to UIScrollView as well?
Big thanks!

Detecting clicks outside of UIScrollView

I've implemented a paged scroll according to this technique
( iOS develop. How to extend UIScrollView's scroll event responding area? ) and it works just as intended.
The view that I'm scrolling is containing a couple of buttons and I want to be able to click not only those that are centered/paged into the scrollview but also those to the left and to the right of it. I cannot find any way to solve this but I'm not really an iOS-Jedi yet, hoping one of you are though :)
So as you can see from the screenie the UIScrollView is about a third of the width of the window, the contentsize of the UIScrollView is much larger: about 1500px and contains a lot of buttons added programmatically. The cool thing with this solution, and the part that actually works, is that the buttons:
1) are paged into the scrollview
2) are visible outside the scrollview (since "clip subviews" is unchecked for the scrollview) 3) the buttons are clickable when visible inside the uiscrollview.
BUT what doesn't work is simply this:
- the buttons currently being outside of the window does not receive "their" clicks when clicking on them, the events are instead forwarded to the underlaying (the white part of the window) view.
So,
I finally managed to solve this puzzle and the solution is divided into two
parts. The problem was, as you way recall, that the click events did not travel to the
buttons that were(visible) outside the UIScrollView. It turned out that the clicks were captured by the underlying view and that it is possible to manipulate their way to finding their target by bending the rules a bit regarding who got hit and thereby tricking the events to get passed where you want them. Not really sure if this is how it should be done but it solved my problem.. . :)
1) First one must override the following method in the bottom view
so that it returns the scrollview instead of itself when appropriate.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView *view = [super hitTest:point withEvent:event];
if (view == self)
return [self scrollView];
return view;
}
2) The scrollView must override TWO methods to hand over the clicks to its contained objects.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView *view = [super hitTest:point withEvent:event];
// Always return us.
return view ;
}
and
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
// We want EVERYTHING!
return YES;
}
Thanks a lot for you comments and willingness to help.
I ho
Inspired by the answer #tommys mentioned, it turns out that by overriding the hinTest method of a UIView and return the scrollView instead, you actually can detach the swiping of this UIView to the scrollView.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView *view = [super hitTest:point withEvent:event];
// Doing this make you detached the swiping to the scrollView
if (view == self)
return [self scrollView];
return view;
}
So this UIView is acting like an extension scroll area of the scrollView, the idea is here. If you make the UIView mask over the scrollView and same size of the window, then swiping anywhere inside the window makes the scrollView scroll.
Here is the example, ExtensionScrollArea
Here's my version:
hit test in container
- (UIView *) hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if ( CGRectContainsPoint( self.frame, point ) && ! self.hidden ) // <-- *
{
if ( ! CGRectContainsPoint( scrollView.frame, point ) )
return scrollView;
}
return [super hitTest:point withEvent:event];
}
(*) This marked line is important if you are moving about or otherwise hiding your view, for instance if you have multiple views, each with their own scrollviews. If you don't have this line, you may be directing all your touches to an off-screen scrollview!
override in scrollview
- (BOOL) pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
return YES;
}
(in the hitTest of the container, you can exclude additional frames within the if statement for default behaviour) :)

Detecting swipe gestures on UITableViewCell inside UIScrollView

I am hoping someone will be able to help me with a problem that is doing my head in at the moment!
Given the following view hierarchy
I want to be able to detect swipe gestures on my custom UITableViewCell.
I have subclassed the UIScrollView and have a hitTest:withEvent: method that checks whether I am touching the tableview cell (or its content) or not, in which case I set the following scroll view properties:
- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView* result = [super hitTest:point withEvent:event];
if ([result.superview isKindOfClass:[UITableViewCell class]] || [result.superview tag] == SUBVIEW_TAG)
{
self.canCancelContentTouches = NO;
self.delaysContentTouches = YES;
} else {
self.canCancelContentTouches = YES;
self.delaysContentTouches = NO;
}
return result;
}
I have also implemented:
- (BOOL)touchesShouldCancelInContentView:(UIView *)view
{
if (view.tag == SUBVIEW_TAG || [[view superview] isKindOfClass:[UITableViewCell class]])
return NO;
return YES;
}
And am returning NO in case the view being touched is the table view cell.
These methods are all getting called and performing their actions as expected, but I am still unable to stop the UIScrollView from "hogging" the swipe gesture.
The interesting thing is that if I include the UIView that contains the tableview and cell on both of the methods above (the one with SUBVIEW_TAG) it works perfectly so I am guessing it must be something to do with the fact that UITableView inherits from UIScrollView.
My main goal is to be able to swipe on the cell to reveal more options for the cell. A horizontal swipe anywhere else on that view would be captured by the scroll view and shift the content horizontally as per its normal behaviour.
Any ideas would be very much appreciated!
Thanks!
Rog
I had a similar problem with a swipe detect for a component inside a scrollview and I was able to resolve it with
[scrollView.panGestureRecognizer requireGestureRecognizerToFail:swipeGesture]
Where scrollView is the scroll view object that acts like container and swipeGesture is the component swipe gesture object inside scrollview.
So, you can define a swipe for the cell object like this (for right swipe in the example, custom it as you want)
UISwipeGestureRecognizer* rightSwipeRecognizer = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:#selector(yourMethod)];
[rightSwipeRecognizer setDirection:UISwipeGestureRecognizerDirectionLeft];
[cell addGestureRecognizer:rightSwipeRecognizer];
and then do
[scrollView.panGestureRecognizer requireGestureRecognizerToFail:rightSwipeRecognizer]
The documentation of requireGestureRecognizerToFail says:
This method creates a relationship with another gesture recognizer
that delays the receiver’s transition out of
UIGestureRecognizerStatePossible. The state that the receiver
transitions to depends on what happens with otherGestureRecognizer:
If otherGestureRecognizer transitions to
UIGestureRecognizerStateFailed, the receiver transitions to its normal
next state.
if otherGestureRecognizer transitions to
UIGestureRecognizerStateRecognized or UIGestureRecognizerStateBegan,
the receiver transitions to UIGestureRecognizerStateFailed.
An example where this method might be called is when you want a
single-tap gesture require that a double-tap gesture fail.
Availability Available in iOS 3.2 and later.
Hope helps!
The solution is pretty simple. All you need to do is add UIScrollView inside you UITableViewCell. It will prevent "hogging" effect during swipe gesture.

Resources