4
\$\begingroup\$

After a fair amount of trial and error, I have gotten the animation acting in the manner that I want it, as seen here. However, I always feel like I'm writing way too much and too sloppily when trying to use Core Animation.

The animation is as follows:

  1. The view begins with a point of light at the top of the loop.
  2. When the user initiates the timer, the glowing line takes the prescribed amount of time to fill the loop.
  3. When complete, the loop stays lit until acknowledged by the user.
  4. When acknowledged, the tail of the glowing line chases the head and returns to being a single point at the top of the screen.
  5. Repeat

The code controlling the animation is all wrapped in a subclass of UIView:

class LoopView: UIView {
 // ...
 lazy var path: UIBezierPath = initializedPath()
 lazy var animationLayer: CAShapeLayer = initializedAnimationLayer()
 override func draw(_ rect: CGRect) {
 layer.addSublayer(animationLayer)
 // ...
 }
 // ...
}

Here are the path and animation layer initializations:

 private func intializedPath() -> UIBezierPath {
 return UIBezierPath(ovalIn: bounds)
 }
 private func initializedAnimationLayer() -> CAShapeLayer {
 let layer = CAShapeLayer()
 let transform = CGAffineTransform(rotationAngle: 1.5 * CGFloat.pi).translatedBy(x: -bounds.width, y: 0)
 layer.path = path.cgPath
 layer.setAffineTransform(transform)
 layer.strokeColor = timerColor.cgColor
 layer.fillColor = UIColor.clear.cgColor
 layer.lineCap = kCALineCapRound
 layer.lineWidth = lineWidth
 layer.shadowColor = timerColor.cgColor
 layer.shadowRadius = 5.0
 layer.shadowOpacity = 1
 layer.shadowOffset = CGSize(width: 0, height: 0)
 layer.strokeEnd = CGFloat.leastNormalMagnitude
 return layer
 }

And finally, the meat of the animation:

func animateTimer(over duration: TimeInterval) {
 let head = CABasicAnimation(keyPath: "strokeEnd")
 head.fromValue = CGFloat.leastNormalMagnitude
 head.toValue = 1
 head.duration = duration
 head.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
 head.fillMode = kCAFillModeForwards
 head.isRemovedOnCompletion = false
 animationLayer.add(head, forKey: "strokeEnd")
}
func animateTimerReset() {
 UIView.animate(withDuration: 0, animations: { self.animationLayer.strokeStart = 1 }) { (_) in
 self.animationLayer = self.initializedAnimationLayer()
 self.layer.addSublayer(self.animationLayer)
 }
}

Where I spent the most time on figuring it out was that I didn't want to discard the animationLayer every time I wanted to reset the animation. I also had to use different methods of defining the animation for the fill and the collapse, which feels wrong, but trying to use the same strategies wasn't getting the results I wanted.

For reference, the full class code is in this gist

asked Mar 28, 2018 at 18:01
\$\endgroup\$

1 Answer 1

6
\$\begingroup\$

There is one problem with your approach: Additional sublayers are added in draw() and in animateTimerReset() and never removed. That can cause memory problems and slower drawing eventually.

I would suggest to create and add only a single instance of the animation layer, this can for example be done in

override func awakeFromNib() {
 layer.addSublayer(animationLayer)
}

Then animate this single animation layer:

func animateTimer(over duration: TimeInterval) {
 animationLayer.removeAllAnimations()
 let head = CABasicAnimation(keyPath: "strokeEnd")
 head.toValue = 1
 head.duration = duration
 head.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
 head.fillMode = kCAFillModeForwards
 head.isRemovedOnCompletion = false
 animationLayer.add(head, forKey: "strokeEnd")
}
func animateTimerReset() {
 let tail = CABasicAnimation(keyPath: "strokeStart")
 tail.toValue = 1
 tail.duration = 0.1
 tail.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
 tail.fillMode = kCAFillModeForwards
 tail.isRemovedOnCompletion = false
 animationLayer.add(tail, forKey: "strokeStart")
}

Some further remarks: At several places the explicit type annotation can be removed, e.g.

var trackColor: UIColor = UIColor(red: 0.3, green: 0.3, blue: 0.3, alpha: 1)
var trackShade: UIColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.6)
var trackLight: UIColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.3)

is shorter written as

var trackColor = UIColor(red: 0.3, green: 0.3, blue: 0.3, alpha: 1)
var trackShade = UIColor(red: 0, green: 0, blue: 0, alpha: 0.6)
var trackLight = UIColor(red: 1, green: 1, blue: 1, alpha: 0.3)

Also "implicit member expressions" can be used if the type is inferred from the context, e.g.

layer.strokeEnd = CGFloat.leastNormalMagnitude

becomes

layer.strokeEnd = .leastNormalMagnitude
answered Mar 28, 2018 at 22:11
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.