CAShapeLayer is not touchable? - ios

I created a CAShapeLayer and added it as a subLayer.
Then I overridden the:
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
And it get called successfully, but after I move this layer using CABasicAnimation.
It's not longer touchable.
The strange thing is that the original empty place of the CAShapeLayer is touchable after the animation finishes.
The structure of the view is as follows:
I subclass UIView and add to it the layer, let's name it view X.
Then I take view X and add it to anther view.
Then I animate view X using CABasicAnimation
Code:
class AIFloatingMenuItem: UIView
{
var image = UIImage()
var color = UIColor.brown
var location:CGPoint?
var fontName:String?
var text:String?
let rectShape = CAShapeLayer()
convenience init(title:String, img:UIImage, bgColor:UIColor)
{
self.init(frame: CGRect(x: 0, y: 0, width: ITEM_SIZE, height: ITEM_SIZE))
self.image = img
self.color = bgColor
self.drawCircle()
backgroundColor = UIColor.red
}
override init(frame: CGRect)
{
super.init(frame: frame)
}
//MARK: - Drawing -
func drawCircle()
{
rectShape.anchorPoint = CGPoint(x:0.5 , y:0.5)
rectShape.fillColor = color.cgColor
let path = UIBezierPath(arcCenter: CGPoint(x: ITEM_SIZE/2, y: ITEM_SIZE/2), radius: ITEM_SIZE/2, startAngle: CGFloat(0), endAngle:CGFloat(M_PI * 2), clockwise: true)
rectShape.path = path.cgPath
layer.addSublayer(rectShape)
}
}
class AIFloatingMenu: UIView, CAAnimationDelegate
{
var item:AIFloatingMenuItem?
override func awakeFromNib()
{
super.awakeFromNib()
animateItem1()
}
func animateItem1()
{
item = AIFloatingMenuItem(title: "title", img: UIImage(named : "icon-plus")!, bgColor: .green)
let itemsInitialPostition = CGPoint(x: 250, y: 300)
item?.layer.position = itemsInitialPostition
self.addSubview(item!)
drawAnimateY(end: CGPoint(x: item!.layer.position.x, y: 300), isEnd: false, item: item!, delay: 0.5)
}
func drawAnimateY(end:CGPoint, isEnd:Bool, item:AIFloatingMenuItem, delay:Double)
{
let pathAnimation = CASpringAnimation(keyPath: "position")
pathAnimation.damping = 13
CATransaction.begin()
CATransaction.setCompletionBlock
{
}
pathAnimation.delegate = self
pathAnimation.duration = 1.5
pathAnimation.fromValue = item.layer.position
pathAnimation.toValue = end
pathAnimation.fillMode = kCAFillModeForwards
pathAnimation.isRemovedOnCompletion = false
pathAnimation.beginTime = CACurrentMediaTime() + delay
item.layer.add(pathAnimation, forKey: nil)
CATransaction.commit()
}
}
I add a view in the storyboard and set it's class to AIFloatingMenu.

I have been able to solve my issue by changing the position of the item being animated to the destination location after the item finishes the Animation:
func drawAnimateY(end:CGPoint, isEnd:Bool, item:AIFloatingMenuItem, delay:Double)
{
let pathAnimation = CASpringAnimation(keyPath: "position")
pathAnimation.damping = 13
CATransaction.begin()
CATransaction.setCompletionBlock
{
item.layer.position = end// changing the position of item to the destination location
}
pathAnimation.delegate = self
pathAnimation.duration = 1.5
pathAnimation.fromValue = item.layer.position
pathAnimation.toValue = end
pathAnimation.fillMode = kCAFillModeForwards
pathAnimation.isRemovedOnCompletion = false
pathAnimation.beginTime = CACurrentMediaTime() + delay
item.layer.add(pathAnimation, forKey: nil)
CATransaction.commit()
}

Related

Swift UIBezierPath starting offset from where it should

I have a Button that hides when pressed and instead an animation is shown that fills from left to right, indicating a wait time.
I have the following class which handles the animation:
//MARK: - Class: LinearProgressBarButtonView
class LinearProgressBarButtonView: UIView {
private var myWidth: CGFloat!
private var myHeight: CGFloat!
init(frame: CGRect, width: CGFloat, height: CGFloat) {
super.init(frame: frame)
myWidth = width
myHeight = height
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
private var lineLayer = CAShapeLayer()
private var progressLayer = CAShapeLayer()
func createLinePath() {
// created linePath for lineLayer and progressLayer
let rect = CGRect(x: 0, y: 0, width: myWidth, height: myHeight)
let linePath = UIBezierPath()
linePath.move(to: CGPoint(x: 0.0, y: rect.height/2))
linePath.addLine(to: CGPoint(x: myWidth, y: rect.height/2))
// lineLayer path defined to circularPath
lineLayer.path = linePath.cgPath
// ui edits
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.lineCap = .square
lineLayer.lineWidth = myHeight
lineLayer.strokeEnd = 1.0
lineLayer.strokeColor = colors.Blue.cgColor
// added circleLayer to layer
layer.addSublayer(lineLayer)
// progressLayer path defined to circularPath
progressLayer.path = linePath.cgPath
// ui edits
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineCap = .square
progressLayer.lineWidth = myHeight
progressLayer.strokeEnd = 0
progressLayer.strokeColor = colors.red.cgColor
// added progressLayer to layer
layer.addSublayer(progressLayer)
}
func progressAnimation(duration: TimeInterval) {
// created circularProgressAnimation with keyPath
let linearProgressAnimation = CABasicAnimation(keyPath: "strokeEnd")
// set the end time
linearProgressAnimation.duration = duration
linearProgressAnimation.toValue = 1.0
linearProgressAnimation.fillMode = .forwards
linearProgressAnimation.isRemovedOnCompletion = true
progressLayer.add(linearProgressAnimation, forKey: "progressAnim")
}
func endAnimation(){
progressLayer.removeAllAnimations()
}
}
I have set all necessary constraints, which are all correct. Using
linearProgressBarButtonView = LinearProgressBarButtonView(frame: .zero, width: bookButton.frame.width, height: bookButton.frame.height) linearProgressBarButtonView.createLinePath()
I can create the view and later add it as a subview.
I can now use linearProgressBarButtonView.progressAnimation(duration: linearViewDuration) to start the animation, which works exactly as it should. However, the animation does not seem to start at x = 0, but further along the way (somewhere at around 15%). Here is a screenshot of the first second of the animation, which is supposed to last 60 seconds:
I can't seem to figure out why. As far as I understand, it should start from x = 0. And the width should be the exact same width as the view has, which I pass when generating the animated view. Why is it starting with an offset then?
The main problem is:
lineLayer.lineCap = .square
// and
progressLayer.lineCap = .square
Those need to be .butt
When set to .square one-half the line-width will be "added" on each end. Here, the line path goes from 0,20 to 260,20, with a .lineWidth = 40:
You can easily see what's going on by setting layer.borderWidth = 1 on your view ... it will look similar to this:
So, that change should fix your issue.
However, I'd suggest -- instead of saving width/height and having to call createLinePath(), move that code into layoutSubviews(). That will always keep your line path correct, even if you change the frame at a later point:
class LinearProgressBarButtonView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
layer.addSublayer(lineLayer)
layer.addSublayer(progressLayer)
}
private var lineLayer = CAShapeLayer()
private var progressLayer = CAShapeLayer()
override func layoutSubviews() {
super.layoutSubviews()
let linePath = UIBezierPath()
linePath.move(to: CGPoint(x: bounds.minX, y: bounds.midY))
linePath.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY))
lineLayer.path = linePath.cgPath
// ui edits
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.lineCap = .butt
lineLayer.lineWidth = bounds.height
lineLayer.strokeEnd = 1.0
lineLayer.strokeColor = UIColor.systemBlue.cgColor
progressLayer.path = linePath.cgPath
// ui edits
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineCap = .butt
progressLayer.lineWidth = bounds.height
progressLayer.strokeEnd = 0.0
progressLayer.strokeColor = UIColor.systemRed.cgColor
}
func progressAnimation(duration: TimeInterval) {
// created ProgressAnimation with keyPath
let linearProgressAnimation = CABasicAnimation(keyPath: "strokeEnd")
// set the end time
linearProgressAnimation.duration = duration
linearProgressAnimation.fromValue = 0.0
linearProgressAnimation.toValue = 1.0
linearProgressAnimation.fillMode = .forwards
linearProgressAnimation.isRemovedOnCompletion = true
progressLayer.add(linearProgressAnimation, forKey: "progressAnim")
}
func endAnimation(){
progressLayer.removeAllAnimations()
}
}

How do I mask and erase a CAShapeLayer?

I am trying to get erase functionality working with CAShapeLayer. Current code:
class MaskTestVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let image = UIImage(named: "test.jpg")!
let testLayer = CAShapeLayer()
testLayer.contents = image.cgImage
testLayer.frame = view.bounds
let maskLayer = CAShapeLayer()
maskLayer.opacity = 1.0
maskLayer.lineWidth = 20.0
maskLayer.strokeColor = UIColor.black.cgColor
maskLayer.fillColor = UIColor.clear.cgColor
maskLayer.frame = view.bounds
testLayer.mask = maskLayer
view.layer.addSublayer(testLayer)
}
}
I was thinking if I create a mask layer then I could draw a path on the mask layer and erase parts of the image eg:
let path = UIBezierPath()
path.move(to: CGPoint(x: 100.0, y: 100.0))
path.addLine(to: CGPoint(x: 200.0, y: 200.0))
maskLayer.path = path.cgPath
However it seems that when I add the maskLayer it covers the entire image and I can't see the contents below it. What am I doing wrong here?
You can use a bezier path mask to "erase" the path by creating a custom CALayer subclass and overriding draw(in ctx: CGContext):
class MyCustomLayer: CALayer {
var myPath: CGPath?
var lineWidth: CGFloat = 24.0
override func draw(in ctx: CGContext) {
// fill entire layer with solid color
ctx.setFillColor(UIColor.gray.cgColor)
ctx.fill(self.bounds);
// we want to "clear" the stroke
ctx.setStrokeColor(UIColor.clear.cgColor);
// any color will work, as the mask uses the alpha value
ctx.setFillColor(UIColor.white.cgColor)
ctx.setLineWidth(self.lineWidth)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
if let pth = self.myPath {
ctx.addPath(pth)
}
ctx.setBlendMode(.sourceIn)
ctx.drawPath(using: .fillStroke)
}
}
Here's a complete example... we'll create a UIView subclass and, since the effect will be "scratching off the image" we'll call it ScratchOffImageView:
class ScratchOffImageView: UIView {
public var image: UIImage? {
didSet {
self.scratchOffImageLayer.contents = image?.cgImage
}
}
// adjust drawing-line-width as desired
// or set from
public var lineWidth: CGFloat = 24.0 {
didSet {
maskLayer.lineWidth = lineWidth
}
}
private class MyCustomLayer: CALayer {
var myPath: CGPath?
var lineWidth: CGFloat = 24.0
override func draw(in ctx: CGContext) {
// fill entire layer with solid color
ctx.setFillColor(UIColor.gray.cgColor)
ctx.fill(self.bounds);
// we want to "clear" the stroke
ctx.setStrokeColor(UIColor.clear.cgColor);
// any color will work, as the mask uses the alpha value
ctx.setFillColor(UIColor.white.cgColor)
ctx.setLineWidth(self.lineWidth)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
if let pth = self.myPath {
ctx.addPath(pth)
}
ctx.setBlendMode(.sourceIn)
ctx.drawPath(using: .fillStroke)
}
}
private let maskPath: UIBezierPath = UIBezierPath()
private let maskLayer: MyCustomLayer = MyCustomLayer()
private let scratchOffImageLayer: CALayer = CALayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
// Important, otherwise you will get a black rectangle
maskLayer.isOpaque = false
// add the image layer
layer.addSublayer(scratchOffImageLayer)
// assign the layer mask
scratchOffImageLayer.mask = maskLayer
}
override func layoutSubviews() {
super.layoutSubviews()
// set frames for mask and image layers
maskLayer.frame = bounds
scratchOffImageLayer.frame = bounds
// triggers drawInContext
maskLayer.setNeedsDisplay()
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let currentPoint = touch.location(in: self)
maskPath.move(to: currentPoint)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let currentPoint = touch.location(in: self)
// add line to our maskPath
maskPath.addLine(to: currentPoint)
// update the mask layer path
maskLayer.myPath = maskPath.cgPath
// triggers drawInContext
maskLayer.setNeedsDisplay()
}
}
and, an example view controller:
class ScratchOffViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
guard let img = UIImage(named: "test.jpg") else {
fatalError("Could not load image!!!!")
}
let scratchOffView = ScratchOffImageView()
// set the "scratch-off" image
scratchOffView.image = img
// default line width is 24.0
// we can set it to a different width here
//scratchOffView.lineWidth = 12
// let's add a light-gray label with red text
// we'll overlay the scratch-off-view on top of the label
// so we can see the text "through" the image
let backgroundLabel = UILabel()
backgroundLabel.font = .italicSystemFont(ofSize: 36)
backgroundLabel.text = "This is some text in a label so we can see that the path is clear -- so it appears as if the image is being \"scratched off\""
backgroundLabel.numberOfLines = 0
backgroundLabel.textColor = .red
backgroundLabel.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
[backgroundLabel, scratchOffView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
backgroundLabel.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.7),
backgroundLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
backgroundLabel.centerYAnchor.constraint(equalTo: g.centerYAnchor),
scratchOffView.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.8),
scratchOffView.heightAnchor.constraint(equalTo: scratchOffView.widthAnchor, multiplier: 2.0 / 3.0),
scratchOffView.centerXAnchor.constraint(equalTo: backgroundLabel.centerXAnchor),
scratchOffView.centerYAnchor.constraint(equalTo: backgroundLabel.centerYAnchor),
])
}
}
It will look like this to start - I used a 3:2 image, and overlaid it on a light-gray label with red text so we can see that we are "scratching off" the image:
then, after a little bit of "scratching":
and after a lot of "scratching":
maskLayer has no initial path therefore its content is not filled, also filling it with .clear will completely mask the image.
This works for me:
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let image = UIImage(named: "test.jpg")!
let testLayer = CAShapeLayer()
testLayer.contents = image.cgImage
testLayer.frame = view.bounds
let maskLayer = CAShapeLayer()
maskLayer.strokeColor = UIColor.black.cgColor
maskLayer.fillColor = UIColor.black.cgColor
maskLayer.path = UIBezierPath(rect: view.bounds).cgPath
testLayer.mask = maskLayer
view.layer.addSublayer(testLayer)
/* optional for testing
let path = UIBezierPath()
path.move(to: CGPoint(x: 100.0, y: 100.0))
path.addLine(to: CGPoint(x: 200.0, y: 200.0))
maskLayer.path = path.cgPath
*/
}
Not actually sure if a combination of stroke and fill works but maybe someone else comes up with a solution. If you want to cut out shapes from your path you could try something like this:
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let image = UIImage(named: "test.jpg")!
let testLayer = CAShapeLayer()
testLayer.contents = image.cgImage
testLayer.frame = view.bounds
let maskLayer = CAShapeLayer()
maskLayer.fillColor = UIColor.black.cgColor
maskLayer.fillRule = .evenOdd
testLayer.mask = maskLayer
view.layer.addSublayer(testLayer)
let path = UIBezierPath(rect: CGRect(x: 100, y: 100, width: 200, height: 20))
let fillPath = UIBezierPath(rect: view.bounds)
fillPath.append(path)
maskLayer.path = fillPath.cgPath
}

Mask a gradient layer with the intersection of two shape layers in Swift

I have two shapes of type CAShapeLayer (e.g. one box and a circle) and a gradient layer of type CAGradientLayer. How can I mask the gradient layer with the intersection of the two shapes like this picture in Swift?
Not exactly clear what you mean by "intersection of the two shapes" ... but maybe this is what you're going for:
To get that, we can create a CAShapeLayer with an oval (round) path, and use it as a mask on the gradient layer.
Here's some example code:
class GradientMaskingViewController: UIViewController {
let gradView = MaskedGradView()
override func viewDidLoad() {
super.viewDidLoad()
gradView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(gradView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
gradView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 60.0),
gradView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -60.0),
gradView.heightAnchor.constraint(equalTo: gradView.widthAnchor),
gradView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
gradView.colorArray = [
.blue, .orange, .purple, .yellow
]
}
}
class MaskedGradView: UIView {
enum Direction {
case horizontal, vertical, diagnal
}
public var colorArray: [UIColor] = [] {
didSet {
setNeedsLayout()
}
}
public var locationsArray: [NSNumber] = [] {
didSet {
setNeedsLayout()
}
}
public var direction: Direction = .vertical {
didSet {
setNeedsLayout()
}
}
private let gLayer = CAGradientLayer()
private let maskLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
// add gradient layer as a sublayer
layer.addSublayer(gLayer)
// mask it
gLayer.mask = maskLayer
// we'll use a 120-point diameter circle for the mask
maskLayer.path = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: 120.0, height: 120.0)).cgPath
// so we can see this view's frame
layer.borderColor = UIColor.black.cgColor
layer.borderWidth = 1
}
override func layoutSubviews() {
super.layoutSubviews()
// update gradient layer
// frame
// colors
// locations
gLayer.frame = bounds
gLayer.colors = colorArray.map({ $0.cgColor })
if locationsArray.count > 0 {
gLayer.locations = locationsArray
}
switch direction {
case .horizontal:
gLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
case .vertical:
gLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
gLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
case .diagnal:
gLayer.startPoint = CGPoint(x: 0.0, y: 0.0)
gLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
}
}
// touch code to drag the circular mask around
private var curPos: CGPoint = .zero
private var lPos: CGPoint = .zero
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
curPos = touch.location(in: self)
lPos = maskLayer.position
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let newPos = touch.location(in: self)
let diffX = newPos.x - curPos.x
let diffY = newPos.y - curPos.y
CATransaction.begin()
CATransaction.setDisableActions(true)
maskLayer.position = CGPoint(x: lPos.x + diffX, y: lPos.y + diffY)
CATransaction.commit() }
}
To help make it clear, I added touch handling code so you can drag the circle around inside the view:
Edit - after comment (but still missing details), let's try this again...
We can get the desired output by using multiple layers.
a white-filled gray-bordered rectangle CAShapeLayer
a white-filled NON-bordered oval CAShapeLayer
a CAGradientLayer masked with an oval CAShapeLayer
a NON-filled gray-bordered oval CAShapeLayer
So, we start with a view:
add a white-filled gray-bordered rectangle CAShapeLayer:
add a white-filled NON-bordered oval CAShapeLayer (red first, to show it clearly):
add a CAGradientLayer:
mask it with an oval CAShapeLayer:
finally, add a NON-filled gray-bordered oval CAShapeLayer:
Here's the example code:
class GradientMaskingViewController: UIViewController {
let gradView = MultiLayeredGradView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
gradView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(gradView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
gradView.widthAnchor.constraint(equalToConstant: 240.0),
gradView.heightAnchor.constraint(equalTo: gradView.widthAnchor),
gradView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
gradView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
}
}
class MultiLayeredGradView: UIView {
private let rectLayer = CAShapeLayer()
private let filledCircleLayer = CAShapeLayer()
private let gradLayer = CAGradientLayer()
private let maskLayer = CAShapeLayer()
private let outlineCircleLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
// add filled-bordered rect layer as a sublayer
layer.addSublayer(rectLayer)
// add filled circle layer as a sublayer
layer.addSublayer(filledCircleLayer)
// add gradient layer as a sublayer
layer.addSublayer(gradLayer)
// mask it
gradLayer.mask = maskLayer
// add outline circle layer as a sublayer
layer.addSublayer(outlineCircleLayer)
let bColor: CGColor = UIColor.gray.cgColor
let fColor: CGColor = UIColor.white.cgColor
// filled-outlined
rectLayer.strokeColor = bColor
rectLayer.fillColor = fColor
rectLayer.lineWidth = 2
// filled
filledCircleLayer.fillColor = fColor
// clear-outlined
outlineCircleLayer.strokeColor = bColor
outlineCircleLayer.fillColor = UIColor.clear.cgColor
outlineCircleLayer.lineWidth = 2
// gradient layer properties
let colorArray: [UIColor] = [
.blue, .orange, .purple, .yellow
]
gradLayer.colors = colorArray.map({ $0.cgColor })
gradLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
gradLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
}
override func layoutSubviews() {
super.layoutSubviews()
// circle diameter is 45% of the width of the view
let circleDiameter: CGFloat = bounds.width * 0.45
// circle Top is at vertical midpoint
// circle is moved Left by 25% of the circle diameter
let circleBounds: CGRect = CGRect(x: bounds.minX - circleDiameter * 0.25,
y: bounds.maxY * 0.5,
width: circleDiameter,
height: circleDiameter)
// gradient layer fills the bounds
gradLayer.frame = bounds
let rectPath = UIBezierPath(rect: bounds).cgPath
rectLayer.path = rectPath
let circlePath = UIBezierPath(ovalIn: circleBounds).cgPath
filledCircleLayer.path = circlePath
outlineCircleLayer.path = circlePath
maskLayer.path = circlePath
}
}

why isn't the CAShapeLayer showing up sometimes?

I am using the following custom UIView class as a loading indicator in my app. Most of the time it works great and I can just uses loadingView.startLoading() to make it appear. However, in rare cases, only the UIView appears with a shadow behind it, and the CAShapeLayer is nowhere to be seen. What could be preventing the CAShapeLayer from appearing on top of the LoadingView UIView?
LoadingView
class LoadingView: UIView {
//MARK: - Properties and Init
let circleLayer = CAShapeLayer()
private var circlePath : UIBezierPath = .init()
let size : CGFloat = 80
init() {
super.init(frame: CGRect(x: 0, y: 0, width: size, height: size))
tag = 100
addShadow(shadowColor: UIColor.label.cgColor, shadowOffset: CGSize(width: 0, height: 0), shadowOpacity: 0.3, shadowRadius: 3)
backgroundColor = .secondarySystemBackground
self.layer.addSublayer(circleLayer)
calculateCirclePath()
animateCircle(duration: 1, repeats: true)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: - Private UI Setup Methods
private func calculateCirclePath() {
self.circlePath = UIBezierPath(arcCenter: CGPoint(x: size / 2, y: size / 2), radius: size / 3, startAngle: 0.0, endAngle: CGFloat(Double.pi*2), clockwise: true)
}
override func layoutSubviews() {
circleLayer.frame = self.layer.bounds
}
private func animateCircle(duration: TimeInterval, repeats: Bool) {
// Setup the CAShapeLayer with the path, colors, and line width
circleLayer.path = circlePath.cgPath
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.strokeColor = UIColor.blue.cgColor
circleLayer.lineWidth = 5.0
// Don't draw the circle initially
circleLayer.strokeEnd = 0.0
// Add the circleLayer to the view's layer's sublayers
self.layer.addSublayer(circleLayer)
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.repeatCount = repeats ? .infinity : 1
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
circleLayer.strokeEnd = 0
circleLayer.add(animation, forKey: "animateCircle")
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.repeatCount = repeats ? .infinity : 1
rotationAnimation.fromValue = 0.0
rotationAnimation.toValue = Double.pi*3
rotationAnimation.duration = duration
rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
circleLayer.add(rotationAnimation, forKey: nil)
let fadeAnimation = CABasicAnimation(keyPath: "opacity")
fadeAnimation.repeatCount = repeats ? .infinity : 1
fadeAnimation.fromValue = 1
fadeAnimation.toValue = 0
fadeAnimation.duration = duration
fadeAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
circleLayer.add(fadeAnimation, forKey: nil)
}
}
Extension for ViewController
extension ViewController {
func startLoading() {
view.addSubview(loadingView)
loadingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingView.widthAnchor.constraint(equalToConstant: CGFloat(80)),
loadingView.heightAnchor.constraint(equalToConstant: CGFloat(80)),
])
loadingView.isHidden = false
}
func stopLoading() {
loadingView.isHidden = true
}
}
Because a sublayer and animation can't happen in one go, I changed the code to what is shown below and now it works perfectly!
LoadingView
class LoadingView: UIView {
//MARK: - Properties and Init
let circleLayer = CAShapeLayer()
private var circlePath : UIBezierPath = .init()
let size : CGFloat = 80
init() {
super.init(frame: CGRect(x: 0, y: 0, width: size, height: size))
tag = 100
addShadow(shadowColor: UIColor.label.cgColor, shadowOffset: CGSize(width: 0, height: 0), shadowOpacity: 0.3, shadowRadius: 3)
backgroundColor = .secondarySystemBackground
layer.cornerRadius = size/8
self.layer.addSublayer(circleLayer)
calculateCirclePath()
circleLayer.path = circlePath.cgPath
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.strokeColor = UIColor.blue.cgColor
circleLayer.lineWidth = 5.0
// Don't draw the circle initially
circleLayer.strokeEnd = 0.0
// Add the circleLayer to the view's layer's sublayers
self.layer.addSublayer(circleLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: - Private UI Setup Methods
private func calculateCirclePath() {
self.circlePath = UIBezierPath(arcCenter: CGPoint(x: size / 2, y: size / 2), radius: size / 3, startAngle: 0.0, endAngle: CGFloat(Double.pi*2), clockwise: true)
}
override func layoutSubviews() {
circleLayer.frame = self.layer.bounds
}
func animateCircle(duration: TimeInterval, repeats: Bool) {
// Setup the CAShapeLayer with the path, colors, and line width
// We want to animate the strokeEnd property of the circleLayer
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.repeatCount = repeats ? .infinity : 1
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
circleLayer.strokeEnd = 0
circleLayer.add(animation, forKey: "animateCircle")
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.repeatCount = repeats ? .infinity : 1
rotationAnimation.fromValue = 0.0
rotationAnimation.toValue = Double.pi*3
rotationAnimation.duration = duration
rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
circleLayer.add(rotationAnimation, forKey: nil)
let fadeAnimation = CABasicAnimation(keyPath: "opacity")
fadeAnimation.repeatCount = repeats ? .infinity : 1
fadeAnimation.fromValue = 1
fadeAnimation.toValue = 0
fadeAnimation.duration = duration
fadeAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
circleLayer.add(fadeAnimation, forKey: nil)
}
}
Extension for ViewController
extension ViewController {
func startLoading() {
view.addSubview(loadingView)
loadingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingView.widthAnchor.constraint(equalToConstant: CGFloat(80)),
loadingView.heightAnchor.constraint(equalToConstant: CGFloat(80)),
])
loadingView.isHidden = false
loadingView.animateCircle(duration: 1, repeats: true)
}
func stopLoading() {
loadingView.isHidden = true
}
}
its better practice to call this method in main thread using dispatchQueue as sometime control may be in background thread so it must get returned to main thread before performing any UI updates

Custom indicator with rotate blink animation like UIActivityIndicatorView

I am trying to make custom activity indicator, see the indicator class below
import UIKit
class MyIndicator: UIView {
let gap = CGFloat(.pi/4 / 6.0)
var count = 0
override func draw(_ rect: CGRect) {
super.draw(rect)
}
func blink() {
backgroundColor = .clear
let duration: CFTimeInterval = 1.2
//let beginTime = CACurrentMediaTime()
let beginTimes: [CFTimeInterval] = [0.25, 1, 1.75, 2.5]
let timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
// Animation
let animation = CAKeyframeAnimation(keyPath: "opacity")
animation.keyTimes = [0, 0.5, 1]
animation.timingFunctions = [timingFunction, timingFunction]
animation.values = [1, 0.3, 1]
animation.duration = duration
animation.repeatCount = HUGE
animation.isRemovedOnCompletion = false
for i in 0...3 {
let shape = CAShapeLayer()
shape.frame = self.bounds
shape.fillColor = UIColor.clear.cgColor
shape.lineWidth = 6.8
shape.strokeColor = UIColor.blue.cgColor
let startAngle:CGFloat = CGFloat(i) * CGFloat(Double.pi/2) + gap
let endAngle:CGFloat = startAngle + CGFloat(Double.pi/2) - gap * 2
shape.path = UIBezierPath(arcCenter: center, radius: -20, startAngle: startAngle, endAngle: endAngle, clockwise: true).cgPath
animation.beginTime = beginTimes[i]
shape.add(animation, forKey: "animation")
self.layer.addSublayer(shape)
}
}
func startAnimating() {
blink()
}
}
let indicator = MyIndicator(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
self.view.addSubview(indicator)
indicator.startAnimating()
I have attached my current result.
But you can see that the animation is not in circular motion like standard UIActivityIndicatorView. Can anyone help me to fix this.
Try using a CAReplicatorLayer and instance delay to get everything in sync. Here is a Playground. I am not 100% sure on the visual you want but this should be close.
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
class MyIndicator: UIView {
let gap = CGFloat(.pi/4 / 6.0)
private var replicatorLayer = CAReplicatorLayer()
private var mainShapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonSetup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonSetup()
}
func commonSetup(){
mainShapeLayer = CAShapeLayer()
mainShapeLayer.frame = self.bounds
mainShapeLayer.fillColor = UIColor.clear.cgColor
mainShapeLayer.lineWidth = 6.8
mainShapeLayer.strokeColor = UIColor.blue.cgColor
let startAngle:CGFloat = CGFloat(Double.pi * 2) + gap/2
let endAngle:CGFloat = startAngle + CGFloat(Double.pi/2) - gap/2
mainShapeLayer.path = UIBezierPath(arcCenter: center, radius: self.bounds.midX - 10, startAngle: startAngle, endAngle: endAngle, clockwise: true).cgPath
replicatorLayer = CAReplicatorLayer()
replicatorLayer.frame = self.bounds
replicatorLayer.instanceCount = 4
let angle = (Double.pi * 2)/4
replicatorLayer.instanceTransform = CATransform3DRotate(CATransform3DIdentity, CGFloat(angle), 0, 0, 1)
replicatorLayer.addSublayer(mainShapeLayer)
replicatorLayer.opacity = 0
self.layer.addSublayer(replicatorLayer)
}
func animate(){
let defaultDuration : Double = 0.75
let animate = CAKeyframeAnimation(keyPath: "opacity")
animate.values = [1, 0.3, 1]
animate.keyTimes = [0, 0.5, 1]
animate.repeatCount = .greatestFiniteMagnitude
animate.duration = defaultDuration
animate.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
replicatorLayer.instanceDelay = defaultDuration/4
self.mainShapeLayer.add(animate, forKey: nil)
let opacityIn = CABasicAnimation(keyPath: "opacity")
opacityIn.fromValue = 1
opacityIn.toValue = 0
opacityIn.duration = 0.2
replicatorLayer.add(opacityIn, forKey: nil)
self.replicatorLayer.opacity = 1
}
func stopAnimating(){
CATransaction.begin()
let opacityOut = CABasicAnimation(keyPath: "opacity")
opacityOut.fromValue = 1
opacityOut.toValue = 0
opacityOut.duration = 0.2
CATransaction.setCompletionBlock {
[weak self] in
self?.mainShapeLayer.removeAllAnimations()
}
replicatorLayer.add(opacityOut, forKey: nil)
self.replicatorLayer.opacity = 0
CATransaction.commit()
}
override func layoutSubviews() {
super.layoutSubviews()
mainShapeLayer.frame = self.bounds
replicatorLayer.frame = self.bounds
}
}
class MyViewController : UIViewController {
override func loadView() {
let view = UIView()
view.backgroundColor = .white
let indicator = MyIndicator(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
indicator.animate()
//just to simulate starting and stoping
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 10) {
indicator.stopAnimating()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5) {
indicator.animate()
}
}
view.addSubview(indicator)
self.view = view
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

Resources