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

\$\endgroup\$
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.

\$\endgroup\$
  • \$\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\$ – Badhan Ganesh Jun 24 '18 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\$ – Badhan Ganesh Jun 25 '18 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 Jun 25 '18 at 7:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.