Animating a UIView's mask (larger circle CAShapeLayer to smaller) - swift

I have a UIView class that I add to my main view that blocks everything out except for a circle shape. Now I'm trying to animate the circle. I decided to use CABasicAnimation to do this, but the circle stays large and doesn't animate. I'm not sure if I'm missing something or what I'm trying to do is impossible.
class SpotlightOverlay: UIView {
let overlayView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
}
init(frame: CGRect,
xOffset: CGFloat,
yOffset: CGFloat,
radius: CGFloat) {
super.init(frame: frame)
overlayView.frame = frame
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.7)
let path = CGMutablePath()
path.addArc(center: CGPoint(x: xOffset, y: yOffset),
radius: radius*5,
startAngle: 0.0,
endAngle: 2.0 * .pi,
clockwise: false)
path.addRect(CGRect(origin: .zero, size: overlayView.frame.size))
let path2 = CGMutablePath()
path2.addArc(center: CGPoint(x: xOffset, y: yOffset),
radius: radius,
startAngle: 0.0,
endAngle: 2.0 * .pi,
clockwise: false)
path2.addRect(CGRect(origin: .zero, size: overlayView.frame.size))
let anim = CABasicAnimation(keyPath: "path")
anim.fromValue = path
anim.toValue = path2
anim.duration = 6.0
anim.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
let maskLayer = CAShapeLayer()
maskLayer.backgroundColor = UIColor.black.cgColor
maskLayer.add(anim, forKey: nil)
CATransaction.begin()
CATransaction.setDisableActions(true)
maskLayer.path = path
CATransaction.commit()
maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
overlayView.layer.mask = maskLayer
overlayView.clipsToBounds = true
self.addSubview(overlayView)
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

Instead of using a CGMutablePath, I achieved what I wanted with a UIBezierPath. More specifically, it appears as a hole in the view that can animate.
import UIKit
class SpotlightOverlay: UIView {
let overlayView = UIView()
let maskLayer = CAShapeLayer()
var initXOffset:CGFloat = 0
var initYOffset:CGFloat = 0
var initDiameter:CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
}
init(frame: CGRect,
xOffset: CGFloat,
yOffset: CGFloat,
startDiameter: CGFloat,
endDiameter:CGFloat,
duration:Double) {
super.init(frame: frame)
self.isUserInteractionEnabled = false
self.overlayView.isUserInteractionEnabled = false
overlayView.frame = frame
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.7)
let padding:CGFloat = 50
let startDiameter = startDiameter + padding
let endDiameter = endDiameter + padding
initXOffset = xOffset
initYOffset = yOffset
initDiameter = endDiameter
let ovalFrame1 = CGRect(x:xOffset-(startDiameter/2),
y:yOffset-(startDiameter/2),
width:startDiameter,
height:startDiameter)
let ovalFrame2 = CGRect(x:xOffset-(endDiameter/2),
y:yOffset-(endDiameter/2),
width:endDiameter,
height:endDiameter)
let path = UIBezierPath(ovalIn: ovalFrame1)
let path2 = UIBezierPath(ovalIn: ovalFrame2)
maskLayer.backgroundColor = UIColor.black.cgColor
path.append(UIBezierPath(rect: self.bounds))
path2.append(UIBezierPath(rect: self.bounds))
maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
CATransaction.begin()
CATransaction.setDisableActions(true)
maskLayer.path = path.cgPath
CATransaction.commit()
let anim = CABasicAnimation(keyPath: "path")
anim.fromValue = path.cgPath
anim.toValue = path2.cgPath
anim.duration = duration
anim.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
anim.fillMode = CAMediaTimingFillMode.forwards
anim.isRemovedOnCompletion = false
maskLayer.add(anim, forKey: nil)
overlayView.layer.mask = maskLayer
overlayView.clipsToBounds = true
self.addSubview(overlayView)
}
func animateTo(xOffset: CGFloat,
yOffset: CGFloat,
endDiameter:CGFloat,
duration:Double) {
let padding:CGFloat = 50
let startDiameter = initDiameter
let endDiameter = endDiameter + padding
let ovalFrame1 = CGRect(x:initXOffset-(startDiameter/2),
y:initYOffset-(startDiameter/2),
width:startDiameter,
height:startDiameter)
let ovalFrame2 = CGRect(x:xOffset-(endDiameter/2),
y:yOffset-(endDiameter/2),
width:endDiameter,
height:endDiameter)
initXOffset = xOffset
initYOffset = yOffset
let path = UIBezierPath(ovalIn: ovalFrame1)
let path2 = UIBezierPath(ovalIn: ovalFrame2)
path.append(UIBezierPath(rect: self.bounds))
path2.append(UIBezierPath(rect: self.bounds))
CATransaction.begin()
CATransaction.setDisableActions(true)
maskLayer.path = path.cgPath
CATransaction.commit()
let anim = CABasicAnimation(keyPath: "path")
anim.fromValue = path.cgPath
anim.toValue = path2.cgPath
anim.duration = duration
anim.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
anim.fillMode = CAMediaTimingFillMode.forwards
anim.isRemovedOnCompletion = false
maskLayer.add(anim, forKey: nil)
initDiameter = endDiameter
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
First, initialize the class and then call the animateTo function any time you want to animate the hole moving.

Related

How do I set an endpoint for a CAKeyFrameAnimation?

I am using an arc for a progress bar.
There is a track layer that the progress indicator moves along and I also have a circle that moves with the progress bar for added emphasis. Both the progress bar and the circle move correctly along the path but when the progress is complete, instead of stopping at the end of the path, the circle jumps back to the beginning of the progress path.
screen recording of behavior
The code:
class ViewController: UIViewController {
let shapeLayer = CAShapeLayer()
let trackLayer = CAShapeLayer()
let dotLayer: CALayer = CALayer()
let seconds = 10.0
// MARK: - Properties
private var progressBarView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
title = "Making Progress"
view.backgroundColor = .systemTeal
// determine the value of Center
let center = view.center
let circularPath = UIBezierPath(arcCenter: center, radius: 100, startAngle: 2.7, endAngle: 0.45, clockwise: true) // these values give us a complete circle
trackLayer.path = circularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 10
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineCap = .round
view.layer.addSublayer(trackLayer)
shapeLayer.path = circularPath.cgPath
shapeLayer.strokeColor = UIColor.red.cgColor
shapeLayer.lineWidth = 10
shapeLayer.strokeEnd = 0
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineCap = .round
view.layer.addSublayer(shapeLayer)
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap )))
}
#objc private func handleTap() {
print("Attempting to animate stroke")
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = seconds
basicAnimation.fillMode = CAMediaTimingFillMode.forwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.add(basicAnimation, forKey: "anyKeyWillDo")
// circle image
let circleNobImage = UIImage(named: "whiteCircle.png")!
dotLayer.contents = circleNobImage.cgImage
dotLayer.bounds = CGRect(x: 0.0, y: 0.0, width: circleNobImage.size.width, height: circleNobImage.size.height)
view.layer.addSublayer(dotLayer)
dotLayer.position = CGPoint(x: 105, y: 460)
dotLayer.opacity = 1
dotAnimation()
}
func dotAnimation() {
let dotAnimation = CAKeyframeAnimation(keyPath: "position")
dotAnimation.path = trackLayer.path
dotAnimation.calculationMode = .paced
let dotAnimationGroup = CAAnimationGroup()
dotAnimationGroup.duration = seconds
dotAnimation.autoreverses = false
dotAnimationGroup.animations = [dotAnimation]
dotLayer.add(dotAnimationGroup, forKey: nil)
}
}
I need to get the circle to stop at the end of the arc with the progress bar. How is this done?
Also set dotAnimationGroup.fillMode and dotAnimationGroup.isRemovedOnCompletion
dotAnimationGroup.fillMode = .forwards
dotAnimationGroup.isRemovedOnCompletion = false

Animate a CALayer frame change

I'm trying to create a Custom Progress Bar View and the idea is to have a timer that will update every second. However, the transition from the progress is not so smooth.
If I reduce the timer interval to something like 0.01 instead of 0.1 the "animation" works nicely; however, I'm trying to avoid it.
There's some way to make this animation smooth using 1 second on the Timer interval?
class CustomProgressBar: UIView {
private let progressLayer = CALayer()
var progress: CGFloat = 1.0 {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
addSublayers()
startTimer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
addSublayers()
startTimer()
}
private func startTimer() {
Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(updateProgressBar), userInfo: nil, repeats: true)
}
override func draw(_ rect: CGRect) {
let backgroundMask = CAShapeLayer()
backgroundMask.path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height * 0.5).cgPath
layer.mask = backgroundMask
updateLayer(rect: rect, color: .white)
backgroundColor = .black
progressLayer.backgroundColor = UIColor.white.cgColor
}
private func addSublayers() {
layer.addSublayer(progressLayer)
}
#objc func updateProgressBar() {
progress -= 0.1
}
private func updateLayer(rect: CGRect, color: UIColor?) {
let progressRectBorderSize = rect.height * 0.2
let width: CGFloat
width = max(rect.width * progress - (progressRectBorderSize * 2), 0)
let progressRect = CGRect(origin: CGPoint(x: progressRectBorderSize, y: progressRectBorderSize), size: CGSize(width: width, height: rect.height - (progressRectBorderSize * 2)))
let progressBackgroundMask = CAShapeLayer()
let progressBackgroundMaskRoundedRect = CGRect(x: 0, y: 0, width: progressRect.width, height: progressRect.height)
progressBackgroundMask.path = UIBezierPath(roundedRect: progressBackgroundMaskRoundedRect, cornerRadius: progressRect.height * 0.5).cgPath
progressLayer.mask = progressBackgroundMask
progressLayer.frame = progressRect
}
}
Here is how I create CustomProgressBar for you according to your needs.
I gave it a run, it works smoothly. You can customize it according to your Design needs.
class CustomProgressBar: UIView {
private var progressLayerBackground = CAShapeLayer()
private var progressLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func draw(_ rect: CGRect) {
progressLayerBackground = createBarShapeLayer(strokeColor: .black, rect: rect, margin: 10)
layer.addSublayer(progressLayerBackground)
progressLayer = createBarShapeLayer(strokeColor: .white, rect: rect, margin: 10)
progressLayer.lineWidth = 15
progressLayer.strokeEnd = 0 // This define how much will be width of bar at start.
layer.addSublayer(progressLayer)
animateBar()
backgroundColor = .white
progressLayerBackground.backgroundColor = UIColor.white.cgColor
}
private func addSublayers() {
layer.addSublayer(progressLayerBackground)
}
private func createBarShapeLayer(strokeColor: UIColor, rect: CGRect, margin: CGFloat) -> CAShapeLayer {
let layer = CAShapeLayer()
let linePath = UIBezierPath()
linePath.move(to: CGPoint(x: rect.width - margin, y: rect.height / 2))
linePath.addLine(to: CGPoint(x: rect.minX + margin , y: rect.height / 2))
layer.path = linePath.cgPath
layer.strokeColor = strokeColor.cgColor
layer.lineWidth = 20
layer.strokeEnd = 1
layer.lineCap = .round
return layer
}
private func animateBar() {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = 2 // This defines how much will be duration of animation.
basicAnimation.fillMode = .forwards
basicAnimation.beginTime = CACurrentMediaTime() + 0.5
basicAnimation.isRemovedOnCompletion = false
progressLayer.add(basicAnimation, forKey: "moveHorizontally")
}
}
Demo: https://drive.google.com/file/d/1iGwcPPe7uQROa9s276t4SIsHY42fTJ1D/view?usp=sharing

Gradient Arc View Not Showing Correctly

i have a custom view that is a 85% of a circle ( Just Stroke ) with Gradient color . here is my code :
class ProfileCircle:UIView
{
override func draw(_ rect: CGRect)
{
let desiredLineWidth:CGFloat = 4 // your desired value
let arcCenter = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
let radius = self.bounds.midX
let circlePath = UIBezierPath(
arcCenter: arcCenter,
radius: radius,
startAngle: CGFloat(0),
endAngle:CGFloat((.pi * 2)*0.85),
clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.lineCap = .round
shapeLayer.path = circlePath.cgPath
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.red.cgColor
shapeLayer.lineWidth = desiredLineWidth
let gradient = CAGradientLayer()
gradient.frame = circlePath.bounds
gradient.colors = [UIColor.colorPrimary.cgColor, UIColor.colorSecondary.cgColor]
//layer.addSublayer(shapeLayer)
gradient.mask = shapeLayer
layer.addSublayer(gradient)
}
}
but the edges are clipped
i have tried a lot but i cant fix it .
one more thing is that when i change the radius manually the view wont be centered
Just two changes:
let radius = self.bounds.midX - desiredLineWidth
and
gradient.frame = self.bounds

Change color gradient around donut view

I am trying to make an animated donut view that when given a value between 0 and 100 it will animate round the view up to that number. I have this working fine but want to fade the color from one to another, then another on the way around. Currently, when I add my gradient it goes from left to right and not around the circumference of the donut view.
class CircleScoreView: UIView {
private let outerCircleLayer = CAShapeLayer()
private let outerCircleGradientLayer = CAGradientLayer()
private let outerCircleLineWidth: CGFloat = 5
override init(frame: CGRect) {
super.init(frame: .zero)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
buildLayers()
}
/// Value must be within 0...100 range
func setScore(_ value: Int, animated: Bool = false) {
if value != 0 {
let clampedValue: CGFloat = CGFloat(value.clamped(to: 0...100)) / 100
if !animated {
outerCircleLayer.strokeEnd = clampedValue
} else {
let outerCircleAnimation = CABasicAnimation(keyPath: "strokeEnd")
outerCircleAnimation.duration = 1.0
outerCircleAnimation.fromValue = 0
outerCircleAnimation.toValue = clampedValue
outerCircleAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
outerCircleLayer.strokeEnd = clampedValue
outerCircleLayer.add(outerCircleAnimation, forKey: "outerCircleAnimation")
}
outerCircleGradientLayer.colors = [Constant.Palette.CircleScoreView.startValue.cgColor,
Constant.Palette.CircleScoreView.middleValue.cgColor,
Constant.Palette.CircleScoreView.endValue.cgColor]
}
}
private func buildLayers() {
// Outer background circle
let arcCenter = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
let startAngle = CGFloat(-0.5 * Double.pi)
let endAngle = CGFloat(1.5 * Double.pi)
let circlePath = UIBezierPath(arcCenter: arcCenter,
radius: (frame.size.width - outerCircleLineWidth) / 2,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
// Outer circle
setupOuterCircle(outerCirclePath: circlePath)
}
private func setupOuterCircle(outerCirclePath: UIBezierPath) {
outerCircleLayer.path = outerCirclePath.cgPath
outerCircleLayer.fillColor = UIColor.clear.cgColor
outerCircleLayer.strokeColor = UIColor.black.cgColor
outerCircleLayer.lineWidth = outerCircleLineWidth
outerCircleLayer.lineCap = CAShapeLayerLineCap.round
outerCircleGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
outerCircleGradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
outerCircleGradientLayer.frame = bounds
outerCircleGradientLayer.mask = outerCircleLayer
layer.addSublayer(outerCircleGradientLayer)
}
}
I am going for something like this but the color isn't one block but gradients around the donut view from one color to the next.
If you imported AngleGradientLayer into your project then all you should need to do is change:
private let outerCircleGradientLayer = CAGradientLayer() to
private let outerCircleGradientLayer = AngleGradientLayer()

CAShapeLayer rotate around the center

The layer has a shape of ring. I want it to rotate around its center. Here's my code:
class ClipRotateView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func animate() {
let ring = CAShapeLayer()
let path = UIBezierPath()
path.addArcWithCenter(CGPoint(x: frame.width/2, y: frame.height/2), radius: frame.width/2, startAngle: CGFloat(-3 * M_PI_4), endAngle: CGFloat(-M_PI_4), clockwise: false)
ring.path = path.CGPath
ring.fillColor = nil
ring.strokeColor = UIColor.whiteColor().CGColor
ring.lineWidth = 2
ring.anchorPoint = CGPoint(x: 0.5, y: 0.5)
layer.addSublayer(ring)
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
scaleAnimation.keyTimes = [0, 0.5, 1]
scaleAnimation.values = [1, 0.6, 1]
let rotateAnimation = CAKeyframeAnimation(keyPath: "transform.rotation.z")
rotateAnimation.keyTimes = scaleAnimation.keyTimes
rotateAnimation.values = [0, M_PI, 2 * M_PI]
let animation = CAAnimationGroup()
animation.animations = [scaleAnimation, rotateAnimation]
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
animation.duration = 0.75
animation.repeatCount = HUGE
animation.removedOnCompletion = false
ring.addAnimation(animation, forKey: nil)
layer.borderWidth=1
layer.borderColor = UIColor.blackColor().CGColor
}
}
Even though I've set the anchorPoint to (0.5, 0.5), the ring is still rotating around the left top corner.
try
ring.frame = frame
make sure the center of the frame is intended rotation center
works for me.