CABasicAnimation on transform.scale translates CAShapeLayer on the X & Y axis - swift

I am trying to add a pulsating effect around a button, however, the code I am using translates the CAShapeLayer as well as increasing its size.
How do I only increase the scale of a CAShapeLayer during this animation whilst keeping its position in the view static?
I have isolated the code out into a simple project which performs this animation and it is still occurring.
See effect in a video here: https://imgur.com/a/AbTtLKe
To test this:
Create a new project
Add a button into the centre of the view
Link it to the viewControllers code file as an IBOutlet with the name beginButton
Here is my code:
let pulsatingLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
let circularPath = UIBezierPath(arcCenter: beginButton.center, radius: beginButton.bounds.midX, startAngle: -CGFloat.pi / 2, endAngle: 2 * .pi, clockwise: true)
pulsatingLayer.path = circularPath.cgPath
pulsatingLayer.strokeColor = UIColor.clear.cgColor
pulsatingLayer.lineWidth = 10
pulsatingLayer.fillColor = UIColor.red.cgColor
pulsatingLayer.lineCap = kCALineCapRound
view.layer.addSublayer(pulsatingLayer)
animatePulsatingLayer()
}
private func animatePulsatingLayer() {
let pulseAnimation = CABasicAnimation(keyPath: "transform.scale")
pulseAnimation.toValue = 1.5
pulseAnimation.duration = 1
pulseAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
pulseAnimation.autoreverses = true
pulseAnimation.repeatCount = Float.infinity
pulsatingLayer.add(pulseAnimation, forKey: "pulsing")
}
Thanks!

Your animation is relative to the origin of the frame of the view.
By changing the center of the circular path to be CGPoint.zero, you get an animation that pulses centered on the origin of the layer. Then by adding that to a new pulsatingView whose origin is centered on the button, the pulsing layer is centered on the button.
override func viewDidLoad() {
super.viewDidLoad()
let circularPath = UIBezierPath(arcCenter: CGPoint.zero, radius: beginButton.bounds.midX, startAngle: -CGFloat.pi / 2, endAngle: 2 * .pi, clockwise: true)
pulsatingLayer.path = circularPath.cgPath
pulsatingLayer.strokeColor = UIColor.clear.cgColor
pulsatingLayer.lineWidth = 10
pulsatingLayer.fillColor = UIColor.red.cgColor
pulsatingLayer.lineCap = kCALineCapRound
let pulsatingView = UIView(frame: .zero)
view.addSubview(pulsatingView)
view.bringSubview(toFront: beginButton)
// use Auto Layout to place the new pulsatingView relative to the button
pulsatingView.translatesAutoresizingMaskIntoConstraints = false
pulsatingView.leadingAnchor.constraint(equalTo: beginButton.centerXAnchor).isActive = true
pulsatingView.topAnchor.constraint(equalTo: beginButton.centerYAnchor).isActive = true
pulsatingView.layer.addSublayer(pulsatingLayer)
animatePulsatingLayer()
}

You need to read up on transformation matrixes. (Any book on 3D computer graphics should have a section that covers it.)
A scale transformation is centered on the current origin. So if your shape is not centered on the origin, it will be drawn in towards the origin. To scale a shape in toward it's own center you need to do something like this:
Create an identity transform
Translate it by (-x, -y) of your shape's center
Add the desired scale to the transform
Add a Translate by (x, y) to shift the shape back
Concat your transform to the layer's transform.
(I always have to think really hard to get this stuff right. I wrote the above off the top of my head, before 7:00 AM local time on a weekend, without sufficient caffeine. It probably isn't exactly right, but should give you the idea.)

Related

CAShapeLayer won't appear

I can't for the life of me figure out why my CAShapeLayer is not appearing in my view. Here is my code:
let circleLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
setUpUI()
}
func setUpUI() {
let center = circleTimerView.center
let radius = circleTimerView.bounds.height / 2
let circularPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: CGFloat.pi/2, endAngle: -(3/2)*CGFloat.pi, clockwise: true)
circleLayer.path = circularPath.cgPath
circleLayer.frame = circleTimerView.frame
circleLayer.fillColor = UIColor.gray.cgColor
circleLayer.strokeColor = UIColor.white.cgColor
circleLayer.lineWidth = 4
circleLayer.strokeEnd = 0
circleTimerView.layer.addSublayer(circleLayer)
}
The circleTimerView appears on my screen, but there is no sign of the layer at all. What am I missing?
Just figured this out, the endAngle of my CGPath was less than my start angle, so it wasn't being drawn. I changed the endAngle to (5/2)*CGFloat.pi and it worked perfectly. Matt was also right that strokeEnd can't be set to zero, I had that because I was going to animate the strokeEnd paramenter.

Stroke of CAShapeLayer stops at wrong point?

I drew a simple circular progress view like this:
let trackLayer:CAShapeLayer = CAShapeLayer()
let circularPath:UIBezierPath = UIBezierPath(arcCenter: CGPoint(x: progressView.frame.width / 2, y: 39.5), radius: progressView.layer.frame.size.height * 0.4, startAngle: -CGFloat.pi / 2, endAngle: 2 * CGFloat.pi, clockwise: true)
trackLayer.path = circularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 6
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineCap = kCALineCapRound
progressView.layer.insertSublayer(trackLayer, at: 0)
let progressLayer:CAShapeLayer = CAShapeLayer()
progressLayer.path = circularPath.cgPath
progressLayer.strokeColor = UIColor.blue.cgColor
progressLayer.lineWidth = 6
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineCap = kCALineCapSquare
progressLayer.strokeEnd = 0
progressView.layer.insertSublayer(progressLayer, above: trackLayer)
And on a certain event, I try to update the progressLayer:
if let sublayers = progressView.layer.sublayers {
(sublayers[1] as! CAShapeLayer).strokeEnd = 0.5
}
Now if I understand strokeEnd correctly, 0 is the start point of the circular path, and 1 is the end point, which means that 0.5 should make it go halfway around, correct? This is what I get instead:
I tested multiple values and it turns out that around 0.78 makes a full circle. Anything above that, all the way up to 1, doesn't change anything. Can anyone tell me what I'm missing here. Perhaps a problem with the start or end angle of my circular path? Or maybe I just completely misunderstood how strokeEnd works, in which case an explanation would be very much appreciated.
Can anyone tell me what I'm missing here. Perhaps a problem with the start or end angle of my circular path?
Exactly. Think about your path (circularPath) which is assigned as the path of the shape layer. It goes from startAngle: -CGFloat.pi / 2 to endAngle: 2 * CGFloat.pi. That is more than just a complete circle. Do you see? It's a one-and-a-quarter circle!
That's why, above 0.78 (actually 0.8 or four-fifths), a change in the value of your strokeEnd appears to change nothing; you are just drawing over the start of the circle, redrawing a part of the circle that you already drew.
You probably meant
startAngle: -.pi / 2.0, endAngle: 3 * .pi / 2.0
(although what I would do is go from 0 to .pi*2 and apply a rotation transform so as to start the drawing at the top).
Another problem with your code, which could cause trouble down the line, is that you have forgotten to give either of your layers a frame. Thus they have no size.

Adding CAShapeLayer to UIButton Not Working

I am working on a Swift project and creating a circle around a UIButton, using CAShapeLayer and creating a circular UIBezeir path.The problem is that if I add this CAShapLayer as a sublayer to UIbutton,It does not work. However adding this sublayer on UiView creates the circle.
Below is my code.
let ovalPath = UIBezierPath(arcCenter: lockButton.center, radius:
CGFloat(lockButton.frame.size.width/2), startAngle: CGFloat(startAngle), endAngle: CGFloat(sentAngel), clockwise: true)
UIColor.grayColor().setFill()
ovalPath.fill()
let circleLayer = CAShapeLayer()
circleLayer.path = ovalPath.CGPath
view.layer.addSublayer(circleLayer)
circleLayer.strokeColor = UIColor.blueColor().CGColor
circleLayer.fillColor = UIColor.clearColor().CGColor
circleLayer.lineWidth = 10.0
let animation = CABasicAnimation(keyPath: "strokeEnd")
// Set the animation duration appropriately
animation.duration = 0.5
// Animate from 0 (no circle) to 1 (full circle)
animation.fromValue = 0
animation.toValue = 1
// Do a linear animation (i.e. the speed of the animation stays the same)
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
// Set the circleLayer's strokeEnd property to 1.0 now so that it's the
// right value when the animation ends.
circleLayer.strokeEnd = 3.0
// Do the actual animation
circleLayer.addAnimation(animation, forKey: "animateCircle")
Can anyone suggest me, if we can add CAShapeLayer to UIButton?
Thanks
One very obvious problem with your code is that your circleLayer has no frame. It's kind of amazing that anything appears at all. You need to set the frame of the circleLayer so that it has size and so that it is positioned correctly with respect to its superlayer.
To resolve this problem you need to remove UIButton background color and set it on the default value.

Drawing a gradient color in an arc with a rounded edge

I want to replicate this in Swift.. reading further: How to draw a linear gradient arc with Qt QPainter?
I've figured out how to draw a gradient inside of a rectangular view:
override func drawRect(rect: CGRect) {
let currentContext = UIGraphicsGetCurrentContext()
CGContextSaveGState(currentContext)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let startColor = MyDisplay.displayColor(myMetric.currentValue)
let startColorComponents = CGColorGetComponents(startColor.CGColor)
let endColor = MyDisplay.gradientEndDisplayColor(myMetric.currentValue)
let endColorComponents = CGColorGetComponents(endColor.CGColor)
var colorComponents
= [startColorComponents[0], startColorComponents[1], startColorComponents[2], startColorComponents[3], endColorComponents[0], endColorComponents[1], endColorComponents[2], endColorComponents[3]]
var locations: [CGFloat] = [0.0, 1.0]
let gradient = CGGradientCreateWithColorComponents(colorSpace, &colorComponents, &locations, 2)
let startPoint = CGPointMake(0, 0)
let endPoint = CGPointMake(1, 8)
CGContextDrawLinearGradient(currentContext, gradient, startPoint, endPoint, CGGradientDrawingOptions.DrawsAfterEndLocation)
CGContextRestoreGState(currentContext)
}
and how to draw an arc
func drawArc(color: UIColor, x: CGFloat, y: CGFloat, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, lineWidth: CGFloat, clockwise: Int32) {
let context = UIGraphicsGetCurrentContext()
CGContextSetLineWidth(context, lineWidth)
CGContextSetStrokeColorWithColor(context,
color.CGColor)
CGContextAddArc(context, x, y, radius, startAngle, endAngle, clockwise)
CGContextStrokePath(context)
}
With the gradient, that took up the entire view. I was unsure how to limit the gradient to a particular area so I made a subclass of UIView, modified the frame and used the gradient code (first snippet).
For arcs, I've used the second snippet. I'm thinking I can draw an arc and then draw a circle at the end of the arc to smooth out the arc with a rounded finish. How can I do this and draw a gradient over the arc shape?
Please ignore the purple mark. The first picture is the beginning of the gradient arc from my specification, the second is the end of the gradient arc, below and to the left of the beginning arc.
How can I draw this in Swift with Core Graphics?
Exact code for 2019 with true circular gradient and totally flexible end positions:
These days basically you simply have a gradient layer and mask for that layer.
So step one, you need a "mask".
What the heck is a "mask" in iOS?
In iOS, you actually use a CAShapeLayer to make a mask.
This is confusing. It should be called something like 'CAShapeToUseAsAMaskLayer'
But that's how you do it, a CAShapeLayer.
Step two. In layout, you must resize the shape layer. And that's it.
So basically it's just this:
class SweetArc: UIView {
open override func layoutSubviews() {
super.layoutSubviews()
arcLayer.frame = bounds
gradientLayer.frame = bounds
}
private lazy var gradientLayer: CAGradientLayer = {
let l = CAGradientLayer()
...
l.mask = arcLayer
layer.addSublayer(l)
return l
}()
private lazy var arcLayer: CAShapeLayer = {
let l = CAShapeLayer()
...
return l
}()
}
That's the whole thing.
Note that the mask of the gradient layer is indeed set as the shape layer, which we have named "arcLayer".
A shape layer is used as a mask.
Here's the exact code for the two layers:
private lazy var gradientLayer: CAGradientLayer = {
// recall that .conic is finally available in 12 !
let l = CAGradientLayer()
l.type = .conic
l.colors = [
UIColor.yellow,
UIColor.red
].map{$0.cgColor}
l.locations = [0, 1]
l.startPoint = CGPoint(x: 0.5, y: 0.5)
l.endPoint = CGPoint(x: 0.5, y: 0)
l.mask = arcLayer
layer.addSublayer(l)
return l
}()
private lazy var arcLayer: CAShapeLayer = {
let l = CAShapeLayer()
l.path = arcPath
// to use a shape layer as a mask, you mask with white-on-clear:
l.fillColor = UIColor.clear.cgColor
l.strokeColor = UIColor.white.cgColor
l.lineWidth = lineThick
l.lineCap = CAShapeLayerLineCap.round
l.strokeStart = 0.4 // try values 0-1
l.strokeEnd = 0.5 // try values 0-1
return l
}()
Again, notice arcLayer is a shape layer you are using as a mask - you do NOT actually add it as a subview.
Remember that shape layers used as masks do not! get added as subviews.
That can be a source of confusion. It's not a "layer" at all.
Finally, notice arcLayer uses a path arcPath.
Construct the path perfectly so that strokeStart and strokeEnd work correctly.
You want to build the path perfectly.
So that it is very easy to set the stroke start and stroke end values.
And they make sense by perfectly and correctly running clockwise, from the top, from 0 to 1.
Here's the exact code for that:
private let lineThick: CGFloat = 10.0
private let outerGap: CGFloat = 10.0
private let beginFraction: CGFloat = 0 // 0 means from top; runs clockwise
private lazy var arcPath: CGPath = {
let b = beginFraction * .pi * 2.0
return UIBezierPath(
arcCenter: bounds.centerOfCGRect(),
radius: bounds.width / 2.0 - lineThick / 2.0 - outerGap,
startAngle: .pi * -0.5 + b,
endAngle: .pi * 1.5 + b,
clockwise: true
).cgPath
}()
You're done!
override func draw(_ rect: CGRect) {
//Add gradient layer
let gl = CAGradientLayer()
gl.frame = rect
gl.colors = [UIColor.red.cgColor, UIColor.blue.cgColor]
layer.addSublayer(gl)
//create mask in the shape of arc
let sl = CAShapeLayer()
sl.frame = rect
sl.lineWidth = 15.0
sl.strokeColor = UIColor.red.cgColor
let path = UIBezierPath()
path.addArc(withCenter: .zero, radius: 250.0, startAngle: 10.0.radians, endAngle: 60.0.radians, clockwise: true)
sl.fillColor = UIColor.clear.cgColor
sl.lineCap = kCALineCapRound
sl.path = path.cgPath
//Add mask to gradient layer
gl.mask = sl
}
Hope this helps!!

animate along certain path of a layer in swift

I am pretty new to iOS, I am facing a small problem about the CAKeyframeAnimation, I want to animate a view to a certain path of another view layer. it is already successfully animating. However, the position of the animation is not what i expected.
As you can see in my code. I create a UIView(myView) with round bounds. I want another view(square) to follow the orbit of myView's bounds. I already set the myView's centre to the middle of the screen. then I try to get the CGPath of myView and set it to CAKeyframeAnimation's path. However, the square is rotate somewhere else, not on the bounds of myView. Could anyone help me? thanks
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
let square = UIView()
square.frame = CGRect(x: 0, y: 0, width: 20, height: 20)
square.backgroundColor = UIColor.redColor()
self.view.addSubview(square)
let bounds = CGRect(x: 0, y: 0, width: 200, height: 200)
let myView = UIView(frame: bounds)
myView.center = self.view.center
myView.layer.cornerRadius = bounds.width/2
myView.layer.borderWidth = 10
myView.layer.borderColor = UIColor.brownColor().CGColor
self.view.addSubview(myView)
var orbit = CAKeyframeAnimation(keyPath: "position")
orbit.path = CGPathCreateWithEllipseInRect(myView.layer.bounds, nil)
orbit.rotationMode = kCAAnimationRotateAuto
orbit.repeatCount = Float.infinity
orbit.duration = 5.0
square.layer.addAnimation(orbit, forKey: "orbit")
}
The frame "describes the view’s location and size in its superview’s coordinate system," whereas bounds "describes the view’s location and size in its own coordinate system."
So if you're building the path from bounds, then square would have to be a subview of myView. If you constructed the path using the frame of myView, then square could be a subview of view.
So, I find another solution, instead of create the CGPath based on some view already existed, I used
let myPath = UIBezierPath(arcCenter: myView.center, radius: bounds.width/2, startAngle: CGFloat(-(CGFloat(M_PI_2))), endAngle: CGFloat((2.0 * M_PI - M_PI_2)), clockwise: true).CGPath
in this way, I can specify the centre point of the UIBezierPath.