Search code examples
iosobjective-cswiftcore-graphicspaintcode

Dynamically change position based on scrollView


I have a "U" shaped UIBezierPath which I use as the path for my myImage.layer to animate on. I also have a scrollView. My goal is to have a custom "Pull to Refresh" animation.

The problem I am having is that I want my myImage.layer to update based on how much the scrollView scrolled.

As the scrollView is pulled down, the myImage.layer animates along a "U" shape path. This is the path in my code which I created as a UIBezierPath.

This is how I calculate how far the scrollView is pulled down:

func scrollViewDidScroll(scrollView: UIScrollView) {
    let offsetY = CGFloat(max(-(scrollView.contentOffset.y + scrollView.contentInset.top), 0.0))
    self.progress = min(max(offsetY / frame.size.height, 0.0), 1.0)

    if !isRefreshing {
        redrawFromProgress(self.progress)
    }
}

This is the function to dynamically update the position (it is not working):

func redrawFromProgress(progress: CGFloat) {

    // PROBLEM: This is not correct. Only the `x` position is dynamic based on scrollView position.
    // The `y` position is static. 
    // I want this to be dynamic based on how much the scrollView scrolled.
    myImage.layer.position = CGPoint(x: progress, y: 50)

}

Basically, this is what I want:

  • If the scrollView scrolled is 0.0, then the myImage.layer position should be CGPoint(x: 0, y: 0) or the starting point of the path.

  • If the scrollView scrolled is 0.5 (50%), then the myImage.layer position should be at 50% of the path, I don't know what the CGPoint value would be here.

  • and so on...

I tried getting the CGPoint values along the UIBezierPath and based on the % of the scrollView scrolled, assign that CGPoint value to it but don't know how to do this. I also looked at this post but I can't get it to work for me.

EDIT QUESTION 1:

By using this extension, I was able to get an array of CGPoints which contain 10 values based on my UIBezierPath:

extension CGPath {
func forEachPoint(@noescape body: @convention(block) (CGPathElement) -> Void) {
    typealias Body = @convention(block) (CGPathElement) -> Void
    func callback(info: UnsafeMutablePointer<Void>, element: UnsafePointer<CGPathElement>) {
        let body = unsafeBitCast(info, Body.self)
        body(element.memory)
    }
    // print(sizeofValue(body))
    let unsafeBody = unsafeBitCast(body, UnsafeMutablePointer<Void>.self)
    CGPathApply(self, unsafeBody, callback)
}

func getPathElementsPoints() -> [CGPoint] {
    var arrayPoints : [CGPoint]! = [CGPoint]()
    self.forEachPoint { element in
        switch (element.type) {
        case CGPathElementType.MoveToPoint:
            arrayPoints.append(element.points[0])
        case .AddLineToPoint:
            arrayPoints.append(element.points[0])
        case .AddQuadCurveToPoint:
            arrayPoints.append(element.points[0])
            arrayPoints.append(element.points[1])
        case .AddCurveToPoint:
            arrayPoints.append(element.points[0])
            arrayPoints.append(element.points[1])
            arrayPoints.append(element.points[2])
        default: break
        }
    }
    return arrayPoints
}

I also rewrote the function above called redrawFromProgress(progress: CGFloat) to this:

func redrawFromProgress(progress: CGFloat) {

    let enterPath = paths[0]
    let pathPointsArray = enterPath.CGPath
    let junctionPoints = pathPointsArray.getPathElementsPoints()
    // print(junctionPoints.count) // There are 10 junctionPoints

    // progress means how much the scrollView has been pulled down,
    // it goes from 0.0 to 1.0. 

    if progress <= 0.1 {

        myImage.layer.position = junctionPoints[0]

    } else if progress > 0.1 && progress <= 0.2 {

        myImage.layer.position = junctionPoints[1]

    } else if progress > 0.2 && progress <= 0.3 {

        myImage.layer.position = junctionPoints[2]

    } else if progress > 0.3 && progress <= 0.4 {

        myImage.layer.position = junctionPoints[3]

    } else if progress > 0.4 && progress <= 0.5 {

        myImage.layer.position = junctionPoints[4]

    } else if progress > 0.5 && progress <= 0.6 {

        myImage.layer.position = junctionPoints[5]

    } else if progress > 0.6 && progress <= 0.7 {

        myImage.layer.position = junctionPoints[6]

    } else if progress > 0.7 && progress <= 0.8 {

        myImage.layer.position = junctionPoints[7]

    } else if progress > 0.8 && progress <= 0.9 {

        myImage.layer.position = junctionPoints[8]

    } else if progress > 0.9 && progress <= 1.0 {

        myImage.layer.position = junctionPoints[9]

    }

}

If I pull down the scrollView very slow, the myImage.layer actually follows the path. The only problem is that if I pull down on the scrollView very fast, then the myImage.layer jumps to the last point. Could it be because of the way I wrote the if statement above?

Any ideas?


Solution

  • Thanks to @Sam Falconer for making me aware of this:

    Your code is relying on the scrollViewDidScroll delegate callback to be called frequently enough to hit all of your keyframe points. When you pull quickly on the scroll view, it does not call that method frequently enough, causing the jump.

    Once I confirmed this, he also helped by mentioning:

    Additionally, you will find the CAKeyframeAnimation class to be useful.

    With CAKeyfraneAnimation I am able to manually control it's value with this code:

    func scrollViewDidScroll(scrollView: UIScrollView) {
        let offsetY = CGFloat(max(-(scrollView.contentOffset.y + scrollView.contentInset.top), 0.0))
        self.progress = min(max(offsetY / frame.size.height, 0.0), 1.0)
    
        if !isRefreshing {
            redrawFromProgress(self.progress)
        }
    }
    
    
    func redrawFromProgress(progress: CGFloat) {
    
        // Animate image along enter path
        let pathAnimation = CAKeyframeAnimation(keyPath: "position")
        pathAnimation.path = myPath.CGPath
        pathAnimation.calculationMode = kCAAnimationPaced
        pathAnimation.timingFunctions = [CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)]
        pathAnimation.beginTime = 1e-100
        pathAnimation.duration = 1.0
        pathAnimation.timeOffset = CFTimeInterval() + Double(progress)
        pathAnimation.removedOnCompletion = false
        pathAnimation.fillMode = kCAFillModeForwards
    
        imageLayer.addAnimation(pathAnimation, forKey: nil)
        imageLayer.position = enterPath.currentPoint
    }
    

    Thanks again for the help guys!