iosswiftpopupuikituiswipegesturerecognizer

UIGestureRecognizer works outside of UIView borders instead of inside


I've implemented a popup notification in my app to show user the data was being updated successfully or not.

This popup is just a UIView instantiated from .xib. Here is screenshot of elements hierarchy in xib file: CLICK

Also I wanted the ability to swipe out the popup if user don't want to see a full time it shows itself on the screen. To do that I have implemented a UIGestureRecognizer:

    private func configureTapGesture() {
    guard let window = UIApplication.shared.windows.first(where: {$0.isKeyWindow}) else { return }
    let swipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipes(_:)))
    swipe.direction = .up
    window.addGestureRecognizer(swipe)
}

@objc private func handleSwipes(_ sender:UISwipeGestureRecognizer) {
    if sender.direction == .up {
        UIView.animate(withDuration: 0.15, delay: 0.0, options: .curveLinear) {
            self.popupView.center.y -= 50
        } completion: { _ in
            self.popupView.removeFromSuperview()
        }
        self.isRemovedByTap = true
    }
}

When I run the app it works only if I swipe out anywhere but not inside the popupView. Please check for the GIF: CLICK

If I replace a target from self (Popup class) to popupView (UIVisualEffectView, which is a rounded rectangle you see on GIF), then I receive an error unrecognized selector sent to instance

Here is a my full custom Popup class where I initialize the view, configure, animate and show it:

import UIKit

  class PopupView: UIView {

@IBOutlet weak var popupView: UIVisualEffectView!
@IBOutlet weak var symbol: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var descriptionLabel: UILabel!

private var isRemovedByTap = false

override init(frame: CGRect) {
    super.init(frame: frame)
    configure()
}

required init?(coder: NSCoder) {
    super.init(coder: coder)
    configure()
}

private func configure() {
    if let views = Bundle.main.loadNibNamed("PopupView", owner: self) {
        guard let view = views.first as? UIView else { return }
        view.frame = bounds
        addSubview(view)
    }
}

private func configurePopup() {
    guard let window = UIApplication.shared.windows.first(where: {$0.isKeyWindow}) else { return }
    popupView.layer.cornerRadius = 20
    popupView.clipsToBounds = true
    popupView.center.x = window.frame.midX
    popupView.translatesAutoresizingMaskIntoConstraints = true
    window.addSubview(popupView)
}

private func animatePopup() {
    UIView.animate(withDuration: 0.15, delay: 0.0, options: .curveLinear) {
        self.popupView.center.y += 34
    } completion: { _ in
        UIView.animate(withDuration: 0.15, delay: 10.0, options: .curveLinear) {
            self.popupView.center.y -= 50
        } completion: { _ in
            if !self.isRemovedByTap {
                self.popupView.removeFromSuperview()
            }
        }
    }
}

private func configureTapGesture() {
    guard let window = UIApplication.shared.windows.first(where: {$0.isKeyWindow}) else { return }
    let swipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipes(_:)))
    swipe.direction = .up
    window.addGestureRecognizer(swipe)
}

@objc private func handleSwipes(_ sender:UISwipeGestureRecognizer) {
    if sender.direction == .up {
        UIView.animate(withDuration: 0.15, delay: 0.0, options: .curveLinear) {
            self.popupView.center.y -= 50
        } completion: { _ in
            self.popupView.removeFromSuperview()
        }
        self.isRemovedByTap = true
    }
}

func showPopup(title: String, message: String, symbol: UIImage) {
    titleLabel.text = title
    descriptionLabel.text = message
    self.symbol.image = symbol
    
    configurePopup()
    animatePopup()
    configureTapGesture()
}
}

What should I change to be able to swipe out only when inside the popupView rounded rectangle?


Solution

  • Couple issues - without seeing your xib view hierarchy, I can't test this completely, but it should get you on your way.

    You are adding the swipe gesture to the window ...

    The reason you can't swipe inside the popup view is because the popup view captures the touch gestures. If you set .isUserInteractionEnabled = false you should be able to swipe inside the view.

    If you want to swipe only inside the view, check the swipe location and see if the view contains that point.

    Along these lines:

    @objc private func handleSwipes(_ sender:UISwipeGestureRecognizer) {
        // may need to convert touch and frame
        let loc = sender.location(in: self)
        let r = popupView.frame
        if sender.direction == .up, r.contains(loc) {
            UIView.animate(withDuration: 0.15, delay: 0.0, options: .curveLinear) {
                self.popupView.center.y -= 50
            } completion: { _ in
                self.popupView.removeFromSuperview()
            }
            self.isRemovedByTap = true
        }
    }
    

    You'll need to debug the frame and gesture coordinates to make sure you're checking the correct area.


    Edit

    To check if the swipe is inside the popupView frame, we have to do a couple things...

    Add a new property -- I'll call it yAdjustment -- and then in your animatePopup() func, set that to the popupView's y origin:

    var yAdjustment: CGFloat = 0
    
    private func animatePopup() {
        UIView.animate(withDuration: 0.15, delay: 0.0, options: .curveLinear) {
            self.popupView.center.y += 34
            // save the top of the popupView frame
            self.yAdjustment = self.popupView.frame.origin.y
        } completion: { _ in
            UIView.animate(withDuration: 0.15, delay: 10.0, options: .curveLinear) {
                self.popupView.center.y -= 50
            } completion: { _ in
                if !self.isRemovedByTap {
                    self.popupView.removeFromSuperview()
                }
            }
        }
    }
    

    then, change your swipe region testing to this:

    @objc private func handleSwipes(_ sender:UISwipeGestureRecognizer) {
    
        // get location of swipe
        var loc = sender.location(in: popupView)
    
        // adjust the touch y
        loc.y -= (yAdjustment - popupView.frame.origin.y)
    
        // get the view bounds
        let r = popupView.bounds
        
        // only do this if
        //  swipe direct is up
        //  AND
        //  swipe location is inside popupView
        if sender.direction == .up, r.contains(loc) {
            UIView.animate(withDuration: 0.15, delay: 0.0, options: .curveLinear) {
                self.popupView.center.y -= 50
            } completion: { _ in
                self.popupView.removeFromSuperview()
            }
            self.isRemovedByTap = true
        }
        
    }
    

    The reason we have to "correct" the frame...

    When you write that chained animation block, this line:

    self.popupView.center.y -= 50
    

    is executed immediately -- the animation is simply delayed (by 10-seconds, in this case).

    Normally, we would get the touch location in the view's frame and see if it is contained in the bounds.

    In this case, though, because the view's frame has already been changed - even though we don't see it yet - we have to compensate for the new location.