UIScrollView with touch interceptor for contentSize to control subviews - ios

assume I've got the following view hierarchy;
Those views are actually ShinobiCharts, which are subclasses of UIView. View1 acts as a main chart that can be touched by the user (pinch, pan, long press and so on). This view in turn controls the other views (View2, View3 and so on) with regards to specific, touch dependent properties (the user pans the View1, so views 2 and 3... have to act accordingly and pan as well).
However, once the user scrolls the UIScrollView, View1 may disappear from the screen, leaving only views 2, 3 etc. visible, which don't have the corresponding gesture recognizers, so the user can't interact with the charts any more, bad user experience.
I sure could add recognizers to these additional charts as well, but during pinch, that would mean that both fingers would have to be located within a single view, the user can't just touch where he wants in order to pinch and zoom, again, bad user experience.
To cut things short, I need some sort of touch interceptor that covers the entire content area of the UIScrollView so that when a user pinches or pans, the corresponding touches will be forwarded to the main chart (View1) which in turn can update the other subviews.
Vertical scrolling the UIScrollView should be possible at all times.
First experiment:
I've tried adding a transparent UIView to the ViewController that covers the UIScrollView.
However, even with a direct reference to View1, the touches wouldn't get forwarded.
-(void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
[self.chartReference touchesBegan:touches withEvent:event];
}
I'm not so sure if this is the correct way to achieve this anyways.
Second experiment:
Disabling userInteraction on the chart subviews in UIScrollView and add my own pinch and pan gestures (in UIViewController) to the UIScrollView.
Issues;
1. The UIScrollView doesn't scroll any more.
2. How to forward these gestures to View1?
I'm afraid I can't provide more example code at this point since there isn't really much relevant code to show yet.
Edit:
Small note to experiment two;
The gesture recognisers have been added as follows;
UIPinchGestureRecognizer *pinchGestureRecognizer = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:#selector(pinchGesture:)];
pinchGestureRecognizer.cancelsTouchesInView = NO;
[scrollView addGestureRecognizer:pinchGestureRecognizer];
Simultaneous gesture recognition on the UIViewController has been enabled;
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}
Edit:
Problem 2 for experiment 2 has been solved. I didn't set the correct delegate for the pan/pinch gestures. D'oh!
What remains is how to forward the pinch/pan gestures.

You're close with placing a transparent view on top of the others. But, you need to override one more method.
You want to override hitTest:withEvent on the transparent view. This method is used while traversing the responder chain to see which view handles a touch in a particular area. If a view handles that touch, it returns Itself. If it wants to pass it on to the next view below it, it returns nil. If it knows another view handles that touch, it can return that view.
So, in your case, if the point is in the target area of your top transparent view, you return view1. View1's gesture recognizer should then be called.
Example:
InterceptorView is a transparent view which lies on top of the scrollView. TargetView is a view inside the scrollView, and has a TapGestureRecognizer attached to it.
class InterceptorView: UIView {
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
#IBOutlet weak var targetView1: UIView?
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
print("[Interceptor] Testing point: \(point) ")
if self.pointInside(point, withEvent: event) {
println("Hit")
return targetView1
}
else {
println()
return nil;
}
}
}
--
class TargetView: UIView {
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
#IBAction func handleGesture(gestureRecognizer: UIGestureRecognizer) {
let location = gestureRecognizer.locationInView(self)
print("[TargetView] Got gesture. Location \(location) ")
if (pointInside(location, withEvent: nil)) {
println("Inside");
}
else {
println("Outside");
}
}
}
Here's the project:
https://github.com/annabd351/GestureForwarding
(there's some other stuff in there too, but it works!)

Related

How to ignore all UIControl Taps, when using the tap to close UIKeyboard

This issue is stemming from my previous question dismiss keyboard on tapping on any UIControl object.
So when user has a keyboard present, I want the user to be able to tap anywhere outside of keyboard or UITextView to close keyboard. This works...But I also want that "first" tap that closes the keyboard to not cause any other actions.
i.e. UIKeyboard is present...user taps anywhere to close and happens to tap on a UIButton. UIKeyboard is closed and UIButton's action is NOT sent.
I tried to place
#IBAction func blahblah(sender: UIButton){
if keyboardShow == false {//UIButtons action here
}
}
But with some println() tests, it's showing that the keyboard is closed(thus keyboardShow is set to false) before UIButton registers the tap.
But I also want that "first" tap that closes the keyboard to not cause any other actions.
You can do it the way you are doing it, but that's very inflexible because you have to deal with all those other controls separately and individually.
The simplest way to do this is simply to put an invisible view on top of your entire "screen" (i.e., add it to your view controller's view, in front of all views) — except that it should be behind the text view.
text view
secret invisible view
all other views (buttons etc.)
Normally, your secret invisible view's userInteractionEnabled is false, so taps just fall through to whatever is behind it, as if the invisible view were not even there. The user can tap buttons and so on.
But when you show the keyboard, you also set your secret invisible view's userInteractionEnabled to true and give it a tap gesture recognizer. Thus, when the user taps outside the text view, the tap gesture recognizer responds, and you dismiss the keyboard.
When you dismiss the keyboard, you reverse all of that: remove the tap gesture recognizer and turn the invisible view's userInteractionEnabled to false.
I use this kind of trick all the time in my apps.
It is also possible to be more sophisticated: put your interfering invisible view in front of everything and override its touch handling (hitTest:). Here's an example of a view that blocks all touches except touches that would land on one particular passthruView located behind it:
class BlockerView: UIView {
weak var passthruView : UIView!
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
if let pv = self.passthruView {
let pt = pv.convertPoint(point, fromView: self)
if pv.pointInside(pt, withEvent: event) {
return nil // let the touch fall thru
}
}
return super.hitTest(point, withEvent: event)
}
}

UIPanGestureRecognizer top level view not getting events, subviews consuming them

Using UIPanGestureRecognizer & UITapGestureRecognizer on top-level UIView.
The setup in Interface Builder:
ViewController (our main view controller)
UIView (our main view and wired to our UIViewController
Our core UIView has a subview called a “Block” which is simply a UIView.
The Block view has 4 subviews (children) each being an instance of a UIButton.
The UIButton has its Touch Up Inside event wired to the the UIViewController.
The UIView (our main top-level UIView) has a UIPanGestureRecognizer and a UITapGestureRecognizer
Here is the scenario we are trying to accomplish (a.k.a.The behavior):
A user taps a button (a cell).
The button will change its stated from “normal” to “selected”. (This works fine and the code is simple)
With a selected item, a user can place their finger anywhere on the screen an move it up or down
The issue:
Need to know when panning stops.
The top UIView does not receive a gesture stated of ended.
The UIView does not receive a touchesEnded event.
How do you know when the user has lifted their finger? Suppose I start the panning when my finger is over a UIButton, while panning occurs, the UIButton eats the touches begin and end events. Therefore, you have no way of knowing when the user stopped moving their finger across the iPhone/iPad glass.
First implement UIGestureRecognizerDelegate in your view controller.
Then set the delegate on your gesture recognizers to self and implement the following method
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
return YES;
}
Your target method should look like this:
-(void)gestureRegognized:(UIGestureRecognizer*)gestureRecognizer
{
if ([gestureRecognizer isMemberOfClass:[UIPanGestureRecognizer class]])
{
//check its state
if(gestureRecognizer.state==UIGestureRecognizerStateBegan)
{
// add your code here
}
else if(gestureRecognizer.state==UIGestureRecognizerStateEnded)
{
// pan gesture ended code goes here
}
}
else if([gestureRecognizer isMemberOfClass:[UITapGestureRecognizer class]])
{
if(gestureRecognizer.state!=UIGestureRecognizerStateFailed)
{
// tap gesture detected
}
}
}

UITapGestureRecognizer does not respond to subview area outside parent view

I have a UIView called view1. view1 has a subview called subview. I added UITapGestureRecognizer to subview as follow:
UITapGestureRecognizer *recognizer = [[UITapGestureRecognizer alloc]initWithTarget:self action:#selector(handleTap:)];
[subview addGestureRecognizer:recognizer];
If I tapped an area overlapped between subview and view1 then the handleTap method got called. But if I tapped an area on the subview that was outside view1, then handleTap never got called. Is this behavior right? If not, any suggestion to what should I check for?
btw: The UIPanGestureRecognizer works fine. It does not exhibit the behavior mentioned above.
That is the default behaviour of UIView, the subview should be inside parent view bounds. If you want something different is better you create a custom subclass of the top view and override (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
You need to customize the parent view and change the way it handles touches. See this question for more details.
I've found the answers talking about overriding pointInside:withEvent: to be lacking detail on explanation or implementation. In the original question, when the user taps in the black, unlabeled area/view (we'll call it view2), the event framework will only trigger hitTest:withEvent: for the main window down through the view2 (and its immediate subviews), and will never hit it for view1 because the point tested for in pointInside:point is outside of the bounds of view1's frame. In order to get subview1 to register the gesture, you should override view2's implementation of hitTest:withEvent to include a check for subview's pointInside:point
//This presumes view2 has a reference to view1 (since they're nested in the example).
//In scenarios where you don't have access, you'd need to implement this
//in a higher level in the view hierachy
//In view2
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let ptRelativeToSubviewBounds = convert(point, to: view1.subview)
if view1.subview.point(inside:ptRelativeToSubviewBounds, with:event){
return view1.subview
}
else{
return super.hitTest(point, with: event)
}

touches methods not getting called on UIView placed inside a UIScrollView

I have a Custom Scroll View, subclassing UIScrollView. I have added a scroll view in my viewcontroller nib file and changed its class to CustomScrollView. Now, this custom scroll view (made from xib) is added as a subview on self.view.
In this scroll view, I have 3 text fields and 1 UIImageView(named signImageView) added from xib. On clicking UIImageView (added a TapGestureRecogniser), a UIView named signView is added on the custom scroll view. I want to allow User to sign on this view, So I have created a class Signature.m and .h, subclassing UIView and implemented the touches methods (touchesBegan, touchesMoved and touchesEnded) and initialised the signView as follows:
signView = [[Signature alloc]initWithFrame:signImageView.frame];
[customScrollView addSubview:signView];
But when I start signing on the signView, the view gets scrolled and hence the touches methods don't get called.
I have tried adding signView on self.view instead of custom scroll view, but in that case the view remains glued to a fixed position when I start scrolling. (Its frame remains fixed in this case)
Try setting canCancelContentTouches of the scrollView to NO and delaysContentTouches to YES.
EDIT:
I see that similiar question was answered here Drag & sweep with Cocoa on iPhone (the answer is exactly the same).
If the user tap-n-holds the signView (for about 0.3-0.5 seconds) then view's touchesBegan: method gets fired and all events from that moment on go to the signView until touchesEnded: is called.
If user quickly swipes trough the signView then UIScrollView takes over.
Since you already have UIView subclassed with touchesBegan: method implemented maybe you could somehow indicate to user that your app is prepared for him to sign ('green light' equivalent).
You could also use touchesEnded: to turn off this green light.
It might be better if you add signImageView as as subView of signView (instead of to customScrollView) and hide it when touchesBegan: is fired). You would add signView to customScrollview at the same place where you add signImageView in existing code instead.
With this you achieve that there is effectively only one subView on that place (for better touch-passing efficiency. And you could achieve that green light effect by un-hiding signImageView in touchesBegan:/touchesEnded:
If this app-behaviour (0.3-0.5s delay) is unacceptable then you'd also need to subclass UIScrollView. There Vignesh's method of overriding UIScrollView's touchesShouldBegin: could come to the rescue. There you could possibly detect if the touch accoured in signView and pass it to that view immediately.
When ever you add a scrollview in your view hierarchy it swallows all touches.Hence you are not getting the touches began. So to get the touches in your signon view you will have to pass the touches to signon view. This is how you do it.
We achieved this with a UIScrollView subclass that disables the pan gesture recogniser for a list of views that you provide.
class PanGestureSelectiveScrollView: UIScrollView {
var disablePanOnViews: [UIView]?
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let disablePanOnViews = disablePanOnViews else {
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
let touchPoint = gestureRecognizer.location(in: self)
let isTouchingAnyDisablingView = disablePanOnViews.first { $0.frame.contains(touchPoint) } != nil
if gestureRecognizer === panGestureRecognizer && isTouchingAnyDisablingView {
return false
}
return true
}
}

Detecting horizontal swipes in a UIScrollView with a vertical scroll

I have a UIScrollView that's around 600 pixels in height and 320 in width. So I'm allowing the user to scroll vertically.
I'm also trying to capture horizontal swipes on the view. The problem seems to be that when a user swipes horizontally with some accidental vertical movement, the UIScrollView scrolls and my touchesEnded delegate method never gets called.
Here's my code:
- (void)touchesEnded: (NSSet *)touches withEvent: (UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint currentPosition = [touch locationInView:self];
if (currentPosition.x + 35 < gestureStartPoint.x)
{
NSLog(#"Right");
}
else if (currentPosition.x - 35 > gestureStartPoint.x)
{
NSLog(#"Left");
}
else if (!self.dragging)
{
[self.nextResponder touchesEnded: touches withEvent:event];
}
[super touchesEnded: touches withEvent: event];
}
Does anyone know how I can get this to work even when there is vertical drag involved?
UIScrollView tries to figure out which touches to pass through to its contents, and which are scrolls, based on movement immediately after a touch begins. Basically, if the touch appears to be a scroll right away, it handles that gesture and the contents never see it; otherwise, the gesture gets passed through (with a very short delay for the first touch).
In my experience, I've been able to capture horizontal swipes in the contents of a UIScrollView that handled vertical-only scrolling -- it basically just worked by default for me. I did this by setting the contentSize to be the same as the width of the scroll view's frame, which is enough information to tell the UIScrollView that it won't be handling horizontal scrolling.
It sounds like you're having trouble with the default behavior, though. One hardware gotcha is that it's very hard to simulate a finger swipe on a laptop's trackpad. If I were you, I would test out the default UIScrollView setup using either a mouse or, preferably, on the device itself. I found that these input methods work much better for conveying swipes.
If that doesn't work, here is a very pertinent paragraph from Apple's UIScrollView docs:
Because a scroll view has no scroll bars, it must know whether a touch signals an intent to scroll versus an intent to track a subview in the content. To make this determination, it temporarily intercepts a touch-down event by starting a timer and, before the timer fires, seeing if the touching finger makes any movement. If the time fires without a significant change in position, the scroll view sends tracking events to the touched subview of the content view. If the user then drags their finger far enough before the timer elapses, the scroll view cancels any tracking in the subview and performs the scrolling itself. Subclasses can override the touchesShouldBegin:withEvent:inContentView:, pagingEnabled, and touchesShouldCancelInContentView: methods (which are called by the scroll view) to affect how the scroll view handles scrolling gestures.
In summary, you could try what they suggest by subclassing UIScrollView and overriding the suggested methods.
If #Tyler's method doesn't work, try putting a view right over the UIScrollView, and handle any horizontal swipes in that view's touchesBegan, and pass vertical ones to the next responder. You can be a little fuzzy and handle anything that has more of a horizontal movement than vertical as a horizontal swipe, and pass the more pure vertical swipes to the UISCrollView (via nextResponder).
Swift 4 solution
Register two gesture recognizers in viewDidLoad() in your view controller
let leftGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
leftGesture.direction = .left
leftGesture.delegate = self
view.addGestureRecognizer(leftGesture)
let rightGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
rightGesture.direction = .right
rightGesture.delegate = self
view.addGestureRecognizer(rightGesture)
Implement swipe action
#objc func handleSwipe(_ sender: UISwipeGestureRecognizer) {
// do swipe left/right based on sender.direction == .left / .right
}
In order to have simultaneous horizontal swipe and vertical scroll, let your view controller implement UIGestureRecognizerDelegate
class MyViewController: UIViewController, UIGestureRecognizerDelegate
...
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
The last step fixes the problem that when you move more than +/- 5 pixels on the y-axis the Scroll view takes over and starts scrolling vertically.

Resources