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:
The eyedropper button isn't always in front of other items, even inside the popover or the custom UIView inside the popover
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.
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)
}
Related
I'm working on a game prototype in Swift using UIKit and SpriteKit. The inventory (at the bottom of this screenshot) is a UIView with UIImageView subviews for the individual items. In this example, a single acorn.
I have the acorn recognizing the "pan" gesture so I can drag it around. However, it renders it below the other views in the hierarchy. I want it to pop out of the inventory and be on top of everything (even above its parent view) so I can drop it onto other views elsewhere in the game.
This is what I have as my panHandler on the acorn view:
#objc func panHandler(gesture: UIPanGestureRecognizer){
switch (gesture.state) {
case .began:
removeFromSuperview()
controller.view.addSubview(self)
case .changed:
let translation = gesture.translation(in: controller.view)
if let v = gesture.view {
v.center = CGPoint(x: v.center.x + translation.x, y: v.center.y + translation.y)
}
gesture.setTranslation(CGPoint.zero, in: controller.view)
default:
return
}
}
The problem is in the .began case, when I remove it from the superview, the pan gesture immediately cancels. Is it possible to remove the view from a superview and add it as a subview elsewhere while maintaining the pan gesture?
Or, if my approach is completely wrong, could you give me pointers how to accomplish my goal with another method?
The small answer is you can keep the gesture working if you don't call removeFromSuperview() on your view and add it as a subview right away to your controller view, but that's not the right way to do this because if the user cancels the drag you will have to re add to your main view again and if that view your dragging is heavy somehow it gets laggy and messy quickly
The long answer, and in my opinion is the right way to do it and what apple actually does in all drag and drop apis is
you can actually make a snapshot of the view you want to drag and add it as a subview of the controller view that's holding all your views and then call bringSubviewToFront(_ view: UIView) to make sure it's the top most view in the hierarchy and pass in the snapshot you took of the dragging view
in the .began you can hide the original view and in the .ended you can show it again
and also on .ended you can either take that snapshot and add to the dropping view or do anything else with it's dropping coordinates
I made a sample project to apply this
Here is the storyboard design and view hierarchy
Here is the ViewController code
class ViewController: UIViewController {
#IBOutlet var topView: UIView!
#IBOutlet var bottomView: UIView!
#IBOutlet var smallView: UIView!
var snapshotView: UIView?
override func viewDidLoad() {
super.viewDidLoad()
let pan = UIPanGestureRecognizer(target: self, action: #selector(panning(_:)))
smallView.addGestureRecognizer(pan)
}
#objc private func panning(_ pan: UIPanGestureRecognizer) {
switch pan.state {
case .began:
smallView.backgroundColor = .systemYellow
snapshotView = smallView.snapshotView(afterScreenUpdates: true)
smallView.backgroundColor = .white
if let snapshotView = self.snapshotView {
self.snapshotView = snapshotView
view.addSubview(snapshotView)
view.bringSubviewToFront(snapshotView)
snapshotView.backgroundColor = .blue
snapshotView.center = bottomView.convert(smallView.center, to: view)
}
case .changed:
guard let snapshotView = snapshotView else {
fallthrough
}
smallView.alpha = 0
let translation = pan.translation(in: view)
snapshotView.center = CGPoint(x: snapshotView.center.x + translation.x, y: snapshotView.center.y + translation.y)
pan.setTranslation(.zero, in: view)
case .ended:
if let snapshotView = snapshotView {
let frame = view.convert(snapshotView.frame, to: topView)
if topView.frame.contains(frame) {
topView.addSubview(snapshotView)
snapshotView.frame = frame
smallView.alpha = 1
} else {
bottomView.addSubview(snapshotView)
let newFrame = view.convert(snapshotView.frame, to: bottomView)
snapshotView.frame = newFrame
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) {
snapshotView.frame = self.smallView.frame
} completion: { _ in
self.snapshotView?.removeFromSuperview()
self.snapshotView = nil
self.smallView.alpha = 1
}
}
}
default: break
}
}
}
Here is how it ended up
So what I have is I have a UITapGestureRecognizer for a Tap on the view (lets call that base view) and a UILongPressGestureRecognizer on that same base view. When the user taps the view action one is dispatched and if the user holds down on the view another view pops up. This behavior works nicely.
What I want to do is that the user does not have to separately tap the popup. Instead I want the user to just "pull up" on the popup and activate an action.
I tried to use a UIPanGesture on the popup but that did not work.
Edit
I dragged the long press gesture recognizer on to the view (actually a key in a custom keyboard). This is the code
#IBAction func popupPressed(_ sender: UITapGestureRecognizer) {
//Todo code for popupKey
}
#IBAction func keyPressedLong(_ sender: UILongPressGestureRecognizer) {
switch sender.state {
case .began:
popupTint = UIView(frame: self.frame)
popupTint!.backgroundColor = UIColor.black
popupTint!.alpha = 0.6
self.view.addSubview(popupTint!)
popup = UIView(frame: sender.view!.frame)
popup.frame = CGRect(x: popup.frame.origin.x, y: sender.view!.frame.origin.y - popup.frame.height, width: popup.frame.width, height: popup.frame.height)
popupKey.layer.cornerRadius = 5
popupKey.clipsToBounds = true
let popUpTap = UITapGestureRecognizer(target: self, action: #selector(self.popupPressed(tapGesture:)))
popupKey.addGestureRecognizer(popUpTap)
self.view.addSubview(popup)
default:
break
}
}
Any Ideas ?
Thanks a lot in advance.
UILongPressGestureRecognizer is continuous so you could use the location in the base view to move the popup & check if the movement threshold was met.
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(self.didLongPress(gesture:)))
self.baseView.addGestureRecognizer(longPress)
#objc func didLongPress(gesture: UILongPressGestureRecognizer) {
if self.popUpView.bounds.contains(gesture.location(in: self.popUpView)) {
// perform action
}
}
I hope this answers your question.
I am rather new to iOS and swift 3 programming. At the moment I am working on a little learning project. I have the following problem.
On my screen I have 4 UIViews and 4 UIImageViews. The app allows to “drag and drop” an image onto one of the UIViews. This works pretty well so far. I use Pan Gesture Recognisers for the dragging of UIImages. But for some reason one of the UIViews seems to be in front of the image, meaning that I can drop the image to this area, but I cannot see it - it is layered under the UIView. The other three behave properly, the UIImageView is layered on top of the UIView. In the viewDidLoad() function of the view controller I set the background color of the UIViews to grey. If I don’t do that, the image will be displayed even on the fourth UIView.
I have checked for differences between the four UIView objects but cannot find any. I also tried to recreate the UIView by copying it from one of the the other three.
So my question is: Is there any property which defines that the UIView is layered on top or can be sent to back so that other objects are layered on top of it? Or is there anything else I am missing? Any hints would be very appreciated.
I was not quite sure which parts of the code are relevant, so I posted some of it (but still guess that this is rather a property of the UIView…)
This is where I set the background of the UIViews
override func viewDidLoad() {
super.viewDidLoad()
lbViewTitle.text = "\(viewTitle) \(level) - \(levelPage)"
if (level==0) {
print("invalid level => EXIT")
} else {
loadData(level: level)
originalPositionLetter1 = letter1.center
originalPositionLetter2 = letter2.center
originalPositionLetter3 = letter3.center
originalPositionLetter4 = letter4.center
dropArea1.layer.backgroundColor = UIColor.lightGray.cgColor
dropArea2.layer.backgroundColor = UIColor.lightGray.cgColor
dropArea3.layer.backgroundColor = UIColor.lightGray.cgColor
dropArea4.layer.backgroundColor = UIColor.lightGray.cgColor
}
}
This is the code that moves the image:
#IBAction func hadlePan1(_ sender: UIPanGestureRecognizer) {
moveLetter(sender: sender, dropArea: dropArea4, movedImage: letter1, originalPosition: originalPositionLetter1)
}
func moveLetter(sender: UIPanGestureRecognizer, dropArea : UIView, movedImage : UIImageView, originalPosition : CGPoint) {
let translation = sender.translation(in: self.view)
if let view = sender.view {
view.center = CGPoint(x:view.center.x + translation.x,
y:view.center.y + translation.y)
if sender.state == UIGestureRecognizerState.ended {
let here = sender.location(in: self.view)
if (dropArea.frame.contains(here)) {
movedImage.center = dropArea.center
} else {
movedImage.center = originalPosition
}
}
}
sender.setTranslation(CGPoint.zero, in: self.view)
}
There are two methods that may be useful to you: UIView.sendSubview(toBack:) and UIView.bringSubview(toFront:).
What I think you should do is to call bringSubview(toFront:) when you start dragging the image:
func moveLetter(sender: UIPanGestureRecognizer, dropArea : UIView, movedImage : UIImageView, originalPosition : CGPoint) {
self.view.bringSubview(toFront: movedImage) // <--- add this line!
let translation = sender.translation(in: self.view)
if let view = sender.view {
view.center = CGPoint(x:view.center.x + translation.x,
y:view.center.y + translation.y)
if sender.state == UIGestureRecognizerState.ended {
let here = sender.location(in: self.view)
if (dropArea.frame.contains(here)) {
movedImage.center = dropArea.center
} else {
movedImage.center = originalPosition
}
}
}
sender.setTranslation(CGPoint.zero, in: self.view)
}
I'm not sure whether this has been asked or not, but I failed to find a solution. I'm implementing panning gesture on a button, but the idea is: the button is fixed to a position, and when the user drags it, a copy of the button is created and moving with the gesture; the original one stays at its initial place (so there'll be 2 buttons in the view). When the panning ends, the new button is used for some processing, and after that it should disappear (the original one stays as it is; so this whole process can repeat). Currently what I have is as below:
private func addPanGesture() {
for btn in self.selectors { //selectors is a list of buttons which needs this gesture
let pan = UIPanGestureRecognizer(target: self, action:#selector(self.panDetected(_:)))
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
btn.addGesturerecognizer(pan)
}
}
#objc private func panDetected(_ panGesture: UIPanGestureRecognizer) {
var translation = panGesture.translation(in: view)
panGesture.setTranslation(CGPoint(x: 0, y: 0), in: view)
var newButton = UIButton()
if let initButton = panGesture.view as? UIButton {
print ("Button recognized!") // this msg is printed out
newButton.center = CGPoint(x: initButton.center.x + translation.x, y: initButton.center.y + translation.y)
newButton.setImage(UIImage(named: "somename"), for: .normal)
}
if panGesture.state == UIGestureRecognizerState.began {
self.view.addSubview(newButton)
}
if panGesture.state == UIGestureRecognizerState.ended {
//some other processing
}
if panGesture.state == UIGestureRecognizerState.changed {
self.view.addSubview(newButton)
}
// printed-out msgs show began, ended, changed states have all been reached
}
But the new button doesn't show up in my view. May I know how to solve this?
You need to create and add the new button as a subview only on .began and remove it on .ended.
Therefore you need to keep a reference to the new button.
You are setting the new button's center but not it's size. You might set its .frame.
You do not need to set a translation to the pan gesture. When you get var translation = panGesture.translation(in: view) you get everything you need.
I have wrote the below code for only one button, but if you are going to allow simultaneous dragging of buttons, you would need to keep a list of moving buttons instead of var movingButton: UIButton?
private func addPanGesture() {
let pan = UIPanGestureRecognizer(target: self, action:#selector(self.panDetected(_:)))
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
btn.addGestureRecognizer(pan)
}
#objc private func panDetected(_ panGesture: UIPanGestureRecognizer) {
let translation = panGesture.translation(in: view)
let initButton = panGesture.view as! UIButton
if panGesture.state == UIGestureRecognizerState.began {
// this is just copying initial button
// this might be overkill
// just make sure you set the frame, title and image of the new button correctly
let initButtonData = NSKeyedArchiver.archivedData(withRootObject: initButton)
let newButton = NSKeyedUnarchiver.unarchiveObject(with: initButtonData) as! UIButton
// we store new button's reference since we will just move it while it is added to view
movingButton = newButton
self.view.addSubview(movingButton!)
}
if panGesture.state == UIGestureRecognizerState.ended {
//some other processing
// when we are done just we just remove it from superview
movingButton!.removeFromSuperview()
}
if panGesture.state == UIGestureRecognizerState.changed {
// at any change, all we need to do is update movingButton's frame
var buttonFrame = initButton.frame;
buttonFrame.origin = CGPoint(x: buttonFrame.origin.x + translation.x, y: buttonFrame.origin.y + translation.y)
movingButton!.frame = buttonFrame
}
}
Hard to say without debugging it, but a few things I see:
You create a new button every time through panDetected, and add it to the view each time. You should only create an add the button in the .began state.
You should use init(frame:) to create your button, and initialize it to the size of the image.
It looks like you're attaching the pan gestures to the buttons. Then you get the pan coordinates in the button's coordinate system, which doesn't make sense. You should be converting the pan gesture to the button's superview's coordinate system, and should not be calling setTranslation except when the pan gesture's state is .began.
You should be setting the button's coordinates to the new location of the pan gesture each time you get a 1st.changed` message.
I have a BoardView class which subclasses UIView. It has several subviews, which all have their own UIPanGestureRecognizer. I use this recognizer to drag around a subview inside my BoardView instance, as such:
func pieceDragged(recognizer: UIPanGestureRecognizer) {
let pieceView = recognizer.view!
if recognizer.state == .Began || recognizer.state == .Changed {
// move the pieceView by the appropriate amount
let translation = recognizer.translationInView(self)
pieceView.center = CGPoint(x: pieceView.center.x + translation.x, y: pieceView.center.y + translation.y)
recognizer.setTranslation(CGPoint.zero, inView: self)
} else if recognizer.state == .Ended {
// do something else
}
}
This works fine.
The problem is that I want the subview that is being dragged around to be on top of all of its siblings, so I made my BoardView instance the delegate of each gesture recognizer and overrode this function:
override func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
let pieceView = gestureRecognizer.view!
bringSubviewToFront(pieceView)
return true
}
This seems to break the entire gesture recognizer: if I attempt to drag aroudn a subview, the pieceDragged(_:) function is called exactly once (where the state of the recognizer is .Began), but no more than that.
If I comment out bringSubviewToFront(pieceView), my code works as before - I can drag around any subview, but it's below some of its siblings.
I'm clueless. What could be going on here?
The problem was that I replaced all my subviews in the layoutSubviews() method (which is automatically called after bringToFront(_:) is called).
Seems like you are going round the houses as well and implementing a method that doesn't really make sense. The delegate method gestureRecognizerShouldBegin(_:) is all about should this recogniser actually start detecting not a place to perform an action.
You already have a suitable place to perform the action you want you could simply update your code to be
func pieceDragged(recognizer: UIPanGestureRecognizer) {
guard let pieceView = recognizer.view else {
return
}
switch recognizer.state {
case .Began:
pieceView.superview?.bringSubviewToFront(pieceView)
case .Changed:
let translation = recognizer.translationInView(self)
recognizer.setTranslation(CGPoint.zero, inView: self)
pieceView.center = CGPoint(x: pieceView.center.x + translation.x, y: pieceView.center.y + translation.y)
default: ()
}
}