16

I have a UIView that is partially stuck underneath a UINavigationBar on a UIViewController that's in full screen mode. The UINavigationBar blocks the touches of this view for the portion that it's covering it. I'd like to be able to unblock these touches for said view and have them go through. I've subclassed UINavigationBar with the following:

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

    if (view.tag == 399)
    {
        return view;
    }
    else
    {
        return nil;
    }
}

...where I've tagged the view in question with the number 399. Is it possible to pass through the touches for this view without having a pointer to it (i.e. like how I've tagged it above)? Am a bit confused on how to make this work with the hittest method (or if it's even possible).

Ser Pounce
  • 14,196
  • 18
  • 84
  • 169
  • Consider hide the navigation bar if you want the view under it to response to user touches. – KudoCC Feb 17 '14 at 08:07
  • It's an odd situation where I can't @KudoCC – Ser Pounce Feb 17 '14 at 08:10
  • Will the view under navigation bar receive touches if `- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event` always return nil. – KudoCC Feb 17 '14 at 08:17
  • You can't get the `UIView` beneath navigation bar through tag property, because the `UIView` is not in the hierarchy of navigation bar. – KudoCC Feb 17 '14 at 08:20
  • Probably need a pointer to see if the touch is within the views bounds? @KudoCC – Ser Pounce Feb 17 '14 at 08:29
  • In subclassed UINavigationBar `- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event` just return nil, then can the view beneath navigation bar receive touches? – KudoCC Feb 17 '14 at 09:01
  • I think you just can't pass the touches. i think you must have to have a reference to the below view from custom NavBar, and return it in hitTest. – santhu Feb 18 '14 at 09:12

5 Answers5

9

Here's a version which doesn't require setting the specific views you'd like to enable underneath. Instead, it lets any touch pass through except if that touch occurs within a UIControl or a view with a UIGestureRecognizer.

import UIKit

/// Passes through all touch events to views behind it, except when the
/// touch occurs in a contained UIControl or view with a gesture
/// recognizer attached
final class PassThroughNavigationBar: UINavigationBar {

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        guard nestedInteractiveViews(in: self, contain: point) else { return false }
        return super.point(inside: point, with: event)
    }

    private func nestedInteractiveViews(in view: UIView, contain point: CGPoint) -> Bool {

        if view.isPotentiallyInteractive, view.bounds.contains(convert(point, to: view)) {
            return true
        }

        for subview in view.subviews {
            if nestedInteractiveViews(in: subview, contain: point) {
                return true
            }
        }

        return false
    }
}

fileprivate extension UIView {
    var isPotentiallyInteractive: Bool {
        guard isUserInteractionEnabled else { return false }
        return (isControl || doesContainGestureRecognizer)
    }

    var isControl: Bool {
        return self is UIControl
    }

    var doesContainGestureRecognizer: Bool {
        return !(gestureRecognizers?.isEmpty ?? true)
    }
}
Tricky
  • 7,025
  • 5
  • 33
  • 43
8

Subclass UINavigationBar and override- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event such that it returns NO for the rect where the view you want to receive touches is and YES otherwise.

For example:

UINavigationBar subclass .h:

@property (nonatomic, strong) NSMutableArray *viewsToIgnoreTouchesFor; 

UINavigationBar subclass .m:

- (NSMutableArray *)viewsToIgnoreTouchesFor
{
    if (!_viewsToIgnoreTouchesFor) {
        _viewsToIgnoreTouchesFor = [@[] mutableCopy];
    }
    return _viewsToIgnoreTouchesFor;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    BOOL pointInSide = [super pointInside:point withEvent:event];
    for (UIView *view in self.viewsToIgnoreTouchesFor) {

        CGPoint convertedPoint = [view convertPoint:point fromView:self];
        if ([view pointInside:convertedPoint withEvent:event]) {
            pointInSide = NO;
            break;
        }
    }

    return pointInSide;
}

In your fullscreen viewController where you have the view behind the navBar add these lines to viewDidLoad

UINavigationBarSubclass *navBar = 
(UINavigationBarSubclass*)self.navigationController.navigationBar;
[navBar.viewsToIgnoreTouchesFor addObject:self.buttonBehindBar];

Please note: This will not send touches to the navigationBar, meaning if you add a view which is behind buttons on the navBar the buttons on the navBar will not receive touches.

Swift:

var viewsToIgnore = [UIView]()

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {

    let ignore = viewsToIgnore.first {
        let converted = $0.convert(point, from: self)
        return $0.point(inside: converted, with: event)
    }
    return ignore == nil && super.point(inside: point, with: event)
}

See the documentation for more info on pointInside:withEvent:

Also if pointInside:withEvent: does not work how you want, it might be worth trying the code above in hitTest:withEvent: instead.

James Nelson
  • 1,793
  • 1
  • 20
  • 25
8

I modified Tricky's solution to work with SwiftUI as an Extension. Works great to solve this problem. Once you add this code to your codebase all views will be able to capture clicks at the top of the screen.

Also posted this alteration to my blog.

import UIKit

/// Passes through all touch events to views behind it, except when the
/// touch occurs in a contained UIControl or view with a gesture
/// recognizer attached
extension UINavigationBar {
    open override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        guard nestedInteractiveViews(in: self, contain: point) else { return false }
        return super.point(inside: point, with: event)
    }

    private func nestedInteractiveViews(in view: UIView, contain point: CGPoint) -> Bool {
        if view.isPotentiallyInteractive, view.bounds.contains(convert(point, to: view)) {
            return true
        }

        for subview in view.subviews {
            if nestedInteractiveViews(in: subview, contain: point) {
                return true
            }
        }

        return false
    }
}

private extension UIView {
    var isPotentiallyInteractive: Bool {
        guard isUserInteractionEnabled else { return false }
        return (isControl || doesContainGestureRecognizer)
    }

    var isControl: Bool {
        return self is UIControl
    }

    var doesContainGestureRecognizer: Bool {
        return !(gestureRecognizers?.isEmpty ?? true)
    }
}
dragonfire
  • 407
  • 7
  • 16
  • The link to your blog just shows a login box and no blog. – Darren Nov 30 '21 at 14:58
  • How do you use this? How do you tell the navigation controller to use this subclass as it's navigation bar? – Darren Nov 30 '21 at 15:02
  • OMG! This actually works and I don't have to go fishing around the Navigation stack to try to figure out where and how to subclass the relevant UINavigationBar. Great job and good to know this can be done with extensions. – clearlight Apr 30 '22 at 23:20
  • This seems like a great solution, but it does not work with the UINavigationBar that appears over my UIView when I use UIViewRepresentable. Do you know why this might be the case? – rikitikitavi May 03 '22 at 23:54
2

Swift solution for the above mentioned Objective C answer.

class MyNavigationBar: UINavigationBar {

    var viewsToIgnoreTouchesFor:[UIView] = []

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        var pointInside = super.point(inside: point, with: event)

        for each in viewsToIgnoreTouchesFor {
            let convertedPoint = each.convert(point, from: self)
            if each.point(inside: convertedPoint, with: event) {
                pointInside = false
                break
            }
        }
        return pointInside
    }
}

Now set the views whose touches you want to capture beneath the navigation bar as below from viewDidLoad method or any of your applicable place in code

if let navBar = self.navigationController?.navigationBar as? MyNavigationBar {
    navBar.viewsToIgnoreTouchesFor = [btnTest]
}
halfer
  • 19,824
  • 17
  • 99
  • 186
Dhaval H. Nena
  • 3,992
  • 1
  • 37
  • 50
2

Excellent answer from @Tricky!

But I've recently noticed that it doesn't work on iPad with the latest iOS. It turned out that any navigation bar on iPad has gesture recognizers built-in since keyboards were introduced.

So I made a simple tweak to make it work again:

/// Passes through all touch events to views behind it, except when the
/// touch occurs in a contained UIControl or view with a gesture
/// recognizer attached
final class PassThroughNavigationBar: UINavigationBar {

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        guard nestedInteractiveViews(in: self, contain: point) else { return false }
        return super.point(inside: point, with: event)
    }

    private func nestedInteractiveViews(in view: UIView, contain point: CGPoint) -> Bool {

        if view.isPotentiallyInteractive, view.bounds.contains(convert(point, to: view)) {
            return true
        }

        for subview in view.subviews {
            if nestedInteractiveViews(in: subview, contain: point) {
                return true
            }
        }

        return false
    }
}

fileprivate extension UIView {
    
    var isPotentiallyInteractive: Bool {
        guard isUserInteractionEnabled else { return false }
        return (isControl || doesContainGestureRecognizer) && !(self is PassThroughNavigationBar)
    }

    var isControl: Bool {
        return self is UIControl
    }

    var doesContainGestureRecognizer: Bool {
        return !(gestureRecognizers?.isEmpty ?? true)
    }
}