I'm trying to achieve exactly the same animation shown below
and my output using UIBezierPath and CABasicAnimation is this below.
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
}}
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:
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:
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:
(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:
The app's window looks like this: