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:
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.
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)
}