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 UIScrollView
s 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.
- Create a subclass of
UIView
calledMovableView
that hasNSLayoutConstraint?
properties forhorizontalConstraint
andverticalConstraint
. - Set those properties when you create the constraints before setting their
isActive = true
. When you need to move a
MovableView
, update theconstant
property for thehorizontalConstraint
andverticalConstraint
.moveableView.horizontalConstraint?.constant = newXConstant
moveableView.verticalConstraint?.constant = newYConstantAlternatively, you can set
horizontalConstraint?.isActive = false
andverticalConstraint?.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 werestatic
instead of member properties. You need them to be member properties in order to support multiple movable views. - You had
startingConstantPosX
andstartingConstantPosY
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.
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
Swift 2: Multiline Mkpointannotation
Custom Cell with Uitableview Inside Uicollectionviewcell
Simple Swift Class Does Not Compile
Xctest Unit Test Data Response Not Set in Test After Viewdidload
Use of or Operator on Cloudkit Predicate
Why Does Function Has Multiple Return Types in Swift
Swift Alamofire Return Value Is Empty
Why Do We Need to Specify Init Method
Swift Switch Char{ Case "\U{E2}:
How to Inject .Environmentobject() in Watchos6
How to Change the Appearance of the Datepicker in the Swiftui Framework to Only Months and Years
Auth.Auth().Currentuser.Reload() Doesn't Refresh Currentuser.Isemailverified
What Does the Underscore in a Function Declaration Do
Uitextview Linktextattributes Font Attribute Not Applied to Nsattributedstring