I recently published an iOS control/component called BJDraggable
which basically, with a call of a method, enables us to drag a view within its superview boundary. The whole setup works using the UIKitDynamics
API. (Scroll to last to see the output achieved.)
These are the methods I expose to consumers. You could follow up from these method calls in the detailed code (BJDraggable.swift).
@objc protocol BJDraggable: class {
@objc func addDraggability(withinView referenceView: UIView)
@objc func addDraggability(withinView referenceView: UIView, withMargin insets:UIEdgeInsets)
@objc func removeDraggability()
}
Here is my full code. Please bear with the length of the code; it is pretty long. Demo project is available here at GitHub. Thanks in advance for your time.
BJDraggable.swift
import UIKit
var kReferenceViewKey: String = "ReferenceViewKey"
var kDynamicAnimatorKey: String = "DynamicAnimatorKey"
var kAttachmentBehaviourKey: String = "AttachmentBehaviourKey"
var kPanGestureKey: String = "PanGestureKey"
var kResetPositionKey: String = "ResetPositionKey"
fileprivate enum BehaviourNames {
case main
case border
case collision
case attachment
}
/**A simple protocol *(No need to implement methods and properties yourself. Just drop-in the BJDraggable file to your project and all done)* utilizing the powerful `UIKitDynamics` API, which makes **ANY** `UIView` draggable within a boundary view that acts as collision body, with a single method call.
*/
@objc protocol BJDraggable: class {
/**
Gives you the power to drag your `UIView` anywhere within a specified view, and collide within its bounds.
- parameter referenceView: The boundary view which acts as a wall, and your view will collide with it and would never fall out of bounds hopefully. **Note that the reference view should contain the view that you're trying to add draggability to in its view hierarchy. The app would crash otherwise.**
*/
@objc func addDraggability(withinView referenceView: UIView)
/**
This single method call will give you the power to drag your `UIView` anywhere within a specified view, and collide within its bounds.
- parameter referenceView: This is the boundary view which acts as a wall, and your view will collide with it and would never fall out of bounds hopefully. **Note that the reference view should contain the view that you're trying to add draggability to in its view hierarchy. The app would crash otherwise.**
- parameter insets: If you want to make the boundary to be offset positively or negatively, you can specify that here. This is nothing but a margin for the boundary.
*/
@objc func addDraggability(withinView referenceView: UIView, withMargin insets:UIEdgeInsets)
/**
Removes the power from you, to drag the view in question
*/
@objc func removeDraggability()
}
///Implementation of `BJDraggable` protocol
extension UIView: BJDraggable {
//
//////////////////////////////////////////////////////////////////////////////////////////
//MARK:-
//MARK: Properties
//MARK:-
//////////////////////////////////////////////////////////////////////////////////////////
//
public var shouldResetViewPositionAfterRemovingDraggability: Bool {
get {
let getValue = (objc_getAssociatedObject(self, &kResetPositionKey) as? Bool)
return getValue == nil ? false : getValue!
}
set {
objc_setAssociatedObject(self, &kResetPositionKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
self.translatesAutoresizingMaskIntoConstraints = !newValue
}
}
fileprivate var referenceView: UIView? {
get {
return objc_getAssociatedObject(self, &kReferenceViewKey) as? UIView
}
set {
objc_setAssociatedObject(self, &kReferenceViewKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
fileprivate var animator: UIDynamicAnimator? {
get {
return objc_getAssociatedObject(self, &kDynamicAnimatorKey) as? UIDynamicAnimator
}
set {
objc_setAssociatedObject(self, &kDynamicAnimatorKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
fileprivate var attachmentBehaviour: UIAttachmentBehavior? {
get {
return objc_getAssociatedObject(self, &kAttachmentBehaviourKey) as? UIAttachmentBehavior
}
set {
objc_setAssociatedObject(self, &kAttachmentBehaviourKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
fileprivate var panGestureRecognizer: UIPanGestureRecognizer? {
get {
return objc_getAssociatedObject(self, &kPanGestureKey) as? UIPanGestureRecognizer
}
set {
objc_setAssociatedObject(self, &kPanGestureKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
//
//////////////////////////////////////////////////////////////////////////////////////////
//MARK:-
//MARK: Method Implementations
//MARK:-
//////////////////////////////////////////////////////////////////////////////////////////
//
final func addDraggability(withinView referenceView: UIView) {
self.addDraggability(withinView: referenceView, withMargin: UIEdgeInsets.init(top: 0, left: 0, bottom: 0, right: 0))
}
final func addDraggability(withinView referenceView: UIView, withMargin insets:UIEdgeInsets) {
guard self.animator == nil else { return }
///////////////////////
/////Configuration/////
///////////////////////
performInitialConfiguration()
addPanGestureRecognizer()
////////////////////////////////////////////////
/////Getting Collision Items For Behaviours/////
////////////////////////////////////////////////
let collisionItems = self.drawAndGetCollisionViewsAround(referenceView, withInsets: insets)
////////////////////
/////Behaviours/////
////////////////////
let mainItemBehaviour = get(behaviour: .main, for: referenceView, withInsets: insets, configuredWith: collisionItems)!
let borderItemsBehaviour = get(behaviour: .border, for: referenceView, withInsets: insets, configuredWith: collisionItems)!
let collisionBehaviour = get(behaviour: .collision, for: referenceView, withInsets: insets, configuredWith: collisionItems)!
let attachmentBehaviour = get(behaviour: .attachment, for: referenceView, withInsets: insets, configuredWith: collisionItems)!
//////////////////
/////Animator/////
//////////////////
let animator = UIDynamicAnimator.init(referenceView: referenceView)
animator.addBehavior(mainItemBehaviour)
animator.addBehavior(borderItemsBehaviour)
animator.addBehavior(collisionBehaviour)
animator.addBehavior(attachmentBehaviour)
/////////////////////
/////Persistence/////
/////////////////////
self.animator = animator
self.referenceView = referenceView
self.attachmentBehaviour = attachmentBehaviour as? UIAttachmentBehavior
}
final func removeDraggability() {
if let recognizer = self.panGestureRecognizer { self.removeGestureRecognizer(recognizer) }
self.translatesAutoresizingMaskIntoConstraints = !self.shouldResetViewPositionAfterRemovingDraggability
self.animator?.removeAllBehaviors()
if let subviews = self.referenceView?.subviews {
for view in subviews {
if view.tag == 122 || view.tag == 222 || view.tag == 322 || view.tag == 422 {
view.removeFromSuperview()
}
}
}
self.referenceView = nil
self.attachmentBehaviour = nil
self.animator = nil
self.panGestureRecognizer = nil
}
//
//////////////////////////////////////////////////////////////////////////////////////////
//MARK:-
//MARK: Helpers 1
//MARK:-
//////////////////////////////////////////////////////////////////////////////////////////
//
fileprivate func performInitialConfiguration() {
self.isUserInteractionEnabled = true
}
fileprivate func addPanGestureRecognizer() {
let panGestureRecognizer = UIPanGestureRecognizer.init(target: self, action: #selector(self.panGestureHandler(_:)))
self.addGestureRecognizer(panGestureRecognizer)
self.panGestureRecognizer = panGestureRecognizer
}
@objc final func panGestureHandler(_ gesture: UIPanGestureRecognizer) {
guard let referenceView = self.referenceView else { return }
let touchPoint = gesture.location(in: referenceView)
self.attachmentBehaviour?.anchorPoint = touchPoint
}
fileprivate func get(behaviour:BehaviourNames, for referenceView:UIView, withInsets:UIEdgeInsets, configuredWith boundaryCollisionItems:[UIDynamicItem]) -> UIDynamicBehavior? {
let allItems = [self] + boundaryCollisionItems
switch behaviour {
case .border:
let borderItemsBehaviour = UIDynamicItemBehavior.init(items: boundaryCollisionItems)
borderItemsBehaviour.allowsRotation = false
borderItemsBehaviour.isAnchored = true
borderItemsBehaviour.friction = 2.0
return borderItemsBehaviour
case .main:
let mainItemBehaviour = UIDynamicItemBehavior.init(items: [self])
mainItemBehaviour.allowsRotation = false
mainItemBehaviour.isAnchored = false
mainItemBehaviour.friction = 2.0
return mainItemBehaviour
case .collision:
let collisionBehaviour = UICollisionBehavior.init(items: allItems)
collisionBehaviour.collisionMode = .items
collisionBehaviour.addBoundary(withIdentifier: "Boundary" as NSCopying, for: self.boundaryPathFor(referenceView))
return collisionBehaviour
case .attachment:
let attachmentBehaviour = UIAttachmentBehavior.init(item: self, attachedToAnchor: self.center)
return attachmentBehaviour
}
}
//
//////////////////////////////////////////////////////////////////////////////////////////
//MARK:-
//MARK: Helpers 2
//MARK:-
//////////////////////////////////////////////////////////////////////////////////////////
//
func alteredFrameByPoints(_ point:CGFloat) -> CGRect {
var newFrame = self.frame
newFrame.origin.x -= point
newFrame.origin.y -= point
newFrame.size.width += point * 2
newFrame.size.height += point * 2
return newFrame
}
fileprivate func boundaryPathFor(_ view:UIView) -> UIBezierPath {
let cgPath = CGPath.init(rect: view.alteredFrameByPoints(2.0), transform:nil)
return UIBezierPath.init(cgPath: cgPath)
}
fileprivate func getNewRectFrom(rect:CGRect, byApplying insets:UIEdgeInsets) -> CGRect {
var newRect:CGRect = .zero
let x = rect.origin.x + insets.left
let y = rect.origin.y + insets.top
let width = rect.width - insets.right
let height = rect.height - insets.bottom
newRect.origin.x = x
newRect.origin.y = y
newRect.size.width = width
newRect.size.height = height
return newRect
}
@discardableResult
fileprivate func drawAndGetCollisionViewsAround(_ referenceView:UIView, withInsets insets:UIEdgeInsets) -> ([UIView]) {
let boundaryViewWidth = CGFloat(1)
let boundaryViewHeight = CGFloat(1)
////////////////////
////Get New Rect////
////////////////////
let newReferenceViewRect = self.getNewRectFrom(rect:referenceView.alteredFrameByPoints(1),
byApplying:insets)
////////////
////Left////
////////////
let leftView = UIView(frame: CGRect.init(x: newReferenceViewRect.origin.x - (boundaryViewWidth - 1), y: newReferenceViewRect.origin.y, width: boundaryViewWidth, height: newReferenceViewRect.size.height - insets.bottom))
leftView.isUserInteractionEnabled = false
leftView.tag = 122
/////////////
////Right////
/////////////
let rightView = UIView(frame: CGRect.init(x: newReferenceViewRect.size.width - 2.0, y: newReferenceViewRect.origin.y, width: boundaryViewWidth, height: newReferenceViewRect.size.height - insets.bottom))
rightView.isUserInteractionEnabled = false
rightView.tag = 222
///////////
////Top////
///////////
let topView = UIView(frame: CGRect.init(x: newReferenceViewRect.origin.x, y: newReferenceViewRect.origin.y - (boundaryViewHeight - 1), width: newReferenceViewRect.size.width - insets.right, height: boundaryViewHeight))
topView.isUserInteractionEnabled = false
topView.tag = 322
//////////////
////Bottom////
//////////////
let bottomView = UIView(frame: CGRect.init(x: newReferenceViewRect.origin.x, y: newReferenceViewRect.size.height - 2.0, width: newReferenceViewRect.size.width - insets.right, height: boundaryViewHeight))
bottomView.isUserInteractionEnabled = false
bottomView.tag = 422
///////////////////
////Add Subview////
///////////////////
referenceView.addSubview(leftView)
referenceView.addSubview(rightView)
referenceView.addSubview(topView)
referenceView.addSubview(bottomView)
return [leftView, rightView, topView, bottomView]
}
}
This is how it works:
1 Answer 1
Comparing an optional value with nil
as in
return getValue == nil ? false : getValue!
is better done with the nil-coalescing operator ??
:
return getValue ?? false
It is shorter, avoids the forced-unwrapping, accesses the variable only once, and clearly expresses the intent. (See also When should I compare an optional value to nil? on Stack Overflow.)
And now the intermediate variable is not needed anymore:
return objc_getAssociatedObject(self, &kResetPositionKey) as? Bool ?? false
The keys for the associated objects
var kReferenceViewKey: String = "ReferenceViewKey"
// ...
are global variables. To restrict their visibility, they can be made "file private"
fileprivate var kReferenceViewKey = "ReferenceViewKey"
// ...
or static properties, private to the extension:
extension UIView: BJDraggable {
private static var kReferenceViewKey = "ReferenceViewKey"
// ...
}
Note also that the explicit type annotation is not necessary.
Only the address of the variable is needed as key for the associated value, the type and value does not matter. You can even define it as a single byte
private static var kReferenceViewKey: UInt8 = 0
to save some memory.
Here
if view.tag == 122 || view.tag == 222 || view.tag == 322 || view.tag == 422
"magic tag numbers" are used to identify the special views which were added earlier. That is error-prone, since the original UIView
might use
the same tags by chance.
An alternative would be to create a custom UIView
subclass for those
special views, or keep references to them in another (associated)
property.
This
func alteredFrameByPoints(_ point:CGFloat) -> CGRect {
var newFrame = self.frame
newFrame.origin.x -= point
newFrame.origin.y -= point
newFrame.size.width += point * 2
newFrame.size.height += point * 2
return newFrame
}
can be simplified to
func alteredFrameByPoints(_ point:CGFloat) -> CGRect {
return self.frame.insetBy(dx: -point, dy: -point)
}
and this function
fileprivate func getNewRectFrom(rect:CGRect, byApplying insets:UIEdgeInsets) -> CGRect {
var newRect:CGRect = .zero
let x = rect.origin.x + insets.left
let y = rect.origin.y + insets.top
let width = rect.width - insets.right
let height = rect.height - insets.bottom
newRect.origin.x = x
newRect.origin.y = y
newRect.size.width = width
newRect.size.height = height
return newRect
}
is exactly what
UIEdgeInsetsInsetRect(rect, insets) // Swift <= 4.1
rect.inset(by: insets) // Swift >= 4.2
already does.
-
\$\begingroup\$ Thanks Martin, I will take your comments into account. I actually found
UIEdgeInsetsInsetRect
method right after I wrotegetNewRectFrom
. I didn't knew/search for an already existing one. Thanks again! :) \$\endgroup\$badhanganesh– badhanganesh2018年06月24日 19:11:09 +00:00Commented Jun 24, 2018 at 19:11 -
\$\begingroup\$ The
@objc
annotation exposes my methods to Objective-C code. Without those, I could not call them and I getNo visible @interface for 'Class' declares the selector 'myMethod:'
And yeah, global keys can be avoided, thanks. \$\endgroup\$badhanganesh– badhanganesh2018年06月25日 06:55:05 +00:00Commented Jun 25, 2018 at 6:55 -
\$\begingroup\$ @BadhanGanesh: You are right, I hadn't thought about using the extension from Objective-C. I have removed that part from the answer. \$\endgroup\$Martin R– Martin R2018年06月25日 07:14:54 +00:00Commented Jun 25, 2018 at 7:14