Detect horizontal panning in UITableView - objective-c

I'm using a UIPanGestureRecognizer to recognize horizontal sliding in a UITableView (on a cell to be precise, though it is added to the table itself). However, this gesture recognizer obviously steals the touches from the table. I already got the pangesturerecognizer to recognize horizontal sliding and then snap to that; but if the user starts by sliding vertical, it should pass all events from that touch to the tableview.
One thing i have tried was disabling the recognizer, but then it wouldn't scroll untill the next touch event. So i'd need it to pass the event right away then.
Another thing i tried was making it scroll myself, but then you will miss the persistent speed after stopping the touch.
Heres some code:
//In the viewdidload method
UIPanGestureRecognizer *slideRecognizer = [[UIPanGestureRecognizer alloc]initWithTarget:self action:#selector(sliding:)];
[myTable addGestureRecognizer:slideRecognizer];
-(void)sliding:(UIPanGestureRecognizer *)recognizer
{
if (recognizer.state == UIGestureRecognizerStateBegan)
{
CGPoint translation = [recognizer translationInView:favoritesTable];
if (sqrt(translation.x*translation.x)/sqrt(translation.y*translation.y)>1) {
horizontalScrolling = YES; //BOOL declared in the header file
NSLog(#"horizontal");
//And some code to determine what cell is being scrolled:
CGPoint slideLocation = [recognizer locationInView:myTable];
slidingCell = [myTable indexPathForRowAtPoint:slideLocation];
if (slidingCell.row == 0) {
slidingCell = nil;
}
}
else
{
NSLog(#"cancel");
}
if (recognizer.state == UIGestureRecognizerStateEnded || recognizer.state == UIGestureRecognizerStateCancelled)
{
horizontalScrolling = NO;
}
if (horizontalScrolling)
{
//Perform some code
}
else
{
//Maybe pass the touch from here; It's panning vertically
}
}
So, any advice on how to pass the touches?
Addition: I also thought to maybe subclass the tableview's gesture recognizer method, to first check if it's horizontal; However, then i would need the original code, i suppose... No idea if Apple will have problems with it.
Also: I didn't subclass the UITableView(controller), just the cells. This code is in the viewcontroller which holds the table ;)

I had the same issue and came up with a solution that works with the UIPanGestureRecognizer.
In contrast to Erik I've added the UIPanGestureRecognizer to the cell directly, as I need just one particular cell at once to support the pan. But I guess this should work for Erik's case as well.
Here's the code.
- (BOOL)gestureRecognizerShouldBegin:(UIPanGestureRecognizer *)gestureRecognizer
{
UIView *cell = [gestureRecognizer view];
CGPoint translation = [gestureRecognizer translationInView:[cell superview]];
// Check for horizontal gesture
if (fabsf(translation.x) > fabsf(translation.y))
{
return YES;
}
return NO;
}
The calculation for the horizontal gesture is copied form Erik's code – I've tested this with iOS 4.3.
Edit:
I've found out that this implementation prevents the "swipe-to-delete" gesture. To regain that behavior I've added check for the velocity of the gesture to the if-statement above.
if ([gestureRecognizer velocityInView:cell].x < 600 && sqrt(translate...
After playing a bit on my device I came up with a velocity of 500 to 600 which offers in my opinion the best user experience for the transition between the pan and the swipe-to-delete gesture.

My answer is the same as Florian Mielke's, but I've simplified and corrected it some.
How to use:
Simply give your UIPanGestureRecognizer a delegate (UIGestureRecognizerDelegate). For example:
UIPanGestureRecognizer *panner = [[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(panDetected:)];
panner.delegate = self;
[self addGestureRecognizer:panner];
Then have that delegate implement the following method:
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
CGPoint translation = [(UIPanGestureRecognizer *)gestureRecognizer translationInView:gestureRecognizer.view.superview];
return fabsf(translation.x) > fabsf(translation.y);
}

Maybe you can use the UISwipeGestureRecognizer instead? You can tell it to ignore up/down swipes via the direction property.

You may try using the touch events manually instead of the gesture recognizers. Always passing the event back to the tableview except when you finally recognize the swipe gesture.
Every class that inherits from UIResponder will have the four touch functions (began, ended, canceled, and moved). So the simplest way to "forward" a call is to handle it in your class and then call it explicitly on the next object that you would want to handle it (but you should make sure to check if the object responds to the message first with respondsToSelector: since it is an optional function ). This way, you can detect whatever events you want and also allow the normal touch interaction with whatever other elements need it.

Thanks for the tips! I eventually went for a UITableView subclass, where i check if the movement is horizontal (in which case i use my custom behaviour), and else call [super touchesMoved: withEvent:];.
However, i still don't really get why this works. I checked, and super is a UITableView. It appears i still don't fully understand how this hierarchy works. Can someone try and explain?

Related

After bouncing of table to top App get crash [duplicate]

Here's how the scroll views work: One scroll view is paging enabled in the horizontal direction. Each 'page' of this scroll view contains a vertically scrolling UITableView. Without modification, this works OK, but not perfectly.
The behaviour that's not right: When the user scrolls up and down on the table view, but then wants to flick over to the next page quickly, the horizontal flick/swipe will not work initially - it will not work until the table view is stationary (even if the swipe is very clearly horizontal).
How it should work: If the swipe is clearly horizontal, I'd like the page to change even if the table view is still scrolling/bouncing, as this is what the user will expect too.
How can I change this behaviour - what's the easiest or best way?
NOTE For various reasons, a UIPageViewController as stated in some answers will not work. How can I do this with cross directional UIScrollViews (/one is a table view, but you get the idea)? I've been banging my head against a wall for hours - if you think you can do this then I'll more than happily award a bounty.
According to my understanding of the question, it is only while the tableView is scrolling we want to change the default behaviour. All the other behaviour will be the same.
SubClass UITableView. UITableViews are subClass of UIScrollViews. On the UITableView subClass implement one UIScrollView's UIGestureRecognizer's delegate method
- (BOOL)gestureRecognizer:(UIPanGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UISwipeGestureRecognizer *)otherGestureRecognizer
{
//Edit 1
//return self.isDecelerating;
//return self.isDecelerating | self.bounces; //If we want to simultaneous gesture on bounce and scrolling
//Edit 2
return self.isDecelerating || self.contentOffset.y < 0 || self.contentOffset.y > MAX(0, self.contentSize.height - self.bounds.size.height); // #Jordan edited - we don't need to always enable simultaneous gesture for bounce enabled tableViews
}
As we only want to change the default gesture behaviour while the tableView is decelerating.
Now change all 'UITableView's class to your newly created tableViewSubClass and run the project, swipe should work while tableView is scrolling. :]
But the swipe looks a little too sensitive while tableView is scrolling. Let's make the swipe a little restrictive.
SubClass UIScrollView. On the UIScrollView subclass implement another UIGestureRecognizer's delegate method gestureRecognizerShouldBegin:
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
if ([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
CGPoint velocity = [(UIPanGestureRecognizer *)gestureRecognizer velocityInView:self];
if (abs(velocity.y) * 2 < abs(velocity.x)) {
return YES;
}
}
return NO;
}
We want to make the "swipe is clearly horizontal". Above code only permits gesture begin if the gesture velocity on x axis is double than on y axis. [Feel free to increase the hard coded value "2" if your like. The higher the value the swipe needs to be more horizontal.]
Now change the `UiScrollView' class (which has multiple TableViews) to your ScrollViewSubClass. Run the project. :]
I've made a project on gitHub https://github.com/rishi420/SwipeWhileScroll
Although apple doesn't like this method too much:
Important: You should not embed UIWebView or UITableView objects in UIScrollView objects. If you do so, unexpected behavior can result
because touch events for the two objects can be mixed up and wrongly
handled.
I've found a great way to accomplish this.
This is a complete solution for the problem. In order to scroll the UIScrollView while your UITableView is scrolling you'll need to disable the interaction you have it.
- (void)viewDidLoad
{
[super viewDidLoad];
_myScrollView.contentSize = CGSizeMake(2000, 0);
data = [[NSMutableArray alloc]init];
for(int i=0;i<30;i++)
{
[data addObject:[NSString stringWithFormat:#"%d",i]];
}
UITapGestureRecognizer * tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(handleTap:)];
[self.view addGestureRecognizer:tap];
}
- (void)handleTap:(UITapGestureRecognizer *)recognizer
{
[_myTableView setContentOffset:_myTableView.contentOffset animated:NO];
}
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView
{
scrollView.userInteractionEnabled = NO;
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
scrollView.userInteractionEnabled = YES;
}
To sum up the code above, if the UITableView is scrolling, set userInteractionEnabled to NO so the UIScrollView will detect the swipe. If the UITableView is scrolling and the user taps on the screen, userInteractionEnabled will be set to YES.
Instead of using UIScrollView as a container for these multiple table views, try using a UIPageViewController.
You can even integrate this into your existing view controller setup as a child view controller (directly replacing the UIScrollView).
In addition, you'll likely want to implement the required methods from UIPageViewControllerDataSource and possibly one or more of the methods from UIPageViewControllerDelegate.
Did you try the methods : directionalLockEnabled of both your table and scroll and set them up to horizontal for one and vertical for the other ?
Edit :
1)
What you want to do is very complicate since the touch wait some time (like 0.1s) to know what your movement will be. And if your table is moving, it will take your touch immediately whatever it is (because it's suppose to be reactive movement on it).
I don't see any other solution for you but to override touch movement from scratch to detect immediately the kind of mouvement you want (like if the movement will be horizontal) but it will be more than hard to do it good.
2)
Another solution I can advise you is to make your table have left and right margin, where you can touch the parent scroll (pages thing so) and then even if your table is scrolling, if you touch here, only your paging scroll will be touched. It's simpler, but could not fit with your design maybe...
Use UIPageViewController and in the -viewDidLoad method (or any other method what best suits your needs or design) get UIPageViewController's UIScrollView subview and assign a delegate to it. Keep in mind that, its delegate property won't be nil. So optionally, you can assign it to another reference, and then assign your object, which conforms to UIScrollViewDelegate, to it. For example:
id<UIScrollViewDelegate> originalPageScrollViewDelegate = ((UIScrollView *)[pageViewController.view.subviews objectAtIndex:0]).delegate;
[((UIScrollView *)[pageViewController.view.subviews objectAtIndex:0]) setDelegate:self];
So that you can implement UIScrollViewDelegate methods with ease. And your UIPageViewController will call your delegate's -scrollViewDidScroll: method.
By the way, you may be obliged to keep original delegate, and respond to delegate methods with that object. You can see an example implementation in ViewPagerController class on my UI control project here
I faced the same thing recently. My UIScrollview was on paging mode and every page contained a UITableView and like you described it worked but not as you'd expected it to work. This is how solved it.
First I disabled the scrolling of the UIScrollview
Then I added a UISwipeGestureRecognizer to the actual UITableView for left and right swipes.
The action for those swipes were:
[scroll setContentOffset:CGPointMake(currentPointX + 320, PointY) animated:YES];
//Or
[scroll setContentOffset:CGPointMake(currentPointX - 320 , PointY) animated:YES];
This works flawlessly, the only down side is that if the user drags his finger on the UITableVIew that will be considered as a swipe. He won't be able to see half of screen A and half of screen B on the same screen.
You could subclass your scroll view and your table views, and add this gesture recognizer delegate method to each of them...
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:
(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}
I can't be sure this is exactly what you are after, but it may come close.

UIGestureRecognizer blocking tableview scrolling

I have a table with static cells. One of these cells has a view in it with a pan gesture recogniser on it.
When I am scrolling down my tableview, when I get to the cell with the view with pan gesture recogniser, scrolling doesn't seem to work. If I touch outside the view (to the side or top or bottom) it works and I can scroll. I have an if statement in my gesturerecognizer that tests whether a certain area has been touched, and if so performs an action.
I have looked at this issue (http://stackoverflow.com/questions/3295239/uigesturerecognizer-blocking-table-view-scrolling) but setting cancelsTouchesInView to NO didn't work, I don't have anywhere setting the state property and using the method - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
I don't know where to get the 'otherGestureRecognizer' from or what object to call that method on.
I'm assuming I wan't to put my gesture recogniser as the first argument, and the tableview's scroll gesture recogniser as the otherGestureRecogniser, is that correct? If so, how do I get that?
UIPanGestureRecognizer *windPanGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(moveWindHandle:)];
[self.windRangeView addGestureRecognizer:windPanGesture];
Then in my moveWindHandle:
-(void)moveWindHandle:(UIPanGestureRecognizer *)gesture
{
gesture.cancelsTouchesInView = NO;
isMovingHandle = [self isPoint:startedTouchAt insideHandle:_toHandleWindImageView];
if(isMovingHandle) {
if(gesture.state == UIGestureRecognizerStateBegan) {
//do stuff
}
}
else
{
//i want it to ignore this gesture and just scroll like normal if that is what hte user did
}
}
I have set the tableviewcontroller as a UIGestureRecognizerDelegate, but I don't know what to do with that.
You would not be the one calling -gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:. That method is called by the system. You need to set your table view controller as the delegate for your window pan gesture.
windPanGesture.delegate = self;
At that point, when you do the pan, the system will call the delegate method -gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer: with your gesture recognizer as one argument and the scroll view's gesture recognizer as the other.
Update
You may also want to implement the -gestureRecognizerShouldBegin: method and return NO if you are not in one of the certain areas.

UIPageViewController Traps All UITapGestureRecognizer Events

It's been a long day at the keyboard so I'm reaching out :-)
I have a UIPageViewController in a typical implementation that basically follows Apple's standard template. I am trying to add an overlay that will allow the user to do things like touch a button to jump to certain pages or dismiss the view controller to go to another part of the app.
My problem is that the UIPageViewController is trapping all events from my overlay subview and I am struggling to find a workable solution.
Here's some code to help the example...
In viewDidLoad
// Page creation, pageViewController creation etc....
self.pageViewController.delegate = self;
[self.pageViewController setViewControllers:pagesArray
direction:UIPageViewControllerNavigationDirectionForward
animated:NO
completion:NULL];
self.pageViewController.dataSource = self;
[self addChildViewController:self.pageViewController];
[self.view addSubview:self.pageViewController.view];
// self.overlay being the overlay view
if (!self.overlay)
{
self.overlay = [[MyOverlayClass alloc] init]; // Gets frame etc from class init
[self.view addSubview:self.overlay];
}
This all works great. The overlay gets created, it gets show over the top of the pages of the UIPageViewController as you would expect. When pages flip, they flip underneath the overlay - again just as you would expect.
However, the UIButtons within the self.overlay view never get the tap events. The UIPageViewController responds to all events.
I have tried overriding -(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch per the suggestions here without success.
UIPageViewController Gesture recognizers
I have tried manually trapping all events and handling them myself - doesn't work (and to be honest even if it did it would seem like a bit of a hack).
Does anyone have a suggestion on how to trap the events or maybe a better approach to using an overlay over the top of the UIPageViewController.
Any and all help very much appreciated!!
Try to iterate through UIPageViewController.GestureRecognizers and assign self as a delegate for those gesture and implement
-(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch;
Your code may be like this:
In viewDidLoad
for (UIGestureRecognizer * gesRecog in self.pageViewController.gestureRecognizers)
{
gesRecog.delegate = self;
}
And add the following method:
-(BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
{
if (touch.view != self.pageViewController.view]
{
return NO;
}
return YES;
}
The documented way to prevent the UIPageViewController from scrolling is to not assign the dataSource property. If you assign the data source it will move into 'gesture-based' navigation mode which is what you're trying to prevent.
Without a data source you manually provide view controllers when you want to with setViewControllers:direction:animated:completion method and it will move between view controllers on demand.
The above can be deduced from Apple's documentation of UIPageViewController (Overview, second paragraph):
To support gesture-based navigation, you must provide your view controllers using a data source object.

Continuous scrolling between UIPanGestureRecognizer and re-enabled UIScrollView

I've got a UIScrollView with paging enabled, and I've added my own UIPanGestureRegonizer to it. Under certain instances, my view controller will set scrollview.scrollEnabled = NO, and then add the pan gesture recognizer to it (I'm not using the scrollview's own recognizer).
So, scrolling is disabled but I'm waiting for user touches from my gesture recognizer. When it recognizes, it calls its action in which I re-enable scrolling.
The problem is, while the user still has a finger down, my scrollview doesn't track with the finger. It doesn't start scrolling until the finger is lifted and then dragged again. So my gesture recognizer is swallowing all the touches and not forwarding any to the scrollview.
I've tried toggling panGestureRecognizer.cancelsTouchesInView = NO; but it doesn't seem to have any effect (I'm currently removing this recognizer as soon as I re-enable scrolling but whether I do this or not doesn't solve my problem). I've also looked into the delays... properties of UIGestureRecognizer but they don't seem to be helping, either.
Any ideas? How can I get these events to continue to forward to my scrollview?
The answer is a bit easier if you are only targeting iOS 5 and up, because in that case you really ought to reuse the UIScrollView panGestureRecognizer property.
In any case, the key step is to NOT reuse scrollEnabled, but instead to subclass UIScrollView, create your own property to manage this state, and override setContentOffset:.
- (void) setContentOffset:(CGPoint)contentOffset
{
if(self.programaticScrollEnabled)
[super setContentOffset:contentOffset];
}
Here's one possible iOS 4+ Solution:
Subclass UIScrollView (or subclass another subclass of UIScrollView, depending on your needs).
Override all the initializers to ensure your setup code is called.
Declare the BOOL property and override setContentOffset: as described above.
In your setup code, set up a UIPanGestureRecognizer and set your state variable to allow programatic scrolling (assuming that's the default state you want):
panRecognizer = [[[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(handleGesture:)] autorelease];
//These properties may change according to your needs
panRecognizer.cancelsTouchesInView = NO;
panRecognizer.delaysTouchesBegan = NO;
panRecognizer.delaysTouchesEnded = NO;
[self addGestureRecognizer:panRecognizer];
panRecognizer.delegate = self;
self.programaticScrollEnabled = YES;
Manage which gestures can occur simultaneously. In my case:
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
return YES;
}
Turn programatic scrolling back on wherever you need it. For example:
- (void)handleGesture:(UIPanGestureRecognizer *)gestureRecognizer
{
self.programaticScrollEnabled = YES;
}
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
self.programaticScrollEnabled = YES;
return YES;
}

How can you add a UIGestureRecognizer to a UIBarButtonItem as in the common undo/redo UIPopoverController scheme on iPad apps?

Problem
In my iPad app, I cannot attach a popover to a button bar item only after press-and-hold events. But this seems to be standard for undo/redo. How do other apps do this?
Background
I have an undo button (UIBarButtonSystemItemUndo) in the toolbar of my UIKit (iPad) app. When I press the undo button, it fires it's action which is undo:, and that executes correctly.
However, the "standard UE convention" for undo/redo on iPad is that pressing undo executes an undo but pressing and holding the button reveals a popover controller where the user selected either "undo" or "redo" until the controller is dismissed.
The normal way to attach a popover controller is with presentPopoverFromBarButtonItem:, and I can configure this easily enough. To get this to show only after press-and-hold we have to set a view to respond to "long press" gesture events as in this snippet:
UILongPressGestureRecognizer *longPressOnUndoGesture = [[UILongPressGestureRecognizer alloc]
initWithTarget:self
action:#selector(handleLongPressOnUndoGesture:)];
//Broken because there is no customView in a UIBarButtonSystemItemUndo item
[self.undoButtonItem.customView addGestureRecognizer:longPressOnUndoGesture];
[longPressOnUndoGesture release];
With this, after a press-and-hold on the view the method handleLongPressOnUndoGesture: will get called, and within this method I will configure and display the popover for undo/redo. So far, so good.
The problem with this is that there is no view to attach to. self.undoButtonItem is a UIButtonBarItem, not a view.
Possible solutions
1) [The ideal] Attach the gesture recognizer to the button bar item. It is possible to attach a gesture recognizer to a view, but UIButtonBarItem is not a view. It does have a property for .customView, but that property is nil when the buttonbaritem is a standard system type (in this case it is).
2) Use another view. I could use the UIToolbar but that would require some weird hit-testing and be an all around hack, if even possible in the first place. There is no other alternative view to use that I can think of.
3) Use the customView property. Standard types like UIBarButtonSystemItemUndo have no customView (it is nil). Setting the customView will erase the standard contents which it needs to have. This would amount to re-implementing all the look and function of UIBarButtonSystemItemUndo, again if even possible to do.
Question
How can I attach a gesture recognizer to this "button"? More specifically, how can I implement the standard press-and-hold-to-show-redo-popover in an iPad app?
Ideas? Thank you very much, especially if someone actually has this working in their app (I'm thinking of you, omni) and wants to share...
Note: this no longer works as of iOS 11
In lieu of that mess with trying to find the UIBarButtonItem's view in the toolbar's subview list, you can also try this, once the item is added to the toolbar:
[barButtonItem valueForKey:#"view"];
This uses the Key-Value Coding framework to access the UIBarButtonItem's private _view variable, where it keeps the view it created.
Granted, I don't know where this falls in terms of Apple's private API thing (this is public method used to access a private variable of a public class - not like accessing private frameworks to make fancy Apple-only effects or anything), but it does work, and rather painlessly.
This is an old question, but it still comes up in google searches, and all of the other answers are overly complicated.
I have a buttonbar, with buttonbar items, that call an action:forEvent: method when pressed.
In that method, add these lines:
bool longpress=NO;
UITouch *touch=[[[event allTouches] allObjects] objectAtIndex:0];
if(touch.tapCount==0) longpress=YES;
If it was a single tap, tapCount is one. If it was a double tap, tapCount is two. If it's a long press, tapCount is zero.
Option 1 is indeed possible. Unfortunately it's a painful thing to find the UIView that the UIBarButtonItem creates. Here's how I found it:
[[[myToolbar subviews] objectAtIndex:[[myToolbar items] indexOfObject:myBarButton]] addGestureRecognizer:myGesture];
This is more difficult than it ought to be, but this is clearly designed to stop people from fooling around with the buttons look and feel.
Note that Fixed/Flexible spaces are not counted as views!
In order to handle spaces you must have some way of detecting them, and sadly the SDK simply has no easy way to do this. There are solutions and here are a few of them:
1) Set the UIBarButtonItem's tag value to it's index from left to right on the toolbar. This requires too much manual work to keep it in sync IMO.
2) Set any spaces' enabled property to NO. Then use this code snippet to set the tag values for you:
NSUInteger index = 0;
for (UIBarButtonItem *anItem in [myToolbar items]) {
if (anItem.enabled) {
// For enabled items set a tag.
anItem.tag = index;
index ++;
}
}
// Tag is now equal to subview index.
[[[myToolbar subviews] objectAtIndex:myButton.tag] addGestureRecognizer:myGesture];
Of course this has a potential pitfall if you disable a button for some other reason.
3) Manually code the toolbar and handle the indexes yourself. As you'll be building the UIBarButtonItem's yourself, so you'll know in advance what index they'll be in the subviews. You could extend this idea to collecting up the UIView's in advance for later use, if necessary.
Instead of groping around for a subview you can create the button on your own and add a button bar item with a custom view. Then you hook up the GR to your custom button.
While this question is now over a year old, this is still a pretty annoying problem. I've submitted a bug report to Apple (rdar://9982911) and I suggest that anybody else who feels the same duplicate it.
You also can simply do this...
let longPress = UILongPressGestureRecognizer(target: self, action: "longPress:")
navigationController?.toolbar.addGestureRecognizer(longPress)
func longPress(sender: UILongPressGestureRecognizer) {
let location = sender.locationInView(navigationController?.toolbar)
println(location)
}
Until iOS 11, let barbuttonView = barButton.value(forKey: "view") as? UIView will give us the reference to the view for barButton in which we can easily add gestures, but in iOS 11 the things are quite different, the above line of code will end up with nil so adding tap gesture to the view for key "view" is meaningless.
No worries we can still add tap gestures to the UIBarItems, since it have a property customView. What we can do is create a button with height & width 24 pt(according to Apple Human Interface Guidelines) and then assign the custom view as the newly created button. The below code will help you perform one action for single tap and another for tapping bar button 5 times.
NOTE For this purpose you must already have a reference to the barbuttonitem.
func setupTapGestureForSettingsButton() {
let multiTapGesture = UITapGestureRecognizer()
multiTapGesture.numberOfTapsRequired = 5
multiTapGesture.numberOfTouchesRequired = 1
multiTapGesture.addTarget(self, action: #selector(HomeTVC.askForPassword))
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 24, height: 24))
button.addTarget(self, action: #selector(changeSettings(_:)), for: .touchUpInside)
let image = UIImage(named: "test_image")withRenderingMode(.alwaysTemplate)
button.setImage(image, for: .normal)
button.tintColor = ColorConstant.Palette.Blue
settingButton.customView = button
settingButton.customView?.addGestureRecognizer(multiTapGesture)
}
I tried something similar to what Ben suggested. I created a custom view with a UIButton and used that as the customView for the UIBarButtonItem. There were a couple of things I didn't like about this approach:
The button needed to be styled to not stick out like a sore thumb on the UIToolBar
With a UILongPressGestureRecognizer I didn't seem to get the click event for "Touch up Inside" (This could/is most likely be programing error on my part.)
Instead I settled for something hackish at best but it works for me. I'm used XCode 4.2 and I'm using ARC in the code below. I created a new UIViewController subclass called CustomBarButtonItemView. In the CustomBarButtonItemView.xib file I created a UIToolBar and added a single UIBarButtonItem to the toolbar. I then shrunk the toolbar to almost the width of the button. I then connected the File's Owner view property to the UIToolBar.
Then in my ViewController's viewDidLoad: message I created two UIGestureRecognizers. The first was a UILongPressGestureRecognizer for the click-and-hold and second was UITapGestureRecognizer. I can't seem to properly get the action for the UIBarButtonItem in the view so I fake it with the UITapGestureRecognizer. The UIBarButtonItem does show itself as being clicked and the UITapGestureRecognizer takes care of the action just as if the action and target for the UIBarButtonItem was set.
- (void)viewDidLoad
{
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:#selector(longPressGestured)];
UITapGestureRecognizer *singleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(buttonPressed:)];
CustomBarButtomItemView* customBarButtonViewController = [[CustomBarButtomItemView alloc] initWithNibName:#"CustomBarButtonItemView" bundle:nil];
self.barButtonItem.customView = customBarButtonViewController.view;
longPress.minimumPressDuration = 1.0;
[self.barButtonItem.customView addGestureRecognizer:longPress];
[self.barButtonItem.customView addGestureRecognizer:singleTap];
}
-(IBAction)buttonPressed:(id)sender{
NSLog(#"Button Pressed");
};
-(void)longPressGestured{
NSLog(#"Long Press Gestured");
}
Now when a single click occurs in the ViewController's barButtonItem (Connected via the xib file) the tap gesture calls the buttonPressed: message. If the button is held down longPressGestured is fired.
For changing the appearance of the UIBarButton I'd suggest making a property for CustomBarButtonItemView to allow access to the Custom BarButton and store it in the ViewController class. When the longPressGestured message is sent you can change the system icon of the button.
One gotcha I've found is the customview property takes the view as is. If you alter the custom UIBarButtonitem from the CustomBarButtonItemView.xib to change the label to #"really long string" for example the button will resize itself but only the left most part of the button shown is in the view being watched by the UIGestuerRecognizer instances.
I tried #voi1d's solution, which worked great until I changed the title of the button that I had added a long press gesture to. Changing the title appears to create a new UIView for the button that replaces the original, thus causing the added gesture to stop working as soon as a change is made to the button (which happens frequently in my app).
My solution was to subclass UIToolbar and override the addSubview: method. I also created a property that holds the pointer to the target of my gesture. Here's the exact code:
- (void)addSubview:(UIView *)view {
// This method is overridden in order to add a long-press gesture recognizer
// to a UIBarButtonItem. Apple makes this way too difficult, but I am clever!
[super addSubview:view];
// NOTE - this depends the button of interest being 150 pixels across (I know...)
if (view.frame.size.width == 150) {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:targetOfGestureRecognizers
action:#selector(showChapterMenu:)];
[view addGestureRecognizer:longPress];
}
}
In my particular situation, the button I'm interested in is 150 pixels across (and it's the only button that is), so that's the test I use. It's probably not the safest test, but it works for me. Obviously you'd have to come up with your own test and supply your own gesture and selector.
The benefit of doing it this way is that any time my UIBarButtonItem changes (and thus creates a new view), my custom gesture gets attached, so it always works!
I know this is old but I spent a night banging my head against the wall trying to find an acceptable solution. I didn't want to use the customView property because would get rid of all of the built in functionality like button tint, disabled tint, and the long press would be subjected to such a small hit box while UIBarButtonItems spread their hit box out quite a ways. I came up with this solution that I think works really well and is only a slight pain to implement.
In my case, the first 2 buttons on my bar would go to the same place if long pressed, so I just needed to detect that a press happened before a certain X point. I added the long press gesture recognizer to the UIToolbar (also works if you add it to a UINavigationBar) and then added an extra UIBarButtonItem that's 1 pixel wide right after the 2nd button. When the view loads, I add a UIView that's a single pixel wide to that UIBarButtonItem as it's customView. Now, I can test the point where the long press happened and then see if it's X is less than the X of the customview's frame. Here's a little Swift 3 Code
#IBOutlet var thinSpacer: UIBarButtonItem!
func viewDidLoad() {
...
let thinView = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 22))
self.thinSpacer.customView = thinView
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressed(gestureRecognizer:)))
self.navigationController?.toolbar.addGestureRecognizer(longPress)
...
}
func longPressed(gestureRecognizer: UIGestureRecognizer) {
guard gestureRecognizer.state == .began, let spacer = self.thinSpacer.customView else { return }
let point = gestureRecognizer.location(ofTouch: 0, in: gestureRecognizer.view)
if point.x < spacer.frame.origin.x {
print("Long Press Success!")
} else {
print("Long Pressed Somewhere Else")
}
}
Definitely not ideal, but easy enough for my use case. If you need a specify a long press on specific buttons in specific locations, it gets a little more annoying but you should be able to surround the buttons you need to detect the long press on with thin spacers and then just check that your point's X is between both of those spacers.
#voi1d's 2nd option answer is the most useful for those not wanting to rewrite all the functionality of UIBarButtonItem's. I wrapped this in a category so that you can just do:
[myToolbar addGestureRecognizer:(UIGestureRecognizer *)recognizer toBarButton:(UIBarButtonItem *)barButton];
with a little error handling in case you are interested. NOTE: each time you add or remove items from the toolbar using setItems, you will have to re-add any gesture recognizers -- I guess UIToolbar recreates the holding UIViews every time you adjust the items array.
UIToolbar+Gesture.h
#import <UIKit/UIKit.h>
#interface UIToolbar (Gesture)
- (void)addGestureRecognizer:(UIGestureRecognizer *)recognizer toBarButton:(UIBarButtonItem *)barButton;
#end
UIToolbar+Gesture.m
#import "UIToolbar+Gesture.h"
#implementation UIToolbar (Gesture)
- (void)addGestureRecognizer:(UIGestureRecognizer *)recognizer toBarButton:(UIBarButtonItem *)barButton {
NSUInteger index = 0;
NSInteger savedTag = barButton.tag;
barButton.tag = NSNotFound;
for (UIBarButtonItem *anItem in [self items]) {
if (anItem.enabled) {
anItem.tag = index;
index ++;
}
}
if (NSNotFound != barButton.tag) {
[[[self subviews] objectAtIndex:barButton.tag] addGestureRecognizer:recognizer];
}
barButton.tag = savedTag;
}
#end
I know it is not the best solution, but I am going to post a rather easy solution that worked for me.
I have created a simple extension for UIBarButtonItem:
fileprivate extension UIBarButtonItem {
var view: UIView? {
return value(forKey: "view") as? UIView
}
func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
view?.addGestureRecognizer(gestureRecognizer)
}
}
After this, you can simply add your gesture recognizers to the items in your ViewController's viewDidLoad method:
#IBOutlet weak var myBarButtonItem: UIBarButtonItem!
func setupLongPressObservation() {
let recognizer = UILongPressGestureRecognizer(
target: self, action: #selector(self.didLongPressMyBarButtonItem(recognizer:)))
myBarButtonItem.addGestureRecognizer(recognizer)
}
#utopians answer in Swift 4.2
#objc func myAction(_ sender: UIBarButtonItem, forEvent event:UIEvent) {
let longPressed:Bool = (event.allTouches?.first?.tapCount).map {$0 == 0} ?? false
... handle long press ...
}
Ready for use UIBarButtonItem subclass:
#objc protocol BarButtonItemDelegate {
func longPress(in barButtonItem: BarButtonItem)
}
class BarButtonItem: UIBarButtonItem {
#IBOutlet weak var delegate: BarButtonItemDelegate?
private let button = UIButton(type: .system)
override init() {
super.init()
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
private func setup() {
let recognizer = UILongPressGestureRecognizer(
target: self,
action: #selector(longPress)
)
button.addGestureRecognizer(recognizer)
button.setImage(image, for: .normal)
button.tintColor = tintColor
customView = button
}
override var action: Selector? {
set {
if let action = newValue {
button.addTarget(target, action: action, for: .touchUpInside)
}
}
get { return nil }
}
#objc private func longPress(sender: UILongPressGestureRecognizer) {
if sender.state == .began {
delegate?.longPress(in: self)
}
}
}
This is the most Swift-friendly and least hacky way I came up with. Works in iOS 12.
Swift 5
var longPressTimer: Timer?
let button = UIButton()
button.addTarget(self, action: #selector(touchDown), for: .touchDown)
button.addTarget(self, action: #selector(touchUp), for: .touchUpInside)
button.addTarget(self, action: #selector(touchCancel), for: .touchCancel)
let undoBarButton = UIBarButtonItem(customView: button)
#objc func touchDown() {
longPressTimer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(longPressed), userInfo: nil, repeats: false)
}
#objc func touchUp() {
if longPressTimer?.isValid == false { return } // Long press already activated
longPressTimer?.invalidate()
longPressTimer = nil
// Do tap action
}
#objc func touchCancel() {
longPressTimer?.invalidate()
longPressTimer = nil
}
#objc func longPressed() {
// Do long press action
}