Search code examples
iosswiftuipangesturerecognizer

How to drag a button from a popover across whole app? (swift)


In my app, I am displaying a popover that shows a custom UIView allowing the user to select a color using various sliders. I also want to implement an 'eyedropper' tool that the user can tap and hold then drag around to choose a color from anything visible in the app. Inside my custom UIView I added a UIPanGestureRecognizer to my button that points to the handlePan method:

var eyedropperStartLocation = CGPoint.zero
@objc func handlePan(recognizer: UIPanGestureRecognizer) {

    // self is a custom UIView that contains my color selection
    // sliders and is placed inside a UITableView that's in
    // the popover.
    let translation = recognizer.translation(in: self)
    if let view = recognizer.view {

        switch recognizer.state {
        case .began:
            eyedropperStartLocation = view.center

        case .ended, .failed, .cancelled:
            view.center = eyedropperStartLocation
            return

        default: break

        }
        view.center = CGPoint(x: view.center.x + translation.x,
                              y: view.center.y + translation.y)
        recognizer.setTranslation(CGPoint.zero, in: self)

    }
}

I can drag the button around and it changes location, however I have two issues:

  1. The eyedropper button isn't always in front of other items, even inside the popover or the custom UIView inside the popover
  2. The eyedropper button disappears when outside the bounds of the popover

How can I get the button to be visible all the time, including outside the popover? I'll want to detect where the user lets go of it within the app so I can determine what color it was on.


Solution

  • I figured out how to do this so I'll answer my own question. Instead of moving around the view/button that's inside the popup, I create a new UIImageView and add it to the application's Window, letting it span the whole application. The original button stays where it is - you could easily change the state on it to make it look different, or hide it if you wanted to.

    You could also use Interface Builder to tie to @IBActions, but I just did everything in code. The clickButton method kicks things off but calculating location in the window and putting it on the screen. The handlePan method does the translation and lets you move it around.

    All code below is swift 4 and works in XCode 9.4.1 (assuming I didn't introduce any typos):

    // Call this from all your init methods so it will always happen
    func commonInit() {
        let panner = UIPanGestureRecognizer(target: self, action: #selector(handlePan(recognizer:)))
        theButton.addGestureRecognizer(panner)
        theButton.addTarget(self, action: #selector(clickButton), for: .touchDown)
            eyedropperButton.addTarget(self, action: #selector(unclickButton), for: .touchUpInside)
            eyedropperButton.addTarget(self, action: #selector(unclickButton), for: .touchUpOutside)
    }
    
    var startLocation = CGPoint.zero
    lazy var floatingView: UIImageView = {
        let view = UIImageView(image: UIImage(named: "imagename"))
        view.backgroundColor = UIColor.blue
        return view
    }()
    
    
    // When the user clicks button - we create the image and put it on the screen
    // this makes the action seem faster vs waiting for the panning action to kick in
    @objc func clickButton() {
    
        guard let app = UIApplication.shared.delegate as? AppDelegate, let window = app.window else { return }
    
        // We ask the button what it's bounds are within it's own coordinate system and convert that to the
        // Window's coordinate system and set the frame on the floating view. This makes the new view overlap the
        // button exactly.
        floatingView.frame = theButton.convert(theButton.bounds, to: nil)
    
        window.addSubview(floatingView)
    
        // Save the location so we can translate it as part of the pan actions
        startLocation = floatingView.center
    }
    
    // This is here to handle the case where the user didn't move enough to kick in the panGestureRecognizer and cancel the action
    @objc func unclickButton() {
        floatingView.removeFromSuperview()
    }
    
    @objc func handlePan(recognizer: UIPanGestureRecognizer) {
        let translation = recognizer.translation(in: self)
    
        switch recognizer.state {
        case .ended, .failed, .cancelled:
            doSomething()
            floatingView.removeFromSuperview()
            return
        default: break
    
        }
    
        // This section is called for any pan state except .ended, .failed and .cancelled
        floatingView.center = CGPoint(x: floatingView.center.x + translation.x,
                                      y: floatingView.center.y + translation.y)
        recognizer.setTranslation(CGPoint.zero, in: self)
    }