How to Implement Multiple Pangestures (Draggable Views)

How to implement multiple PanGestures (Draggable views)?

If you are using Auto Layout, you shouldn't just move a view by altering its center. Anything that causes Auto Layout to run will move your view back to its original position.

Here is an implementation of a new class called DraggableView that works with Auto Layout to provide views that can be dragged. It creates constraints in code for the width, height, X position, and Y position of the view. It uses its own UIPanGestureRecognizer to allow the view to be dragged.

DraggableView.swift can be dropped into any project that needs draggable views. Take a look at ViewController.swift below to see how easy it is to use.

DraggableView.swift

import UIKit

class DraggableView: UIView {
let superView: UIView!
let xPosConstraint: NSLayoutConstraint!
let yPosConstraint: NSLayoutConstraint!
var constraints: [NSLayoutConstraint] {
get {
return [xPosConstraint, yPosConstraint]
}
}

init(width: CGFloat, height: CGFloat, x: CGFloat, y: CGFloat, color: UIColor, superView: UIView) {
super.init()

self.superView = superView

self.backgroundColor = color

self.setTranslatesAutoresizingMaskIntoConstraints(false)

let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.addTarget(self, action: "draggedView:")
self.addGestureRecognizer(panGestureRecognizer)

let widthConstraint = NSLayoutConstraint(item: self, attribute: .Width, relatedBy: .Equal,
toItem: nil, attribute: .NotAnAttribute, multiplier: 1.0, constant: width)
self.addConstraint(widthConstraint)

let heightConstraint = NSLayoutConstraint(item: self, attribute: .Height, relatedBy: .Equal,
toItem: nil, attribute: .NotAnAttribute, multiplier: 1.0, constant: height)
self.addConstraint(heightConstraint)

xPosConstraint = NSLayoutConstraint(item: self, attribute: .CenterX, relatedBy: .Equal,
toItem: superView, attribute: .Leading, multiplier: 1.0, constant: x)

yPosConstraint = NSLayoutConstraint(item: self, attribute: .CenterY, relatedBy: .Equal,
toItem: superView, attribute: .Top, multiplier: 1.0, constant: y)
}

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

required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func moveByDeltaX(deltaX: CGFloat, deltaY: CGFloat) {
xPosConstraint.constant += deltaX
yPosConstraint.constant += deltaY
}

func draggedView(sender:UIPanGestureRecognizer){
if let dragView = sender.view as? DraggableView {
superView.bringSubviewToFront(dragView)
var translation = sender.translationInView(superView)
sender.setTranslation(CGPointZero, inView: superView)
dragView.moveByDeltaX(translation.x, deltaY: translation.y)
}
}
}

ViewController.swift

import UIKit

class ViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()

let dragView1 = DraggableView(width: 75, height: 75, x: 50, y: 50,
color: UIColor.redColor(), superView: self.view)
self.view.addSubview(dragView1)
self.view.addConstraints(dragView1.constraints)

let dragView2 = DraggableView(width: 100, height: 100, x: 150, y: 50,
color: UIColor.blueColor(), superView: self.view)
self.view.addSubview(dragView2)
self.view.addConstraints(dragView2.constraints)

let dragView3 = DraggableView(width: 125, height: 125, x: 100, y: 175,
color: UIColor.greenColor(), superView: self.view)
self.view.addSubview(dragView3)
self.view.addConstraints(dragView3.constraints)
}
}

UPDATE:

Here is a version of DraggableView.swift that supports images as a subview.

import UIKit

class DraggableView: UIView {
let superView: UIView!
let xPosConstraint: NSLayoutConstraint!
let yPosConstraint: NSLayoutConstraint!
var constraints: [NSLayoutConstraint] {
get {
return [xPosConstraint, yPosConstraint]
}
}

init(width: CGFloat, height: CGFloat, x: CGFloat, y: CGFloat, color: UIColor, superView: UIView, imageToUse: String? = nil, contentMode: UIViewContentMode = .ScaleAspectFill) {
super.init()

self.superView = superView

self.backgroundColor = color

self.setTranslatesAutoresizingMaskIntoConstraints(false)

let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.addTarget(self, action: "draggedView:")
self.addGestureRecognizer(panGestureRecognizer)

let widthConstraint = NSLayoutConstraint(item: self, attribute: .Width, relatedBy: .Equal,
toItem: nil, attribute: .NotAnAttribute, multiplier: 1.0, constant: width)
self.addConstraint(widthConstraint)

let heightConstraint = NSLayoutConstraint(item: self, attribute: .Height, relatedBy: .Equal,
toItem: nil, attribute: .NotAnAttribute, multiplier: 1.0, constant: height)
self.addConstraint(heightConstraint)

xPosConstraint = NSLayoutConstraint(item: self, attribute: .CenterX, relatedBy: .Equal,
toItem: superView, attribute: .Leading, multiplier: 1.0, constant: x)

yPosConstraint = NSLayoutConstraint(item: self, attribute: .CenterY, relatedBy: .Equal,
toItem: superView, attribute: .Top, multiplier: 1.0, constant: y)

if imageToUse != nil {
if let image = UIImage(named: imageToUse!) {
let imageView = UIImageView(image: image)
imageView.contentMode = contentMode
imageView.clipsToBounds = true
imageView.setTranslatesAutoresizingMaskIntoConstraints(false)
self.addSubview(imageView)
self.addConstraint(NSLayoutConstraint(item: imageView, attribute: .Left, relatedBy: .Equal, toItem: self, attribute: .Left, multiplier: 1.0, constant: 0))
self.addConstraint(NSLayoutConstraint(item: imageView, attribute: .Top, relatedBy: .Equal, toItem: self, attribute: .Top, multiplier: 1.0, constant: 0))
self.addConstraint(NSLayoutConstraint(item: imageView, attribute: .Right, relatedBy: .Equal, toItem: self, attribute: .Right, multiplier: 1.0, constant: 0))
self.addConstraint(NSLayoutConstraint(item: imageView, attribute: .Bottom, relatedBy: .Equal, toItem: self, attribute: .Bottom, multiplier: 1.0, constant: 0))
}
}
}

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

required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func moveByDeltaX(deltaX: CGFloat, deltaY: CGFloat) {
xPosConstraint.constant += deltaX
yPosConstraint.constant += deltaY
}

func draggedView(sender:UIPanGestureRecognizer){
if let dragView = sender.view as? DraggableView {
superView.bringSubviewToFront(dragView)
var translation = sender.translationInView(superView)
sender.setTranslation(CGPointZero, inView: superView)
dragView.moveByDeltaX(translation.x, deltaY: translation.y)
}
}
}

Multiple gestures on same view (drag view out of scrollable view) not working the right way?

Ok I solved this with a combination of a few things. It looks like a lot to add, but it will come in handy in other projects.

Step 1: Enable the horizontal scrolling UIScrollView to "fail" both horizontally and vertically.

Although I only want my UIScrollView to scroll horizontally, I still need it to scroll vertically so that it can fail (explanation coming). Failing enables me to "pull" the subviews out of the UIScrollView.

The height of scrollView itself is 40 so the contentSize of the it must have a larger height for it to be able to barely scroll vertically.

self.effectTotalHeight.constant = 41
self.scrollView.contentSize = CGSize(width: contentWidth, height: self.effectTotalHeight.constant)

Great! It scrolls vertically. But now the content rubber-bands (which we do not want). Against what others on SO say, do not go cheap and just disable bounce. (Especially if you want the bounce when scrolling horizontally!)

Note: I also realized only disabling Bounce Horizontally in StoryBoard does... well, nothing (bug?).

Step 2: Add UIScrollViewDelegate to your View Controller class and detect scrolls

Now I want to detect the scroll and make sure that when scrolling vertically, it does not actually scroll. To do this, the contentOffset.y position should not change even though you are scrolling. UIScrollViewDelegate provides a delegate function scrollViewDidScroll that gets called when scrolling. Just set it as:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollView.contentOffset.y = 0.0
}

In order for this to be called, you need to set the delegate of the scrollView to self as well:

self.scrollView.delegate = self

Keep in mind this controls all UIScrollViews so provide an if statement or switch statement if you want it to only affect specific ones. In my case, this view controller only has one UIScrollView so I did not put anything.

Yay! So now it only scrolls horizontally again but this method only keeps the contentOffset.y at 0. It does not make it fail. We do need it to because scrollView failing vertically is the key to enabling pan gesture recognizers (what lets you pull and drag etc.). So let's make it fail!

Step 3: Override UIScrollView default gesture recognition

In order to override any of the default gesture recognizers, we need to add UIGestureRecognizerDelegate as another delegate method to your View Controller class.

The scrollView now needs its own pan gesture recognizer handler so that we can detect gestures on it. You also need to set the delegate of the new scrollGesture to self so we can detect it:

let scrollGesture = UIPanGestureRecognizer(target: self, action: #selector(self.handleScroll(_:)))
scrollGesture.delegate = self
self.scrollView.addGestureRecognizer(scrollGesture)

Set up the handleScroll function:

func handleScroll(_ recognizer: UIPanGestureRecognizer) {
// code here
}

This is all good, but why did we set this all up? Remember we disabled the contentOffset.y but the vertical scroll was not failing. Now we need to detect the direction of the scroll and let it fail if vertical so that panHandle can be activated.

Step 4: Detect gesture direction and let it fail (failure is good!)

I extended the UIPanGestureRecognizer so that it can detect and emit directions by making the following public extension:

public enum Direction: Int {
case Up
case Down
case Left
case Right

public var isX: Bool { return self == .Left || self == .Right }
public var isY: Bool { return !isX }
}

public extension UIPanGestureRecognizer {
public var direction: Direction? {
let velo = velocity(in: view)
let vertical = fabs(velo.y) > fabs(velo.x)
switch (vertical, velo.x, velo.y) {
case (true, _, let y) where y < 0: return .Up
case (true, _, let y) where y > 0: return .Down
case (false, let x, _) where x > 0: return .Right
case (false, let x, _) where x < 0: return .Left
default: return nil
}
}
}

Now in order to use it correctly, you can get the recognizer's .direction inside of the handleScroll function and detect the emitted directions. In this case I am looking for either .Up or .Down and I want it to emit a fail. We do this by disabling the recognizer it, but then re-enabling it immediately after:

func handleScroll(_ recognizer: UIPanGestureRecognizer) {
if (recognizer.direction == .Up || recognizer.direction == .Down) {
recognizer.isEnabled = false
recognizer.isEnabled = true
}
}

The reason .isEnabled is immediately set to true right after false is because false will emit the fail, enabling the other gesture ("pulling out" (panning) its inner views), but to not be disabled forever (or else it will cease being called). By setting it back to true, it lets this listener be re-enabled right after emitting the fail.

Step 5: Let multiple gestures work by overriding each other

Last but not least, this is a very very important step as it allows both pan gestures and scroll gestures to work independently and not have one single one always override the other.

func gestureRecognizer(_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith shouldRecognizeSimultaneouslyWithGestureRecognizer:UIGestureRecognizer) -> Bool {
return true
}

And that's that! This was a lot of research on SO (mostly finding what did not work) and experimentation but it all came together.

This was written with the expectation that you have already written the pan gesture recognizers of the objects inside of the scroll view that you are "pulling out" and how to handle its states (.ended, .changed etc.). I suggest if you need help with that, search SO. There are tons of answers.

If you have any other questions about this answer, please let me know.

How to drag multiple uiview on another uiview or image in a view controller in Swift 4

I have made a simple example for you.

In the example I have created a function which will create 5 draggable UIViews:

   /// Creates A Row Of 5 Draggable UIViews
func createDraggableViews(){

//1. Determine The Size Of The Draggable View
let viewSize = CGSize(width: 50, height: 50)

//2. Create Padding Between The Views
let padding: CGFloat = 10

//2. Create 5 Draggable Views
for i in 0 ..< 5{
print((CGFloat(i) * viewSize.width) + 10)
let draggableView = UIView(frame: CGRect(x: 10 + (CGFloat(i) * viewSize.width) + (CGFloat(i) * padding), y: 100,
width: viewSize.width, height: viewSize.height))
draggableView.backgroundColor = .purple

//2a. Create A Pan Gesture Recognizer So The View Can Be Moved
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(moveObject(_:)))
draggableView.addGestureRecognizer(panGesture)

//2b. Add The Draggable View To The Main View
self.view.addSubview(draggableView)
}

}

These views are then made draggable by the following code:

 /// Moves A Draggable Object Around The Screen
///
/// - Parameter gesture: UIPanGestureRecognizer
@objc func moveObject(_ gesture: UIPanGestureRecognizer){

//1. Check That We Have A Valid Draggable View
guard let draggedObject = gesture.view else { return }

if gesture.state == .began || gesture.state == .changed {

//2. Set The Translation & Move The View
let translation = gesture.translation(in: self.view)
draggedObject.center = CGPoint(x: draggedObject.center.x + translation.x, y: draggedObject.center.y + translation.y)
gesture.setTranslation(CGPoint.zero, in: self.view)

}else if gesture.state == .ended {

}
}

This is not a full example, but hopefully it can be a good starting point for you ^______^.

How can I change layout constraints for different views using a pan gesture recognizer?

You need to keep a reference to the position constraints you create for a view so that you can either change their constant value or set isActive = false before creating new constraints.

  1. Create a subclass of UIView called MovableView that has NSLayoutConstraint? properties for horizontalConstraint and verticalConstraint.
  2. Set those properties when you create the constraints before setting their isActive = true.
  3. When you need to move a MovableView, update the constant property for the horizontalConstraint and verticalConstraint.

    moveableView.horizontalConstraint?.constant = newXConstant
    moveableView.verticalConstraint?.constant = newYConstant

    Alternatively, you can set horizontalConstraint?.isActive = false and verticalConstraint?.isActive = false before creating, assigning, and activating the new constraints.

    moveableView.horizontalConstraint?.isActive = false
    moveableView.verticalConstraint?.isActive = false

    moveableView.horizontalConstraint = ...
    moveableView.horizontalConstraint?.isActive = true

    moveableView.verticalConstraint = ...
    moveableView.verticalConstraint?.isActive = true

See draggable views for an implementation of draggable views that I wrote 4 years ago. It is out of date and needs updating, but you might get some ideas from it. Also read the comments below it where @Matt explains that you can skip using constraints for a draggable view. It is possible to add views to the screen that do not partake in Auto Layout even if the rest of the screen does.


I fixed your code:

  • You didn't provide the implementation of MovableView, but it appears that the constraint properties were static instead of member properties. You need them to be member properties in order to support multiple movable views.
  • You had startingConstantPosX and startingConstantPosY swapped which is what caused the jumping.

class MovableView: UIView {
var horizontalConstraint: NSLayoutConstraint?
var verticalConstraint: NSLayoutConstraint?
}

class ViewController: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate {

var numberOfViews = 0

var startingConstantPosX: CGFloat = 0.0
var startingConstantPosY: CGFloat = 100.0

override func viewDidLoad() {
super.viewDidLoad()

navigationItem.title = "edit views"
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "add", style: .plain, target: self, action: #selector(addView))
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Clear", style: .plain, target: self, action: #selector(clearThis))
}

@objc func addView() {

let view1 = MovableView()
view1.backgroundColor = UIColor.yellow
view1.frame = CGRect()
view.addSubview(view1)
view1.translatesAutoresizingMaskIntoConstraints = false

view1.widthAnchor.constraint(equalToConstant: 80).isActive = true
view1.heightAnchor.constraint(equalToConstant: 80).isActive = true

view1.horizontalConstraint = view1.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 0)
view1.horizontalConstraint?.isActive = true

view1.verticalConstraint = view1.centerYAnchor.constraint(equalTo: view.topAnchor, constant: 100)
view1.verticalConstraint?.isActive = true

numberOfViews+=1
view1.tag = numberOfViews

//add pan gesture
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
panGestureRecognizer.delegate = self
view1.addGestureRecognizer(panGestureRecognizer)
}

@objc func clearThis() {
for view in view.subviews {
view.removeFromSuperview()
}
numberOfViews = 0
}

@objc func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {

guard let currentView = gestureRecognizer.view as? MovableView else { return }

if gestureRecognizer.state == .began {

print("view being moved is: \(currentView.tag)")

startingConstantPosX = currentView.horizontalConstraint?.constant ?? 0
startingConstantPosY = currentView.verticalConstraint?.constant ?? 0

} else if gestureRecognizer.state == .changed {

let translation = gestureRecognizer.translation(in: self.view)

let newXConstant = startingConstantPosX + translation.x
let newYConstant = startingConstantPosY + translation.y

currentView.horizontalConstraint?.constant = newXConstant
currentView.verticalConstraint?.constant = newYConstant

}

}

}

If you set the translation back to .zero each time handlePan is called, you can simplify the code by not needing to keep track of the starting position:

@objc func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {

guard let currentView = gestureRecognizer.view as? MovableView else { return }

if gestureRecognizer.state == .began {

print("view being moved is: \(currentView.tag)")

} else if gestureRecognizer.state == .changed {

let translation = gestureRecognizer.translation(in: self.view)

currentView.horizontalConstraint?.constant += translation.x
currentView.verticalConstraint?.constant += translation.y

gestureRecognizer.setTranslation(.zero, in: self.view)
}
}

Draggable UIView Swift 3

Step 1 : Take one View which you want to drag in storyBoard.

@IBOutlet weak var viewDrag: UIView!

Step 2 : Add PanGesture.

var panGesture       = UIPanGestureRecognizer()

Step 3 : In ViewDidLoad adding the below code.

override func viewDidLoad() {
super.viewDidLoad()

panGesture = UIPanGestureRecognizer(target: self, action: #selector(ViewController.draggedView(_:)))
viewDrag.isUserInteractionEnabled = true
viewDrag.addGestureRecognizer(panGesture)

}

Step 4 : Code for draggedView.

func draggedView(_ sender:UIPanGestureRecognizer){
self.view.bringSubview(toFront: viewDrag)
let translation = sender.translation(in: self.view)
viewDrag.center = CGPoint(x: viewDrag.center.x + translation.x, y: viewDrag.center.y + translation.y)
sender.setTranslation(CGPoint.zero, in: self.view)
}

Step 5 : Output.

GIF

Setting boundaries on pan gestures

No, delegate is not appropriate for this thing. I believe you want to keep gestures and just restrict the position of your views to be inside their parent. To do so you need to restrict that on pan gesture change event.

The easiest is to check if two opposite points are within parent rect:

let topLeft = CGPoint(x: touchpoint.frame.minX + dX, y: touchpoint.frame.minY + dY)
let bottomRight = CGPoint(x: touchpoint.frame.maxX + dX, y: touchpoint.frame.maxY + dY)
if let containerView = touchpoint.superview, containerView.bounds.contains(topLeft), containerView.bounds.contains(bottomRight) {
touchpoint.center = CGPoint(x: touchpoint.center.x + dX, y: touchpoint.center.y + dY)
}

So basically we are computing the new top-left and bottom-right points. And if these are inside their superview bounds then view may be moved to that point.

This is pretty good but the thing is that once this occurs your view may not be exactly at the border. If user for instance drags it toward left really quickly then at some point topLeft may be (15, 0) and in the next event (-10, 0) which means it will ignore the second one and keep at (15, 0).

So you need to implement the limi to movement and restrict it to it...

var targetFrame = CGRect(x: touchpoint.frame.origin.x + dX, y: touchpoint.frame.origin.y + dY, width: touchpoint.frame.width, height: touchpoint.frame.height)

if let containerView = touchpoint.superview {
targetFrame.origin.x = max(0.0, targetFrame.origin.x)
targetFrame.origin.y = max(0.0, targetFrame.origin.y)
targetFrame.size.width = min(targetFrame.size.width, containerView.bounds.width-(targetFrame.origin.x+targetFrame.width))
targetFrame.size.height = min(targetFrame.size.height, containerView.bounds.height-(targetFrame.origin.y+targetFrame.height))
}

touchpoint.frame = targetFrame

So this should make your view stick inside the superview...

Handling UIPanGestureRecognizer gestures for multiple Views (one covers the other)

Managed to get this working.

Of the methods described in my question above I removed the top one keeping just this (it has a few changes):

func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if ((gestureRecognizer as! UIPanGestureRecognizer).velocityInView(view).y < 0
|| pulloverVC.tableView.contentOffset.y > 0)
&& pulloverVC.view.frame.origin.y == bottomNavbarY {
return true // enable tableview control
}
return false
}

The if statement checks that the covering UITableView is in its upper position AND that either the user is is not dragging downwards or the table content is offset (we are not at the top of the table). If this is true, then we return true to enable the tableview.

After this method is called, the standard method implemented to handle my pan gesture is called. In here I have an if statement that sort of checks the opposite to above, and if that's true, it prevents control over the covering viewB from moving:

func handlePanGesture(recognizer: UIPanGestureRecognizer) {

let gestureIsDraggingFromTopToBottom = (recognizer.velocityInView(view).y > 0)

if pulloverVC.view.frame.origin.y != bottomNavbarY || (pulloverVC.view.frame.origin.y == bottomNavbarY && gestureIsDraggingFromTopToBottom && pulloverVC.tableView.contentOffset.y == 0) {

...

This now keeps the UITableView interaction off unless its parent view viewB is in the correct position, and when it is, disables the movement of viewB so that only interaction with the UITableView works.

Then when, we are at the top of the table, and drag downwards, interaction with the UITableView is re-disabled and interaction with its parent view viewB is re-enabled.

A wordy post and answer, but if someone can make sense of what I'm saying, hopefully it will help you.



Related Topics



Leave a reply



Submit