2

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?

Community
  • 1
  • 1
JEL
  • 1,540
  • 4
  • 23
  • 51
  • 1
    I think this SO question might be helpful: http://stackoverflow.com/questions/26112497/place-images-along-a-bezier-path (your question will be solved if you could generate the point at certain percentage) – Cheng-Yu Hsu Apr 18 '16 at 22:47
  • @Cheng-YuHsu Thanks for responding! I'm having trouble following along that link you sent. Can you help with Swift implementation or maybe another link? – JEL Apr 18 '16 at 23:54

2 Answers2

2

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!

JEL
  • 1,540
  • 4
  • 23
  • 51
1

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.

You may want to try calculating a custom path based on a segment of an arc representing the path between your current position, and your desired position. Basing an animation on this, instead of deconstructing your custom path (which looks very close to just being an arc), may be easier.

CGPathAddArc() with x, y, and r being constant, should get you 90% to what your path is now. You could also get fancier with the path to add that line segment like you have at the beginning of your path. It would just take a bit more work to get the partial path to come out right for all the "I'm at this position, get me a path to this other position" logic.

Additionally, you will find the CAKeyframeAnimation class to be useful. You can feed it a CGPath (perhaps one based on the arc segment to travel), and the timing for the animation, and it can make your layer follow the path.

Source: https://developer.apple.com/library/ios/documentation/GraphicsImaging/Reference/CGPath/index.html#//apple_ref/c/func/CGPathAddArc

Source: https://developer.apple.com/library/ios/documentation/GraphicsImaging/Reference/CAKeyframeAnimation_class/index.html

Edit:

Here is some example code for how to draw a partial arc on a CGPath from the current progress to the new progress. I made it work in reverse too. You can play with the numbers and constants, but this is the idea of how to draw an arc segment from a certain percentage to a certain percentage.

Please keep in mind when looking at the CoreGraphics math that it may seem backwards (clockwise vs counterclockwise, etc). This is because UIKit flips everything upside down to put the origin in the upper-left, where CG has its origin in the lower-left.

// start out with start percent at zero, but then use the last endPercent instead
let startPercent = CGFloat(0.0)
// end percent is the "progress" in your code
let endPercent = CGFloat(1.0)

// reverse the direction of the path if going backwards
let clockwise = startPercent > endPercent ? false : true

let minArc = CGFloat(M_PI) * 4/5
let maxArc = CGFloat(M_PI) * 1/5
let arcLength = minArc - maxArc

let beginArc = minArc - (arcLength * startPercent)
let endArc = maxArc + (arcLength * (1.0 - endPercent))

let myPath = CGPathCreateMutable()
CGPathAddArc(myPath, nil, view.bounds.width/2, 0, 160, beginArc, endArc, clockwise)

Here is the full arc segment as defined by the constants minArc and maxArc.

Full Arc Segment

Sam Falconer
  • 488
  • 4
  • 9
  • It looks like your right about the `scrollViewDidScroll` not being called enough, I printed the `progress` variable I made and it jumps from 0.0 to 0.55 and then to 0.90 which skips the in betweens. Thanks for that! I know how to do this with `CAKeyFrameAnimation` but not combined with a dynamic value based on how much the user pulled down on the scrollView. Could you please help by writing some code or linking to an answer/blog post? I've tried so many things at this point. – JEL Apr 19 '16 at 02:21
  • I know how to use `CAKeyframeAnimation` outside of `scrollViewDidScroll` but will this have the effect that as you pull the scrollView `spaceShipImage.layer` moves forward and as you let go `spaceShipImage.layer` goes backward? – JEL Apr 19 '16 at 02:57
  • If you feed a lower percent into the `endPercent` than the `startPercent` the path should draw in reverse, which would cause your animation to go backwards. – Sam Falconer Apr 19 '16 at 03:49
  • With your answer, it helped lead me to how to solve this with my custom path I created. I added my answer here and mentioned all of the help you provided and quoted what was useful. Thank you!! – JEL Apr 19 '16 at 23:05
  • Glad you were able to get it to work with the custom path, and that I could help. Would you mind upvoting my answer, since you found it helpful? – Sam Falconer Apr 20 '16 at 00:21
  • No prob, just did. – JEL Apr 20 '16 at 00:42