iosswiftuibezierpathcashapelayercabasicanimation

Animate a UIBezierPath hexagon like UIActivityIndicatorview


I'm trying to achieve exactly the same animation shown below

enter image description here.

and my output using UIBezierPath and CABasicAnimation is this below.

enter image description here

Here is my LoaderView code

class LoaderView: UIView {

private let lineWidth : CGFloat = 5
internal var backgroundMask = CAShapeLayer()


override init(frame: CGRect) {
    super.init(frame: frame)
    setUpLayers()
    createAnimation()
}


required init?(coder: NSCoder) {
    super.init(coder: coder)
    setUpLayers()
    createAnimation()
}

func setUpLayers()
{
    backgroundMask.lineWidth = lineWidth
    backgroundMask.fillColor = nil
    backgroundMask.strokeColor = UIColor.blue.cgColor
    layer.mask = backgroundMask
    layer.addSublayer(backgroundMask)
}

func createAnimation()
{
    let animation = CABasicAnimation(keyPath: "strokeEnd")
    animation.fromValue = 0
    animation.duration = 1
    animation.repeatCount = .infinity
    backgroundMask.add(animation, forKey: "MyAnimation")
}

override func draw(_ rect: CGRect) {
    let sides = 6
    let rect = self.bounds
    let path = UIBezierPath()
    
    let cornerRadius : CGFloat = 10
    let rotationOffset = CGFloat(.pi / 2.0)
    
    let theta: CGFloat = CGFloat(2.0 * .pi) / CGFloat(sides) // How much to turn at every corner
    let width = min(rect.size.width, rect.size.height)        // Width of the square
    
    let center = CGPoint(x: rect.origin.x + width / 2.0, y: rect.origin.y + width / 2.0)
    
    // Radius of the circle that encircles the polygon
    // Notice that the radius is adjusted for the corners, that way the largest outer
    // dimension of the resulting shape is always exactly the width - linewidth
    let radius = (width - lineWidth + cornerRadius - (cos(theta) * cornerRadius)) / 2.0
    
    
    // Start drawing at a point, which by default is at the right hand edge
    // but can be offset
    var angle = CGFloat(rotationOffset)
    
    let corner = CGPoint(x: center.x + (radius - cornerRadius) * cos(angle), y: center.y + (radius - cornerRadius) * sin(angle))
    path.move(to: CGPoint(x: corner.x + cornerRadius * cos(angle + theta), y: corner.y + cornerRadius * sin(angle + theta)))
    
    for _ in 0..<sides {
        angle += theta
        
        let corner = CGPoint(x: center.x + (radius - cornerRadius) * cos(angle), y: center.y + (radius - cornerRadius) * sin(angle))
        let tip = CGPoint(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle))
        let start = CGPoint(x: corner.x + cornerRadius * cos(angle - theta), y: corner.y + cornerRadius * sin(angle - theta))
        let end = CGPoint(x: corner.x + cornerRadius * cos(angle + theta), y: corner.y + cornerRadius * sin(angle + theta))
        
        path.addLine(to: start)
        path.addQuadCurve(to: end, controlPoint: tip)
        
    }
    path.close()
    backgroundMask.path = path.cgPath
}}

Solution

  • You either need to implement draw(_:) or use CAAnimation, not both.

    As rule, don't implement draw(_:) for view classes. That forces the system to do all it's rendering on the CPU, and does not take advantage of the tile based, hardware accelerated rendering on iOS devices. Instead, use CALayers and CAAnimation and let the hardware do the heavy lifting for you.

    Using CALayers and CAAnimation you can get an effect like this:

    Hexagon animation

    I would suggest doing the following:

    The code to create your gradient layer might look like this:

        let gradientLayer = CAGradientLayer()
        gradientLayer.frame = self.bounds
        gradientLayer.type = .conic
        gradientLayer.colors = [UIColor.clear.cgColor,
                                UIColor.clear.cgColor,
                                UIColor.white.cgColor,
                                UIColor.white.cgColor]
        let center = CGPoint(x: 0.5, y: 0.5)
        gradientLayer.locations = [0, 0.3, 0.7, 0.9]
        gradientLayer.startPoint = center
        gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
    

    (You will need to update the gradient layer's bounds if the owning view's bounds change.)

    The code to rotate the gradient layer might look like this:

    private func animateGradientRotationStep() {
        let rotation = CABasicAnimation(keyPath: "transform.rotation.z")
        animationStepsRemaining -= 1
        rotation.fromValue =  rotationAngle
        rotationAngle += CGFloat.pi / 2
        rotation.toValue =  rotationAngle
        rotation.duration = 0.5
        rotation.delegate = self
        gradientLayer.add(rotation, forKey: nil)
    
        // After a tiny delay, set the layer's transform to the state at the end of the animation
        // so it doesnt jump back once the animation is complete.
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
    
            // You have to wrap this step in a CATransaction with setDisableActions(true)
            // So you don't get an implicit animation
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            self.gradientLayer.transform = CATransform3DMakeRotation(self.rotationAngle, 0, 0, 1)
            CATransaction.commit()
        }
    }
    

    And you would need your view to conform to the CAAnimationDelegate protocol:

    extension GradientLayerView: CAAnimationDelegate {
        func animationDidStop(_ anim: CAAnimation,
                              finished flag: Bool) {
            if animating && animationStepsRemaining > 0 {
                animateGradientRotation()
            }
        }
    }
    

    Note that a layer's transform property is "implicitly animated", which means that by default the system generates an animation of the change. We can take advantage of that fact and just make some adjustments to the implicit animation. That makes the animation function simpler:

    // This version of the function takes advantage of the fact
    // that a layer's transform property is implicitly animated
    private func animateGradientRotationStep() {
        animationStepsRemaining -= 1
        rotationAngle += CGFloat.pi / 2
        // MARK: - CATransaction begin
        // Use a CATransaction to set the animation duration, timing function, and completion block
        CATransaction.begin()
        CATransaction.setAnimationDuration(0.5)
        CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .linear))
        CATransaction.setCompletionBlock {
            self.animationDidStop(finished:true)
        }
        self.gradientLayer.transform = CATransform3DMakeRotation(self.rotationAngle, 0, 0, 1)
        CATransaction.commit()
        // MARK: CATransaction end -
    }
    

    That version requires a slightly different completion function, since it doesn't use a CAAnimation:

    func animationDidStop(finished flag: Bool) {
        delegate?.animationStepComplete(animationStepsRemaining)
        if animating && animationStepsRemaining > 0 {
            animateGradientRotationStep()
        }
    

    I banged out a little sample app that creates such an animation.

    You can download the demo app from Github at this link.

    The one part of your sample animation that I'm not sure how to duplicate is the fact that the color of the hexagon seems to be bright white at the beginning and transitions to yellow. My sample app creates an animation where the hexagon is a fixed color and transitions from opaque to clear.

    Here is the README from the project:


    PolarGradientMaskView

    This project illustrates how to use a "conic" gradient to mask a view and create a circular animation.

    It uses a CAGradientLayer of type .conic, set up to be mostly opaque, with the last half transitioning to transparent. It installs the gradient layer as a mask on a shape layer that contains a yellow Hexagon.

    The gadient layer looks like this:

    enter image description here

    (Rendered in blue against a gray checkerboard background so you can see the transition from opaque to clear.)

    The opaque (blue) parts of the gradient cause the shape layer to be visible. The transparent parts of the gradient hide (mask) those parts of the shape layer, and partly transparent parts of the gradient layer make those parts of the shape layer partly transparent.

    The animation simply rotates the gradient layer on the Z axis around the center of the layer. It rotatest the layer 1/4 turn at a time, and each time an animation step completes, it simply creates a new animation that rotates the mask another 1/4 turn.

    It's a little hard to understand what's going on when you're masking a hexagon shape. I created a variant where I added an image view as a subview of the custom view. The animation for that looks like this:

    enter image description here

    The app's window looks like this:

    enter image description here