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

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.

Related

Draggable UIView stops posting touchesBegan after being added to UIScrollView

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.

UIScrollView inside UIPageViewController: 1 and 2 finger swipe gestures

Structure:
I have one UIPageViewController, currently containing two UIViewController (called e.g. MenuVC and ImageScrollVC).
In the second UIViewController (the ImageScrollVC) I have a UIScrollView. This UIScrollView contains multiple images. The user should be able to swipe between the images.
I want:
The UIPageViewController should only scroll when the user is using two fingers.
** So the user can swipe between the 'MenuVC' and the ImageScrollVC with two fingers, anytime.
The UIScrollView should only scroll when the user is using one finger.
** With the one finger swipe gesture the user can change the images.
Is this possible?
My assumption is to forward a two finger swipe gesture to the underlying UIPageViewController, but I don't know how to do this.
Update:
My ViewController structure:
RootViewController is implementing the UIPageViewController (exactly like the Apple template).
MenuViewControlleris the first ViewController in the PageViewController and ImageScrollViewController the second one with the UIScrollView for the images.
Your assumption is right on. Normally your UIScrollView in the nested ImageScrollVC will handle both one- and two-finger swipes. You can use a custom subclass of UIScrollView in your ImageScrollVC to ignore two-finger swipes so that they will continue up the chain and be handled by the UIPageViewController. For more details, check out this related question, from which the sample code below is based.
The gist of it is that you'll implement -gestureRecognizerShouldBegin: in your UIScrollView subclass; only return YES if the superclass returns YES, and if the number of touches is 1.
-(BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer*)recognizer
{
return recognizer.numberOfTouches == 1 && [super gestureRecognizerShouldBegin:recognizer];
}
I think I solved it. It feels that the solution is not the best or nicest one, but it works.
What I did:
I created a new UIPanGestureRecognizer and added it to the imageScrollView. This gestureRecognizer has min. and max. touches to 2. In the action method of this recognizer I enabled and disabled scrolling of the imageScrollView.
Also I created a own subclass of UIScrollView and implemented the gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer to return YES.
In code:
MenuViewController: My first UIViewController in the pageVC. The class is doing nothing (for the purpose of this problem). This is only a placeholder to swipe with two fingers and have something to show.
RootViewController:
self.pageViewController = [[UIPageViewController alloc] initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal options:nil];
//... exactly the same code as in the Apple template
//the UIPageViewController should only use 2 finger:
for (UIView* view in [self.pageViewController.view subviews]) {
if ([view isKindOfClass:[UIScrollView class]]) {
UIScrollView* scrollView = (UIScrollView*)view;
scrollView.panGestureRecognizer.minimumNumberOfTouches = 2;
scrollView.panGestureRecognizer.maximumNumberOfTouches = 2;
}
}
ImageScrollViewController:
//self.scrollView is from type 'CustomScrollView'
-(void)viewDidLoad {
[super viewDidLoad];
// autolayout would be nicer, but this was faster and easier for me
self.scrollView.contentSize = CGSizeMake(self.view.frame.size.width * _pageImages.count, self.scrollView.frame.size.height);
//... a for loop to add all UIImageViews as a subView to self.scrollView
UIPanGestureRecognizer *twoFingerPan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(handlePan:)];
twoFingerPan.minimumNumberOfTouches = 2;
twoFingerPan.maximumNumberOfTouches = 2;
[self.scrollView addGestureRecognizer:twoFingerPan];
twoFingerPan.delegate = (CarScrollView*)self.scrollView;
}
//the inner scrollView for the images should not scroll when two fingers are active
- (void)handlePan:(UIPanGestureRecognizer*)recognizer {
switch (recognizer.state) {
case UIGestureRecognizerStateBegan:
case UIGestureRecognizerStatePossible:
self.scrollView.scrollEnabled = NO;
break;
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateEnded:
case UIGestureRecognizerStateFailed:
self.scrollView.scrollEnabled = YES;
default:
break;
}
}
CustomScrollView: I created a subclass for the UIScrollView with the images.
#interface CarScrollView : UIScrollView <UIGestureRecognizerDelegate>
#implementation CarScrollView
//Method has to return YES so that a two finger pan gesture will be used for the outer scrollView/pageViewController
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}

iOS Programmatic Zoom in UIScrollView Not Working

I'm new to iOS and I've been stuck for a while on this. My goal is to programmatically zoom to a specific spot when the user pinches anywhere in the view. This may seem strange, but it's an internal app not for the App Store. Right now, when I pinch to zoom, nothing happens.
On my View (it's a single view application), I have images that I'd like to zoom in on programmatically. Here's what I have done.
In the Interface Builder, I dragged a Scroll View object onto my View, and changed the size to fit the View
In the Scroll View, I set the minimum zoom = 0 and the maximum zoom = 1000 (just to make sure this wasn't the problem)
I dragged a Pinch Gesture Recognizer on top of the UIScrollView.
I right-clicked the UIGestureRecognizer, and created an action called handlePinch() where I placed my code to programmatically zoom when the pinch was recognized.
Implemented the zoom in handlePinch (shown below)
- (IBAction)handlePinch:(UIPinchGestureRecognizer *)sender {
CGRect zoomRect;
zoomRect.origin.x = 500;
zoomRect.origin.y = 500;
zoomRect.size.width = 512;
zoomRect.size.height = 384;
[scrollView zoomToRect:zoomRect animated:YES ];
}
Known:
- The pinch gesture is being recognized, and handlePinch is being called
Thank you for any answers or suggestions in advance!
You don't need a pinch gesture to do this, since the zooming is built in. Here's what to do.
In your view controller header file, add the scroll view delegate:
#interface MyViewController : UIViewController <UIScrollViewDelegate>
Next in the implementation file, preferably in your viewDidLoad, set the scroll view's delegate to self:
- (void)viewDidLoad {
//other stuff
self.scrollView.delegate = self;
}
Then, copy and paste this scroll view delegate method into your implementation file:
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
if (scrollView == self.scrollView) {
return self.viewToBeZoomed;
}
}
self.viewToBeZoomed is the view you want to zoom. This view is a subview of self.scrollView.

Stopping MapView's subviews from propagating touches

When I add a subview to a MKMapView or to any of it's descendent views, touches will pass right through and affect the map.
This question is ultimately used to explain a bug that was fixed for my custom callout which adds a view to an annotation view. The callout is not to be dismissed when the callout is touched and this question is in reference to that. But for the case brevity I'll use a smaller example that demonstrates the problem.
Setting exclusiveTouch and userInteractionEnabled doesn't do anything. BTW.
Let's take a UIView and attach it to the mapView
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]
view.backgroundColor = [UIColor blackColor];
[mapView addSubview:view];
And let's add a gesture to the map as well
[mapView addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:#selector(handleLongPress:)]];
And just have handleLongPress print something NSLog(#"longpress"); Now run the program and observe how long pressing the black uiview will cause the longPress to be triggered. If we add an annotation that shows a callout and select it, touching the black uiview will dismiss it. And if we scroll the map so that the annotation is under the black uiview, we'll be able to select the annotation. Now here's my solution. We tag the view that we're going to add to mapview with SOME_TAG and overwrite the hitTest method for MKMapView
#interface MKMapView(HackySolution)
#end
#implementation MKMapView(HackySolution)
- (UIView *) hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *view = [super hitTest:point withEvent:event];
// block touch propagation
if(view.tag == SOME_TAG){
view = nil;
}
return view;
}
#end
Now here's the deal, I may have solved my problem, but I really don't know why touches from the UIView are being passed right through onto the map. It doesn't make sense for a touch to a subview to be passed onto it's parentView. Maybe I'm not understanding how touches work. I mean if I place a button on a normal UIView and place another uiview on top of it, completely obscuring it, the button wouldn't get the touch right? I'm starting to second guess myself. :/
I can only guess that MKMapView is wired to do this, but I'm unsure. I thought there was a scroll view somewhere within MKMapView, but I ran through all the subviews and all the descendents and didn't find anything.
Is there a better way to solve this problem or is this the best it gets?
Any insight into the situation would be greatly appreciated.

UIGestureRecognizer subView recognizer problem

The title is hard .
The the main case is like this
UIView *superView = [[UIView alloc] initWithFrame:CGRectMake(0,0,400,400)];
UIView *subView = [[UIView alloc] initWithFrame:CGRectMake(-200,-200,400,400)];
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(tapAction:)];
[subView addGestureRecognizer:tapGesture];
[superView addSubView:subView];
OK , you will find that the tap gesture will take effect when you click the area in (0,0,200,200) , if you click the point (-150,-150) the tap gesture will not take effect.
I don't know whether the click outside the superView bounds to cause this problem or not.
Anyone have any idea how to fix this?
To allow subviews lying outside of the superview to respond to touch, override hitTest:withEvent: of the superview.
Documentation on Event Delivery
Touch events. The window object uses hit-testing and the responder chain to find the view to receive the touch event. In hit-testing, a window calls hitTest:withEvent: on the top-most view of the view hierarchy; this method proceeds by recursively calling pointInside:withEvent: on each view in the view hierarchy that returns YES, proceeding down the hierarchy until it finds the subview within whose bounds the touch took place. That view becomes the hit-test view.
Create a subclass of UIView.
Override hitTest:withEvent.
Use this UIView subclass for the superview.
Add method below in subclass:
(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
NSEnumerator *reverseE = [self.subviews reverseObjectEnumerator];
UIView *iSubView;
while ((iSubView = [reverseE nextObject])) {
UIView *viewWasHit = [iSubView hitTest:[self convertPoint:point toView:iSubView] withEvent:event];
if(viewWasHit) {
return viewWasHit;
}
}
return [super hitTest:point withEvent:event];
}
Note: Reverse enumerator used since subviews are ordered from back to front and we want to test the front most view first.
The only workaround I've found for case like that is to create an instance of a view that is transparent for touches as main view. In such case inner view will respond to touches as it fits bounds of main. In the class I've made from different examples found in the net I can control the level of "touch visibility" like so:
fully visible - all of the touches end up in the view.
only subviews - the view itself invisible, but subviews get their touches.
fully invisible - pretty self explanatory I think :)
I didn't try to use it with gesture recognizers, but I don't think there will be any problem, as it works perfectly with regular touches.
The code is simple...
TransparentTouchView.h
#import <UIKit/UIKit.h>
typedef enum{
TransparencyTypeNone = 0, //act like usual uiview
TransparencyTypeContent, //only content get touches
TransparencyTypeFull //fully transparent for touches
}TransparencyType;
#interface TransparentTouchView : UIView {
TransparencyType _transparencyType;
}
#property(nonatomic,assign)TransparencyType transparencyType;
#end
TransparentTouchView.m
#import "TransparentTouchView.h"
#implementation TransparentTouchView
#synthesize
transparencyType = _transparencyType;
- (id)initWithFrame:(CGRect)frame{
self = [super initWithFrame:frame];
if (self) {
// Initialization code
self.backgroundColor = [UIColor clearColor];
}
return self;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
// UIView will be "transparent" for touch events if we return NO
switch (_transparencyType) {
case TransparencyTypeContent:
for(UIView* subview in self.subviews){
CGPoint p = [subview convertPoint:point fromView:self];
if([subview pointInside:p withEvent:event]){
return YES;
}
}
return NO;
break;
case TransparencyTypeFull:
return NO;
default:
break;
}
return YES;
}
#end
I believe that you can accomodate it to your needs.

Resources