3
\$\begingroup\$

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:

Output

asked Jun 24, 2018 at 16:30
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

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.

answered Jun 24, 2018 at 19:00
\$\endgroup\$
3
  • \$\begingroup\$ Thanks Martin, I will take your comments into account. I actually found UIEdgeInsetsInsetRect method right after I wrote getNewRectFrom. I didn't knew/search for an already existing one. Thanks again! :) \$\endgroup\$ Commented 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 get No visible @interface for 'Class' declares the selector 'myMethod:' And yeah, global keys can be avoided, thanks. \$\endgroup\$ Commented 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\$ Commented Jun 25, 2018 at 7:14

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.