Drag Gesture Recognizer

Drag Gesture Recognizer

iOS is missing drag & drop gesture recognizer that would require user to hold his finger for a moment to begin similarly to UILongPressGestureRecognizer. Animation below presents how the gesture is going to look.

Drag & Drop

API Design

To be able to move a view gesture recognizer must deliver information about the distance and direction the gesture moves, namely - translation.

Reporting current translation can be accomplished in two ways. First approach would be to provide delta translation from the last time the translation was reported. Unfortunately this can easily lead to precision loss as numerical errors would add up with each delta delivery (view.center += deltaTranslation). In the second approach total translation would be provided instead, thus we would avoid the problem (view.center = viewInitialPosition + totalTranslation).

We also need to take into consideration that views may have various transformations applied to them (you can get the idea here). The same gesture could traverse different coordinate systems, resulting in various translation values.

View Transformations

Now it is clear that our translation function should take view as a parameter.

/// The total translation of the drag gesture in the coordinate system of the specified view.
/// - parameters:
///     - view: The view in whose coordinate system the translation of the drag gesture should be computed. Pass `nil` to indicate window.
func translation(in view: UIView?) -> CGPoint

As you probably have noticed this API has a drawback, each time the gesture begins client has to store initial view position in order to calculate the new position (view.center = viewInitialPosition + totalTranslation). This inconvenience can be avoided if we enable the gesture recognizer to modify it’s translation.

/// Sets the translation value in the coordinate system of the specified view.
/// - parameters:
///     - translation: A point that identifies the new translation value.
///     - view: A view in whose coordinate system the translation is to occur. Pass `nil` to indicate window.
func setTranslation(_ translation: CGPoint, in view: UIView?)

Provided that function, client can assign the view position as translation when gesture begins and avoid managing view initial position variable at all.

@objc private func handleGesture(gestureRecognizer: DragGestureRecognizer) {
    
    switch gestureRecognizer.state {
    
    case .began:
        gestureRecognizer.setTranslation(draggableView.center, in: view)
    
    case .changed:
        draggableView.center = gestureRecognizer.translation(in: view)
        
    case .cancelled, .failed, .ended, .possible:
        break
    }
}

Implementation

Since UILongPressGestureRecognizer already knows how to wait for a moment before the gesture begins we can use it as our base class.

In order to be able to get and set total translation we will need two properties:

private var initialTouchLocationInScreenFixedCoordinateSpace: CGPoint?
private var initialTouchLocationsInViews = [UIView: CGPoint]()

First one will store initial touch location in screen’s fixed coordinate space, the location can be easily converted to any view’s coordinate space. Second will store altered initial touch locations per view, this will allow the gesture recognizer to set custom translations per view.

override var state: UIGestureRecognizerState {
    
    didSet {
        
        switch state {
            
        case .began:
            initialTouchLocationInScreenFixedCoordinateSpace = location(in: nil)
        
        case .ended, .cancelled, .failed:
            initialTouchLocationInScreenFixedCoordinateSpace = nil
            initialTouchLocationsInViews = [:]
        
        case .possible, .changed:
            break
        }
    }
}

The fore-mentioned code listing reports an error unless the following header is imported.

import UIKit.UIGestureRecognizerSubclass

Once we have the properties in place we can provide the function to return translation. The idea is quite trivial, we get initial and current touch locations and return the difference. Please mind that initial touch location may be overridden by a value stored in initialTouchLocationsInViews.

func translation(in view: UIView?) -> CGPoint {
    
    // not attached to a view or outside window
    guard let window = self.view?.window else { return CGPoint() }
    // gesture not in progress
    guard let initialTouchLocationInScreenFixedCoordinateSpace = initialTouchLocationInScreenFixedCoordinateSpace else { return CGPoint() }
    
    let initialLocation: CGPoint
    let currentLocation: CGPoint
    
    if let view = view {
        initialLocation = initialTouchLocationsInViews[view] ?? window.screen.fixedCoordinateSpace.convert(initialTouchLocationInScreenFixedCoordinateSpace, to: view)
        currentLocation = location(in: view)
    }
    else {
        initialLocation = initialTouchLocationInScreenFixedCoordinateSpace
        currentLocation = location(in: nil)
    }
    
    return CGPoint(x: currentLocation.x - initialLocation.x, y: currentLocation.y - initialLocation.y)
}

Assigning a new translation boils down to storing initial touch location in the initialTouchLocationsInViews dictionary.

func setTranslation(_ translation: CGPoint, in view: UIView?) {
    
    // not attached to a view or outside window
    guard let window = self.view?.window else { return }
    // gesture not in progress
    guard let _ = initialTouchLocationInWindow else { return }
    
    let inView = view ?? window
    let currentLocation = location(in: inView)
    let initialLocation = CGPoint(x: currentLocation.x - translation.x, y: currentLocation.y - translation.y)
    initialTouchLocationsInViews[inView] = initialLocation
}

Navigate here to see the full project.


14 March 2019: Updated to include newest bug fixes.