0
votes

I'm working on a scrolling algorithm that takes input from a UIPanGestureRecognizer to move a drawn object around in a custom UIView. I want the scrolling to feel like the UIScrollView, instead it feels kind of clunky. I have a problem implementing the case when the finger is lifted after a pan gesture, and the particle decelerates until it stops.

The deceleration caused by the algorithm seems to differ from time to time. Sometimes it seems like it actually accelerates a little before it decelerates.

I'm wondering if you guys have some suggestions to how fix this?

The algorithm works as follows:

  • When the recognizer state is "began" the current position of the particle is stored in the variable startPosition.

  • While the recognizer state is "changed" the particles current position is calculated relative to the movement. The current velocity is stored and the view is updated to display the particle in it's new location.

  • When the recognizer state is "ended" we enter a global thread using the GCD API to calculate the remaining movement (This is to prevent the UI from freezing). A loop is started and set to run at 60 fps. The particles position is updated and the velocity is decreased each iteration. We enter the main thread in order to update the view to display the particle in its new position.

    func scroll(recognizer: UIPanGestureRecognizer, view: UIView) {

        switch recognizer.state {
        case .began:
            startPosition = currentPosition

        case .changed:
            currentPosition = startPosition + recognizer.translation(in: view)
            velocity = recognizer.velocity(in: view)
            view.setNeedsDisplay()

        case .ended:
            DispatchQueue.global().async {
                let fps: Double = 60
                let delayTime: Double = 1 / fps
                var frameStart = CACurrentMediaTime()
                let friction: CGFloat = 0.9
                let tinyValue: CGFloat = 0.001

                while (self.velocity > tinyValue) {
                    self.currentPosition += (self.velocity / CGFloat(fps))
                    DispatchQueue.main.sync {
                        view.setNeedsDisplay()
                    }
                    self.velocity *= friction

                    let frameTime = CACurrentMediaTime() - frameStart
                    if (frameTime < delayTime) {
                        // calculate time to sleep in μs
                        usleep(UInt32((delayTime - frameTime) * 1E6))
                    }
                    frameStart = CACurrentMediaTime()
                }
            }

        default:
            return
        }
    }

The problem may be that the loop isn't running at a very steady frame rate. But the frame rate looks steady enough to me (I might me wrong). Here is a sample of the calculated frame rate:

    "
    Frame rate: 59.447833329705766
    Frame rate: 57.68833849362473
    Frame rate: 57.43794057083063
    Frame rate: 53.11410092668673
    Frame rate: 51.76492245230155
    Frame rate: 52.71845062546561
    Frame rate: 50.211233616282904
    Frame rate: 59.86028817338459
    Frame rate: 55.7360938798143
    Frame rate: 47.55385819651489
    Frame rate: 50.13437540167264
    Frame rate: 48.93274027995551
    Frame rate: 50.76905714109756
    Frame rate: 57.06095686426517
    Frame rate: 49.852101165412876
    Frame rate: 51.49043459888154
    Frame rate: 55.96442240956844
    Frame rate: 53.66651780498373
    Frame rate: 55.336349953967726
    Frame rate: 51.4698476880566
    "

Calculated by adding the following line at the end of the loop, just before updating the frameStart variable: print("Frame rate: \(1 / (CACurrentMediaTime() - frameStart))") Any suggestions on how to make the frame rate even more steady?

Race conditions could be the problem, but the currentPosition (which is used to position the particle) variable is protected by a semaphore. And I cannot (to my lack knowledge) se any other critical regions in the code.

var currentPosition: CGPoint {
    get {
        semaphore.wait()
        let pos = _currentPosition
        semaphore.signal()
        return pos
    }
    set {
        semaphore.wait()
        _currentPosition = newValue
        semaphore.signal()
    }
}

I'm happy to hear any suggestions. Thanks !

1
You'll probably get better accuracy if you use a CADisplayLink rather than your own loop. Also, when it comes to frame rate, there's a big difference between 60fps and 59fps since that will have one step of the animation visible for twice as long as the rest making it feel less smooth. - Craig Siemens

1 Answers

0
votes

With a suggestion from @CraigSiemens I was able to make the animation very smooth using the CADisplayLink. Thanks a lot @CraigSiemens! Still don't know if my implementation of the CADisplayLink is the ultimate solution, but at least a great improvment. The solution looks as follows:

ViewController.swift

override func viewDidLoad() {
    super.viewDidLoad()

    displayLink = CADisplayLink(target: self, selector: #selector(step))
    displayLink.add(to: .current, forMode: .default)
}

deinit {
    displayLink.invalidate()
}

@objc func step(displayLink: CADisplayLink) {
    InputHandler.shared.decelerate(displayLink: displayLink)
    drawView.setNeedsDisplay()
}

InputHandler.swift

func scroll(recognizer: UIPanGestureRecognizer, view: UIView, displayLink: CADisplayLink) {

    switch recognizer.state {
    case .began:
        decelerate = false
        displayLink.isPaused = false
        startPosition = currentPosition

    case .changed:
        currentPosition = startPosition + recognizer.translation(in: view)
        velocity = recognizer.velocity(in: view)

    case .ended:
        decelerate = true

    default:
        return
    }
}

func decelerate(displayLink: CADisplayLink) {
    if decelerate {
        let friction: CGFloat = 0.9
        let delayTime = displayLink.targetTimestamp - displayLink.timestamp
        let fps = 1 / delayTime

        self.currentPosition += (self.velocity / CGFloat(fps))
        self.velocity *= friction

        if (self.velocity < 0.01) {
            decelerate = false
            displayLink.isPaused = true
        }
    }
}