2

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
}}
govind kumar
  • 205
  • 4
  • 12
  • 1
    Why the votes to close this question? This question states the goal clearly, and provides the OPs current code, as well as a description of how it fails to meet their needs. – Duncan C Jun 04 '21 at 12:33
  • 1
    By the way, `CGMutablePath` has a very cool function, `addArc(tangent1End:tangent2End:radius:)` that makes adding rounded corners at arbitrary angles trivial. Using that would greatly simplify your code that builds your hexagon. The only trig you would need would be the trig to calculate your vertexes. See my project https://github.com/DuncanMC/RoundedCornerPolygon for a general-purpose way of creating arbitrary polygons with a mixture of rounded and non-rounded corners. – Duncan C Jun 04 '21 at 12:38

3 Answers3

5

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:

  • Create a full circle hexagon shape as a CAShapeLayer. (The code in your draw() method already generates a hexagon path. You could easily adapt that to install your hexagon path into a CAShapeLayer.)

  • Add that shape layer as a sublayer of a view.

  • Create a "conic" CAGradientLayer with a start point of the layer's center and an endpoint of the top center.

  • Add colors ranging from clear to any opaque colors to the gradient layer, using an array of locations that feathers the gradient as desired.

  • install the gradient layer as the mask on your hexagon shape layer.

  • Create a CABasicAnimation that rotates the gradient layer around the Z axis 1/4 turn at a time. Run that animation constantly until you're done with the animation.

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

Duncan C
  • 128,072
  • 22
  • 173
  • 272
  • Not OP, but this is amazing. Could you link the sample app too? – aheze Jun 03 '21 at 19:01
  • 2
    @aheze I just edited my answer to include a link to the repo. – Duncan C Jun 03 '21 at 23:11
  • @DuncanC I am a newbie to using and understanding animations. I tried implementing your code since morning, It's rocket science for me. I would be very grateful if you could you please share the link to the above GIF code. Thanks again – govind kumar Jun 04 '21 at 15:23
  • @govindkumar see the last edit to my answer. It says "You can download the demo app from Github at this link. The last part of that is clickable, and takes you to the repo on Github: https://github.com/DuncanMC/PolarGradientMaskView.git – Duncan C Jun 04 '21 at 16:00
  • @DuncanC tried running your code and switched on "Repeat Animations" and "Animate Now" button but I don't see the yellow loader, tried tweaking the code a bit but couldn't see the yellow loader animating. Are they any changes to be made? – govind kumar Jun 04 '21 at 17:27
  • I just pushed an update. Try pulling and re-testing. I might have committed an incomplete set of changes. – Duncan C Jun 04 '21 at 17:30
  • That project includes the ability to stop the animation after a full circle. I used that in creating the GIF animation, but you might want to strip out that logic for your actual use. – Duncan C Jun 04 '21 at 18:19
  • 1
    It would also be pretty easy to generalize that animation to animate the sweeping circular mask animation on ANY view, not just your hexagon. You could use a circle, or even a UIImage, some text, or whatever you wanted. – Duncan C Jun 04 '21 at 18:20
1

The OP's video shows an animation where the hexagon shape has a white highlight at the beginning that transitions to yellow.

I created a variant of my previous animation that adds a white highlight on the leading edge of the animation. It looks like this:

enter image description here

Both versions of the animation are quite similar.

The other, all yellow animation uses a single shape layer with a conic gradient CAGradientLayer installed as a mask layer which causes the hexagon shape to fade out on the last 3rd or so. The animation simply rotates the mask layer around it's center.

The primary mask gradient looks like this:

Conical gradient

(It's drawn in blue against a checkered background so you can see the opaque and transparent parts more easily.

This variant of the animation adds a second shape layer on top of the first. Let's call it the highlight shape layer. The highlight shape layer contains a hexagon with a slightly smaller line width, drawn in white. The highlight shape layer also has a conical CAGradientLayer as it's mask, but it's mask layer masks out all but the beginning of the hexagon shape. It only reveals a small portion of the white hexagon, and never at full opacity.

Because the highlight shape layer is not fully opaque, the two shape layers blend together and the parts of the highlight layer that have higher opacity make the pixels in the combined image look more white.

The gradient mask for the highlight shape layer looks like this (again shown in blue against a checkered background so you can tell the opacity of the mask.)

enter image description here

This version of the project is also on github at https://github.com/DuncanMC/PolarGradientMaskView.git

but in the branch named "AddHighlightLayer".

The setup code for the highlightGradientLayer is as follows:

    highlightGradientLayer.type = .conic
    highlightGradientLayer.colors = [UIColor.clear.cgColor,
                                     UIColor.clear.cgColor,
                                     UIColor(red: 0, green: 0, blue: 1, alpha: 0.5).cgColor,
                                     UIColor(red: 0, green: 0, blue: 1, alpha: 0.9).cgColor,
                            ]
    highlightGradientLayer.locations = [0.00, 0.85, 0.90, 1.00]
    highlightGradientLayer.startPoint = center
    highlightGradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
    self.layer.addSublayer(highlightShapeLayer)
    highlightShapeLayer.mask = highlightGradientLayer
Duncan C
  • 128,072
  • 22
  • 173
  • 272
  • 1
    @govindkumar did you see this second answer? I think the effect is a little closer in this second version. (The white highlights on the yellow hexagon.) – Duncan C Jun 09 '21 at 23:14
0

The problem is - the code is rendering the complete path - start-to-end each time and start is same for all animations.

The idea for loaders is - start point has to change after each animation - something like -

  1. Start at angle 0
  2. Go up to angle 45 /// or 60 whatever you want this to be
  3. Change start angle to next logical step - say 30
  4. Then render up to 75 /// or 90 depending on what you chose previously

In this arrangement, you have to keep drawing a certain portion of the shape by changing start point continuously.

In practice, achieving a smooth transition between different start point values may prove out to be difficult than it seems. You can find an example here - https://github.com/SVProgressHUD/SVProgressHUD/blob/master/SVProgressHUD/SVIndefiniteAnimatedView.m#L48-L102

UPDATE


The link I shared above has all the hints in it. This library uses an image as a mask and then rotate that continuously. Mask shape can be anything you like - you already have code for that.

You just need to create an image that's appropriate for your animation. See what their asset looks like enter image description here

and AFTER masking what their animation looks like - enter image description here


Tarun Tyagi
  • 9,364
  • 2
  • 17
  • 30
  • I tried doing the same, it partially worked but I was unable to fix or remove the flickering effect which occurs when the path finishes the rotation. – govind kumar Jun 03 '21 at 13:29
  • See the **UPDATE** to my answer. – Tarun Tyagi Jun 03 '21 at 13:46
  • 1
    There's no need to use an image as the mask. You can use a conic CAGradientLayer as the mask, and just apply a rotation animation to that layer. A gradient layer scales to any size needed. – Duncan C Jun 03 '21 at 17:01
  • See my answer for a working example that uses a conic CAGradientLayer as a mask and animates a rotation to that layer. – Duncan C Jun 03 '21 at 23:16