3
\$\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

\$\endgroup\$
4
\$\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
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.