Search code examples
swiftanimationuibezierpathcabasicanimationcaanimation

UIBezierPath Core Animation reverse both strokeStart and strokeEnd


I'm trying to create an indefinite loader where a bar moves from left to right. It's kind of like the spinner you see in Google Apps, but then a bar. This blog is a great post to show how a spinner like that can be created using UIBezierPaths and CABasicAnimation:

http://blog.matthewcheok.com/design-teardown-spinning-indicator/

But in my case, I want to do the same with a bar. So there's a line that goes from left to right, and like in the spinner, I also animate the strokeStart with a small delay such that it seems to follow the line and catch up with it at the end. And when it reaches the end, I want it to go back from right to left, etcetera. All of this using easeIn and easeOut. And I don't want the line to be invisible when it reaches the end, a small part should always remain visible.

To make my explanation much more clear, this is what I'm going for:

enter image description here

I know it seems like I already have want I want, but I created this gif by using ugly delay-code that don't work correctly half of the time.

So, to continue, the auto-reverse property of the CABasicAnimation can't be used because I animate both strokeEnd and strokeStart, so I had two ideas:

  1. Use only one layer and update the bezierPath as soon as the line reaches the left or right end.
  2. Use two animated layers and simply replace the one with the other as soon as the line reaches the left or right end.

Now, I got quite far, but the tricky part is when I have to switch the layers around. I always see a flicker, whatever I try. I know I have to use the presentation layer and set the right values before or after adding the animated group.

I use the CATransaction.setCompletionBlock to know when either the left or right part has been reached, and then I turn the other way.

Here are some parts of code I've written:

UIBezierPaths:

    let start:CGPoint = .init(x: 0, y: 0)
    let end:CGPoint = .init(x: frame.width, y: 0)
        
    let pathLeft = UIBezierPath()
    pathLeft.move(to: start)
    pathLeft.addLine(to: end)
    self.barLayerLeft.path = pathLeft.cgPath
       
    let pathRight = UIBezierPath()
    pathRight.move(to: end)
    pathRight.addLine(to: start)
    self.barLayerRight.path = pathRight.cgPath

Setup strokeStart and strokeEnd animations and a group

    let strokeStartAnimation: CAAnimation = {
        let animation = CABasicAnimation(keyPath: "strokeStart")
        animation.fromValue = 0
        animation.toValue = 0.99
        animation.beginTime = 0.3
        animation.duration = 1
        animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        return animation
    }()
    
    let strokeEndAnimation: CAAnimation = {
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.beginTime = 0
        animation.duration = 1.3
        animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        return animation
    }()

Group & layers

    let groupedAnimation:CAAnimationGroup = CAAnimationGroup()
    groupedAnimation.duration = 1.4
    groupedAnimation.animations = [self.strokeStartAnimation, self.strokeEndAnimation]
        
    self.barLayerLeft.lineWidth = 4
    self.barLayerLeft.lineCap = .round
    self.barLayerLeft.strokeColor = AppStyler.mainColor.cgColor
    self.layer.addSublayer(self.barLayerLeft)
      
    self.barLayerRight.lineWidth = 4
    self.barLayerRight.lineCap = .round
    self.barLayerRight.strokeColor = AppStyler.mainColor.cgColor
    self.layer.addSublayer(self.barLayerRight)

Animate left (incl. setting the final positions so the animated line will stay exactly where it was when the animation finished, and removing the right-to-left bar)

    self.barLayerLeft.strokeEnd = 1
    self.barLayerLeft.strokeStart = 0.01
    self.barLayerRight.strokeEnd = 1
    self.barLayerRight.strokeStart = 0
    
    self.barLayerLeft.add(self.groupedAnimation, forKey: "groupLeft")
    CATransaction.setCompletionBlock{ [weak self] in
        self.animateRight()        
    }

Animate right (and incl. positions like above)

    self.barLayerRight.strokeEnd = 1
    self.barLayerRight.strokeStart = 0.01        
    self.barLayerLeft.strokeEnd = 1
    self.barLayerLeft.strokeStart = 0
        
    self.barLayerRight.add(self.groupedAnimation, forKey: "groupRight")
    CATransaction.setCompletionBlock{ [weak self] in
        self.animateLeft()        
    }

I've already tried using 'CATransaction.setDisableActions' to switch the layers without seeing a flicker like this:

    CATransaction.begin()
    CATransaction.setDisableActions(true)
    self.barLayerLeft.strokeEnd = 0.01
    self.barLayerLeft.strokeStart = 0
    self.barLayerRight.strokeEnd = 1
    self.barLayerRight.strokeStart = 0
    CATransaction.commit()

But I can't get it to work...

So, my main question is. Can my ideas above actually work without flickering? Or do I need to use something else entirely...? Is there a way easier/better way to achieve what I want?

Btw, if anything is unclear or if I need to post more code, let me know!


Solution

  • I would approach this using masking and a single moving layer:

    let container = CALayer()
    container.frame = CGRect(x: 10, y: 10, width: 200, height: 10)
    container.borderColor = UIColor.black.cgColor
    container.borderWidth = 1
    container.cornerRadius = 5
    container.masksToBounds = true
    view.layer.addSublayer(container)
    let bar = CALayer()
    bar.frame = container.bounds
    bar.anchorPoint = CGPoint(x: 0, y: 0.5)
    bar.position = CGPoint(x: -195, y: 5)
    bar.backgroundColor = UIColor.blue.cgColor
    container.addSublayer(bar)
    let animation = CABasicAnimation(keyPath: "position.x")
    animation.fromValue = bar.position.x
    animation.toValue = 195
    animation.autoreverses = true
    animation.repeatCount = .infinity
    animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    animation.duration = 2
    bar.add(animation, forKey: nil)
    

    Looks like this, but less terrible because it's not a GIF:

    enter image description here

    This would be much simpler to pause at the end of your work, as it's a single animation.

    For a bar that doesn't fill the whole area, change the frame of the bar:

    bar.frame = CGRect(x: 0, y: 0, width: 75, height: 10)
    

    And the start position:

    bar.position = CGPoint(x: -70, y: 5)
    

    Which gives you:

    enter image description here

    You can round the edges of the moving bar like so:

    bar.cornerRadius = 5