Filling circle animation with UIBezierPath and iOS draw rect - ios

I have this on github: https://github.com/evertoncunha/EPCSpinnerView
It does this animations:
There is a glitch in the animation, when the circle is filling and the view is resizing, and I wan't to fix that.
The glitch happens because the way I fill the circle by setting the layer corners, so it looks like a circle, and I increase the lineWidth with the animation progress, and it looks likes it's filling from outside in. From EPCDrawnIconSpinner.swift file:
let ovalPath = UIBezierPath()
ovalPath.addArc(withCenter: CGPoint(x: ovalRect.midX, y: ovalRect.midY),
radius: ovalRect.width / 2,
startAngle: start * CGFloat.pi/180,
endAngle: end * CGFloat.pi/180, clockwise: true)
layer.cornerRadius = rect.size.height/2
ovalPath.lineWidth = 14 + ((frame.size.width) * progress)
Is there a better way that I can achieve this animation without doing the layer cornerRadius thing? If I could only draw this

I can think of two ways to get away from using cornerRadius:
Use a mask to round the corners, and use a shape layer to fill
You can create a CAShapeLayer of a filled circle, and with a frame set to your view's bounds, then assign it to your layer's mask property. You can animate the size of this mask as you animate the size of your view. Secondly, you will need to create an additional shape later (probably similar to your spinning circle), and do the line width animation on this layer. The mask will ensure that the line does not grow outwards, but only inwards.
Animate the line width and radius of the fill shape
You will need to create a circle shape layer similar to the one described in the first option, but instead of just animating the line width, you'll also need to animate the radius. For example: Let's say your view size is 40x40. Of course, at the start of the fill animation, your shape layer will also need to be 40x40. You will then animate the line width to 20pts, and animate the radius of the circle from 20pts to 10pts. A circle with a radius of 10pts and a line width of 20pts will appear to be a filled circle with a radius of 20pts.
Hope this helps.

I ended up with the following solution:
fileprivate func drawPathCompleted(_ ovalRect: CGRect) {
var size: CGFloat = ovalRect.size.width
var current = size*(1-progress)
var pos: CGFloat = lineWidth/2
while size > current {
let ovalPath = UIBezierPath(ovalIn: CGRect(x: pos, y: pos, width: size, height: size))
ovalPath.lineCapStyle = .round
ovalPath.lineWidth = lineWidth
ovalPath.stroke()
let decr = lineWidth/2
size -= decr
pos += decr/2
}
}
At this commit, line 243:
https://github.com/evertoncunha/EPCSpinnerView/commit/87d968846d92aa97e85ff4c58b6664ad7b03f00b#diff-8dc22814328ed859a00acfcf2909854bR242

Related

Animating CAShapeLayer path smoothly

I'm trying to make a Gauge UIView to mimic the following image as close as possible
func gradientBezierPath(percent: CGFloat) -> UIBezierPath {
// vary this to move the start of the arc
let startAngle = CGFloat(180).toRadians()//-CGFloat.pi / 2 // This corresponds to 12 0'clock
// vary this to vary the size of the segment, in per cent
let proportion = CGFloat(50 * percent)
let centre = CGPoint (x: self.frame.size.width / 2, y: self.frame.size.height / 2)
let radius = self.frame.size.height/4//self.frame.size.width / (CGFloat(130).toRadians())
let arc = CGFloat.pi * 2 * proportion / 100 // i.e. the proportion of a full circle
// Start a mutable path
let cPath = UIBezierPath()
// Move to the centre
cPath.move(to: centre)
// Draw a line to the circumference
cPath.addLine(to: CGPoint(x: centre.x + radius * cos(startAngle), y: centre.y + radius * sin(startAngle)))
// NOW draw the arc
cPath.addArc(withCenter: centre, radius: radius, startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
// Line back to the centre, where we started (or the stroke doesn't work, though the fill does)
cPath.addLine(to: CGPoint(x: centre.x, y: centre.y))
return cPath
}
override func draw(_ rect: CGRect) {
// let endAngle = percent == 1.0 ? 0 : (percent * 180) + 180
path = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2),
radius: self.frame.size.height/4,
startAngle: CGFloat(180).toRadians(),
endAngle: CGFloat(0).toRadians(),
clockwise: true)
percentPath = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2),
radius: self.frame.size.height/4,
startAngle: CGFloat(180).toRadians(),
endAngle: CGFloat(0).toRadians(),
clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.path = self.path.cgPath
shapeLayer.strokeColor = UIColor(red: 110 / 255, green: 78 / 255, blue: 165 / 255, alpha: 1.0).cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineWidth = 5.0
shapeLayer.lineCap = .round
self.layer.addSublayer(shapeLayer)
percentLayer.path = self.percentPath.cgPath
percentLayer.strokeColor = UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 1.0).cgColor
percentLayer.fillColor = UIColor.clear.cgColor
percentLayer.lineWidth = 8.0
// percentLayer.strokeEnd = CGFloat(percent)
percentLayer.lineCap = .round
self.layer.addSublayer(percentLayer)
// n.b. as #MartinR points out `cPath.close()` does the same!
// circle shape
circleShape.path = gradientBezierPath(percent: 1.0).cgPath//cPath.cgPath
circleShape.strokeColor = UIColor.clear.cgColor
circleShape.fillColor = UIColor.green.cgColor
self.layer.addSublayer(circleShape)
gradient.frame = frame
gradient.mask = circleShape
gradient.type = .radial
gradient.colors = [UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.0).cgColor,
UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.0).cgColor,
UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.4).cgColor]
gradient.locations = [0, 0.35, 1]
gradient.startPoint = CGPoint(x: 0.49, y: 0.55) // increase Y adds more orange from top to bottom
gradient.endPoint = CGPoint(x: 0.98, y: 1) // increase x pushes orange out more to edges
self.layer.addSublayer(gradient)
//myTextLayer.string = "\(Int(percent * 100))"
myTextLayer.backgroundColor = UIColor.clear.cgColor
myTextLayer.foregroundColor = UIColor.white.cgColor
myTextLayer.fontSize = 85.0
myTextLayer.frame = CGRect(x: (self.frame.size.width / 2) - (self.frame.size.width/8), y: (self.frame.size.height / 2) - self.frame.size.height/8, width: 120, height: 120)
self.layer.addSublayer(myTextLayer)
}
This produces the following in a playground which is pretty close to what i'm aiming for:
The problem comes when trying to animate the change in the gauge value. I can animate the percentLayer pretty easy with modifying strokeEnd, but animating the circleShape.path for the gradient results in some non-smooth animations if there's a large change in the percent value of the gauge. Here's the function i use to animate both layers (it's called on a timer every 2 seconds right now to simulate gauge value changes).
func randomPercent() {
let random = CGFloat.random(in: 0.0...1.0)
// Animate the percent layer
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = percentLayer.strokeEnd
animation.toValue = random
animation.duration = 1.5
percentLayer.strokeEnd = random
percentLayer.add(animation, forKey: nil)
// Animate the gradient layer
let newShapePath = gradientBezierPath(percent: random)
let gradientAnimation = CABasicAnimation(keyPath: "path")
gradientAnimation.duration = 1.5
gradientAnimation.toValue = newShapePath
gradientAnimation.fromValue = circleShape.path
circleShape.path = newShapePath.cgPath
self.circleShape.add(gradientAnimation, forKey: nil)
myTextLayer.string = "\(Int(random * 100))"
}
Notice how when the animation is done with small changes in the value, the animation looks good. However when there's a large change the gradient animation doesn't look natural at all. Any ideas on how to improve this? Or maybe is it possible to animate a different keyPath for better performance? Any help would be greatly appreciated!
You can't use the Bezier path addArc() function to animate an arc and change the arc distance.
The problem is control points. In order for an animation to work smoothly, the starting and ending shape must have the same number and type of control points. Under the covers, the UIBezierPath (and CGPath) objects create arcs approximating a circle by combining Bezier curves (I don't remember if it uses Quadratic or Cubic Bezier curves.) The entire circle is made up of multiple connected Bezier curves ("Bezier" the mathematical spline function, not UIBeizerPath, which is a UIKit function that creates shapes that can include Bezier paths.) I seem to remember a Bezier approximation of a circle is made up of 4 linked cubic Bezier curves. (See this SO answer for a discussion of what that looks like, if you're interested.)
Here is my understanding of how it works. (I might have the details wrong, but it illustrates the problem in any case.) As you move from <= 1/4 of a full circle to > 1/4 of a full circle, the arc function will use first 1 cubic Bezier section, then 2. At the transition from <= 1/2 of a circle to > 1/2 of a circle, it will shift to 3 Bezier curves, and at the transition from <= 3/4 of a circle to > 3/4 of a circle, it will switch to 4 Bezier curves.
The solution:
You are on the right track with using strokeEnd. Always create your shape as the full circle, and set strokeEnd to something less than 1. That will give you a part of a circle, but in a way that you can animate smoothly. (You can animate strokeStart as well.)
I've animated circles just like you describe using CAShapeLayer and strokeEnd (It was a number of years ago, so it was in Objective-C.) I wrote an article here on OS on using the approach to animate a mask on a UIImageView and create a "clock wipe" animation. If you have an image of your full shaded circle you could use that exact approach here. (You should be able to add a mask layer to any UIView's content layer or other layer, and animate that layer as in my clock wipe demo. Let me know if you need help deciphering the Objective-C.
Here is the sample clock wipe animation I created:
Note that you can use this effect to mask any layer, not just an image view.
EDIT: I posted an update to my clock wipe animation question and answer with a Swift version of the project.
You can get to the new repo directly at https://github.com/DuncanMC/ClockWipeSwift.
For your application I would set up the parts of your gauge that you need to animate as a composite of layers. You'd then attach a CAShapeLayer based mask layer to that composite layer and add a circle arc path to that shape layer and animate the strokeEnd as shown in my sample project. My clock wipe animation reveals the image like the sweep of a clock hand from the center of the layer. In your case you'd center the arc on the bottom center of your layer, and only use a half-circle arc in your shape layer. Using a mask that way would give you a sharp-edged crop to your composited layer. you'd lose the round end caps on your red arc. To fix that you'd have to animate the red arc as it's own shape layer (using strokeEnd) and animate the gradient fill's arc strokeEnd separately.

Scale UIVIew upwards

I have an UIView which I want to scale to double its size. I've tried these 2 commands:
self.bottomView.transform = CGAffineTransform(translationX: 0, y: -125)
This one moves to the point I want but it moves the entire view so I get a gap at the bottom.(125 is my original height)
self.bottomView.transform = CGAffineTransform(scaleX: 1, y: 2)
This one stretches the view but it stretches both ways, up and down. I want it to only stretch in an upward Y-axis direction and not to both ways.
Which one should I continue with? Is there any way to choose which way the view should stretch? Furthermore, scaleX: y: stretches the subviews as well which isn't optimal for my cause.
I think you can use the below API.
CGAffineTransformScale(CGAffineTransform t, CGFloat sx, CGFloat sy)
Update only one axis: X or Y with recognizer.scale and keep the other one 1.0f to achieve one direction scale.
Here is a simple Solution that may help you -- Just Animate your stretching operation on UIView.
UIView.animate(withDuration: 0.2) {
self.bottomView.frame.size.height = self.bottomView.frame.size.height * 2
}

SKSpriteNode will not center

I have this inside my GameScene which is called in the didMove()
for i in 1...5 {
// path to create the circle
let path = UIBezierPath(arcCenter: CGPoint(x: center.x, y: center.y), radius: CGFloat(((43 * i) + 140)), startAngle: CGFloat(GLKMathDegreesToRadians(-50)), endAngle: CGFloat(M_PI * 2), clockwise: false)
// the inside edge of the circle used for creating its physics body
let innerPath = UIBezierPath(arcCenter: CGPoint(x: center.x, y: center.y), radius: CGFloat(((43 * i) + 130)), startAngle: CGFloat(GLKMathDegreesToRadians(-50)), endAngle: CGFloat(M_PI * 2), clockwise: false)
// create a shape from the path and customize it
let shape = SKShapeNode(path: path.cgPath)
shape.lineWidth = 20
shape.strokeColor = UIColor(red:0.98, green:0.99, blue:0.99, alpha:1.00)
// create a texture and apply it to the sprite
let trackViewTexture = self.view!.texture(from: shape)
let trackViewSprite = SKSpriteNode(texture: trackViewTexture)
trackViewSprite.physicsBody = SKPhysicsBody(edgeChainFrom: innerPath.cgPath)
self.addChild(trackViewSprite)
}
It uses UIBezierPaths to make a few circles. It converts the path into a SKShapeNode then a SKTexture and then applies it to the final SKSpriteNode.
When I do this, the SKSpriteNode is not where it should be, it is a few to the right:
But when I add the SKShapeNode I created, it is set perfectly fine to where it should be:
Even doing this does not center it!
trackViewSprite.position = CGPoint(x: 0, y: 0)
No matter what I try it just will not center.
Why is this happening? Some sort of bug when converting to a texture?
P.S - This has something to do with this also Keep relative positions of SKSpriteNode from SKShapeNode from CGPath
But there is also no response :(
Edit, When I run this:
let testSprite = SKSpriteNode(color: UIColor.yellow, size: trackViewSprite.size)
self.addChild(testSprite)
It shows it has the same frame also:
After a long discussion, we determined that the problem is due to the frame size not being the expected size of the shape.
To combat this, the OP created an outer path of his original path, and calculated the frame that would surround this. Now this approach may not work for everybody.
If anybody else comes across this issue, they will need to do these things:
1) Check the frame of the SKShapeNode to make sure that it is correct
2) Determine what method is best to calculate the correct desired frame
3) Use this new frame when getting textureFromNode to extract only the desired texture size

Get the size of the rectangle within a UIView with a cornerRadius

I have a UIView with rounded corners.
How can I get the size of the inner rectangle?
If you would like to be able to get something like the green rectangle here. You can use this method, where you pass the red rectangle as parameter:
func innerRectangle(view:UIView) -> CGRect {
let radius = view.layer.cornerRadius * (1 - M_SQRT1_2)
let origin = CGPoint(x: view.frame.origin.x + radius, y: view.frame.origin.y + radius)
let size = CGSize(width: view.frame.width-radius*2, height: view.frame.height-radius*2)
return CGRect(origin: origin, size: size)
}
The accepted answer is a great estimate for small values of cornerRadius, but it deviates noticeably from the outer rect's boundary as the value increases. Since the OP requested that the inner rect must touch the outer rect, it would be better to replace the first line inside the function with
let radius = view.layer.cornerRadius * (1 - (1 / pow(2, 0.5)))

How to create 'union' of two intersecting CGPath-s?

I wanted to create a rounded cross by combining two rounded rectangles with CGPathAddRoundedRect. The problem is the area where rects intersect is empty, but I would like it to be filled. (see image of the problem)
I'm also using SpriteKit SKShapeNode to draw on scene. Here is Swift code I'm using:
backgroundColor = UIColor.orangeColor()
let width: CGFloat = 302
let height: CGFloat = 92
let path = CGPathCreateMutable()
CGPathAddRoundedRect(path, nil, CGRectMake((size.width-width)/2, (size.height-height)/2, width, height), height/2, height/2)
CGPathAddRoundedRect(path, nil, CGRectMake((size.width-height)/2, (size.height-width)/2, height, width), height/2, height/2)
CGPathCloseSubpath(path)
let cross = SKShapeNode(path: path)
cross.fillColor = UIColor.whiteColor()
addChild(cross)
Any ideas how to make intersecting area filled? I know I could create two separate shape nodes ... but I just want to learn how to do that with combining CGPaths directly.

Resources