How to do hit detection in core graphics - ios

Core graphics is pretty new to me, and I'm facing some issues detecting clicks on my custom graphics.
I generated som code with the demo of paincode which i then heavily modified. It draws a "pie" like this:
The code I used for this looks like this:
import UIKit
public class DrawTest : NSObject {
static var hitAreas = [Int:UIBezierPath]()
static func didHit(_ point: CGPoint){
let res = hitAreas.first{ $0.value.contains(point) }?.key
print("HIT: ", res)
}
public class func drawDartboard(frame targetFrame: CGRect) {
let context = UIGraphicsGetCurrentContext()!
context.saveGState()
let resizedFrame: CGRect = targetFrame
context.translateBy(x: resizedFrame.minX, y: resizedFrame.minY)
context.scaleBy(x: resizedFrame.width / 100, y: resizedFrame.height / 100)
let sliceRect = CGRect(x: 0, y: 0, width: 100, height: 100)
context.saveGState()
context.clip(to: sliceRect)
context.translateBy(x: sliceRect.minX, y: sliceRect.minY)
context.translateBy(x: 0, y: sliceRect.height)
context.scaleBy(x: 1, y: -1)
let dark = UIColor(red: 0.235, green: 0.208, blue: 0.208, alpha: 1.000)
let light = UIColor(red: 0.435, green: 0.408, blue: 0.408, alpha: 1.000)
var slice = 0
while slice < 20 {
let sliceColor = slice%2 == 0 ? dark : light
DrawTest.drawSlice(frame: CGRect(origin: .zero, size: sliceRect.size), roration: CGFloat(slice*18), sliceColor: sliceColor, slice: slice )
slice += 1
}
context.restoreGState()
}
public class func drawSlice(frame targetFrame: CGRect, roration: CGFloat, sliceColor: UIColor, slice: Int) {
let context = UIGraphicsGetCurrentContext()!
context.saveGState()
let resizedFrame: CGRect = targetFrame
context.translateBy(x: resizedFrame.minX, y: resizedFrame.minY)
context.scaleBy(x: resizedFrame.width / 100, y: resizedFrame.height / 100)
context.saveGState()
context.translateBy(x: 49.99, y: 50)
context.rotate(by: roration * CGFloat.pi/180)
let sliceFillPath = UIBezierPath()
sliceFillPath.move(to: CGPoint(x: -7.82, y: 49.38))
sliceFillPath.addCurve(to: CGPoint(x: 7.83, y: 49.38), controlPoint1: CGPoint(x: -2.63, y: 50.2), controlPoint2: CGPoint(x: 2.65, y: 50.2))
sliceFillPath.addLine(to: CGPoint(x: 0.01, y: -0))
sliceFillPath.addLine(to: CGPoint(x: -7.82, y: 49.38))
sliceFillPath.close()
sliceColor.setFill()
sliceFillPath.fill()
hitAreas[slice] = sliceFillPath
context.restoreGState()
}
}
I'm calling the draw code from a simple UIView subclass like below. This is also were I attach a TapGerstureRecognizer.
import UIKit
class DartBoardView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
let gesture = UITapGestureRecognizer(target: self, action: #selector(self.clickAction(sender:)))
addGestureRecognizer(gesture)
}
#objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
DrawTest.didHit(loc)
}
}
override func draw(_ rect: CGRect) {
DrawTest.drawDartboard(frame: bounds)
}
}
The drawing looks like I want it to, but I want to be able to select each of the slices, this is the part that is not working. I am pretty sure that the issue has to do with the point I pass to didHit is local to my View but the UIBezierPath I store in hitAreas and call contains uses the local coordinates of the UIBezierPath, this is why I never get a hit.
I have no idea how to solve this and desperately need help. My guess is that this should be solved by 1) drawing my slices directy on the UIView´s coordinate system, but that would require a lot af math 2) somehow translate the local coordinates of each UIBezierPath to the scope of the view when hit testing
This is all very confusing at all constructive input is very appreciated.

There are various approaches, depending on exactly what your end-goal is.
One approach:
calculate the "degrees-per-slice" ... 360 / 20 = 18
get the angle from the center point to the touch point
"fix" the angle by 1/2 of the slice width (since the slices don't start at zero)
divide that angle by degrees-per-slice to get the slice number
Use these two extensions to make it easy to get the angle (in degrees):
extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}
extension CGPoint {
func angle(to otherPoint: CGPoint) -> CGFloat {
let pX = otherPoint.x - x
let pY = otherPoint.y - y
let radians = atan2f(Float(pY), Float(pX))
var degrees = CGFloat(radians).degrees
while degrees < 0 {
degrees += 360
}
return degrees
}
}
And, in the code you posted, in your DrawTest class, change didHit to:
static func didHit(_ point: CGPoint, in bounds: CGRect){
let c: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let angle = c.angle(to: point)
var fixedAngle = Int(angle) + 99 // 90 degrees + 1/2 of slice width
if fixedAngle >= 360 {
fixedAngle -= 360
}
print("HIT:", fixedAngle / 18)
}
and include the bounds when you call it from DartBoardView class as:
#objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
// include self's bounds
DrawTest.didHit(loc, in: bounds)
}
}
Drawbacks include:
you'd also need to check the "line length" to make sure it doesn't extend outside the circle
you don't have easy access to the slice bezier paths (if you want to do something else with them)
Another approach would be to use shape layers for each slice, making it easier to track the bezier paths.
Start with a Struct for the slices:
struct Slice {
var color: UIColor = .white
var path: UIBezierPath = UIBezierPath()
var shapeLayer: CAShapeLayer = CAShapeLayer()
var key: Int = 0
}
The DartBoardView class becomes (note: it uses the same CGFloat extension from above):
extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}
class DartBoardView: UIView {
// array of slices
var slices: [Slice] = []
// slice width in degrees
let sliceWidth: CGFloat = 360.0 / 20.0
// easy to understand 12 o'clock (3 o'clock is Zero)
let twelveOClock: CGFloat = 270
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
let dark = UIColor(red: 0.235, green: 0.208, blue: 0.208, alpha: 1.000)
let light = UIColor(red: 0.435, green: 0.408, blue: 0.408, alpha: 1.000)
for slice in 0..<20 {
let sliceColor = slice % 2 == 1 ? dark : light
let s = Slice(color: sliceColor, key: slice)
s.shapeLayer.fillColor = s.color.cgColor
layer.addSublayer(s.shapeLayer)
slices.append(s)
}
let gesture = UITapGestureRecognizer(target: self, action: #selector(self.clickAction(sender:)))
addGestureRecognizer(gesture)
}
#objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
if let s = slices.first(where: { $0.path.contains(loc) }) {
print("HIT:", s.key)
} else {
print("Tapped outside the circle!")
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
let c: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let radius: CGFloat = bounds.midX
// slice width in radians
let ww: CGFloat = sliceWidth.radians
// start 1/2 sliceWidth less than 12 o'clock
var startDegrees: CGFloat = twelveOClock.radians - (ww * 0.5)
for i in 0..<slices.count {
let endDegrees: CGFloat = startDegrees + ww
let pth: UIBezierPath = UIBezierPath()
pth.addArc(withCenter: c, radius: radius, startAngle: startDegrees, endAngle: endDegrees, clockwise: true)
pth.addLine(to: c)
pth.close()
slices[i].path = pth
slices[i].shapeLayer.path = pth.cgPath
startDegrees = endDegrees
}
}
}
And here's an example controller class to demonstrate:
class DartBoardViewController: UIViewController {
let dartBoard = DartBoardView()
override func viewDidLoad() {
super.viewDidLoad()
dartBoard.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dartBoard)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
dartBoard.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
dartBoard.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
dartBoard.heightAnchor.constraint(equalTo: dartBoard.widthAnchor),
dartBoard.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
dartBoard.backgroundColor = .black
}
}
Edit
Not as complex as it may seem.
Here's an implementation of a full Dart Board (without the numbers - I'll leave that as an exercise for you):
Segment Struct
struct Segment {
var value: Int = 0
var multiplier: Int = 1
var color: UIColor = .cyan
var path: UIBezierPath = UIBezierPath()
var layer: CAShapeLayer = CAShapeLayer()
}
DartBoardView class
class DartBoardView: UIView {
var doubleSegments: [Segment] = [Segment]()
var outerSingleSegments: [Segment] = [Segment]()
var tripleSegments: [Segment] = [Segment]()
var innerSingleSegments: [Segment] = [Segment]()
var singleBullSegment: Segment = Segment()
var doubleBullSegment: Segment = Segment()
var allSegments: [Segment] = [Segment]()
let boardLayer: CAShapeLayer = CAShapeLayer()
let darkColor: UIColor = UIColor(white: 0.1, alpha: 1.0)
let lightColor: UIColor = UIColor(red: 0.975, green: 0.9, blue: 0.8, alpha: 1.0)
let darkRedColor: UIColor = UIColor(red: 0.8, green: 0.1, blue: 0.1, alpha: 1.0)
let darkGreenColor: UIColor = UIColor(red: 0.0, green: 0.5, blue: 0.3, alpha: 1.0)
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
layer.addSublayer(boardLayer)
boardLayer.fillColor = UIColor.black.cgColor
// points starting at 3 o'clock
let values: [Int] = [
6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5, 20, 1, 18, 4, 13,
]
// local vars for reuse
var seg: Segment = Segment()
var c: UIColor = .white
// doubles and triples
for i in 0..<values.count {
c = i % 2 == 1 ? darkRedColor : darkGreenColor
seg = Segment(value: values[i],
multiplier: 2,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
doubleSegments.append(seg)
seg = Segment(value: values[i],
multiplier: 3,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
tripleSegments.append(seg)
}
// singles
for i in 0..<values.count {
c = i % 2 == 1 ? darkColor : lightColor
seg = Segment(value: values[i],
multiplier: 1,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
outerSingleSegments.append(seg)
seg = Segment(value: values[i],
multiplier: 1,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
innerSingleSegments.append(seg)
}
// bull and double bull
seg = Segment(value: 25,
multiplier: 1,
color: darkGreenColor,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
singleBullSegment = seg
seg = Segment(value: 25,
multiplier: 2,
color: darkRedColor,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
doubleBullSegment = seg
let gesture = UITapGestureRecognizer(target: self, action: #selector(self.clickAction(sender:)))
addGestureRecognizer(gesture)
}
#objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
if let s = allSegments.first(where: { $0.path.contains(loc) }) {
print("HIT:", s.multiplier == 3 ? "Triple" : s.multiplier == 2 ? "Double" : "Single", s.value)
} else {
print("Tapped outside!")
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
// initialize local variables for reuse / readability
var startAngle: CGFloat = 0
var outerDoubleRadius: CGFloat = 0.0
var innerDoubleRadius: CGFloat = 0.0
var outerTripleRadius: CGFloat = 0.0
var innerTripleRadius: CGFloat = 0.0
var outerBullRadius: CGFloat = 0.0
var innerBullRadius: CGFloat = 0.0
// initialize local constants
let viewCenter: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
// leave 20% for the numbers area
let diameter = bounds.width * 0.8
// dart board radii in mm
let specRadii: [CGFloat] = [
170, 162, 107, 99, 16, 6
]
// convert to view size
let factor: CGFloat = (diameter * 0.5) / specRadii[0]
outerDoubleRadius = specRadii[0] * factor
innerDoubleRadius = specRadii[1] * factor
outerTripleRadius = specRadii[2] * factor
innerTripleRadius = specRadii[3] * factor
outerBullRadius = specRadii[4] * factor
innerBullRadius = specRadii[5] * factor
let wireColor: UIColor = UIColor(white: 0.8, alpha: 1.0)
let wedgeWidth: CGFloat = 360.0 / 20.0
let incAngle: CGFloat = wedgeWidth.radians
startAngle = -(incAngle * 0.5)
var path: UIBezierPath = UIBezierPath()
// outer board layer
path = UIBezierPath(ovalIn: bounds)
boardLayer.path = path.cgPath
for i in 0..<20 {
let endAngle = startAngle + incAngle
var shape = doubleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: outerDoubleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: innerDoubleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath
doubleSegments[i].path = path
shape.fillColor = doubleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
shape = outerSingleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: innerDoubleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: outerTripleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath
outerSingleSegments[i].path = path
shape.fillColor = outerSingleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
shape = tripleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: outerTripleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: innerTripleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath
tripleSegments[i].path = path
shape.fillColor = tripleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
shape = innerSingleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: innerTripleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: outerBullRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath
innerSingleSegments[i].path = path
shape.fillColor = innerSingleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
startAngle = endAngle
}
let singleBullPath = UIBezierPath(ovalIn: CGRect(x: viewCenter.x - outerBullRadius, y: viewCenter.y - outerBullRadius, width: outerBullRadius * 2, height: outerBullRadius * 2))
let doubleBullPath = UIBezierPath(ovalIn: CGRect(x: viewCenter.x - innerBullRadius, y: viewCenter.y - innerBullRadius, width: innerBullRadius * 2, height: innerBullRadius * 2))
var shape = singleBullSegment.layer
singleBullPath.append(doubleBullPath)
singleBullPath.usesEvenOddFillRule = true
shape.fillRule = .evenOdd
shape.path = singleBullPath.cgPath
singleBullSegment.path = singleBullPath
shape.fillColor = singleBullSegment.color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
shape = doubleBullSegment.layer
shape.path = doubleBullPath.cgPath
doubleBullSegment.path = doubleBullPath
shape.fillColor = doubleBullSegment.color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
// append all segments for hit-testing
allSegments = []
allSegments.append(contentsOf: tripleSegments)
allSegments.append(contentsOf: outerSingleSegments)
allSegments.append(contentsOf: doubleSegments)
allSegments.append(contentsOf: innerSingleSegments)
allSegments.append(singleBullSegment)
allSegments.append(doubleBullSegment)
}
}
CGFloat extension
extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}
Example view controller
class DartBoardViewController: UIViewController {
let dartBoard = DartBoardView()
override func viewDidLoad() {
super.viewDidLoad()
dartBoard.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dartBoard)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
dartBoard.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
dartBoard.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
dartBoard.heightAnchor.constraint(equalTo: dartBoard.widthAnchor),
dartBoard.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
dartBoard.backgroundColor = .clear
}
}
Result:
and debug output from a few taps:
HIT: Double 20
HIT: Single 18
HIT: Triple 2
HIT: Single 25
HIT: Double 25

Related

Ball path not following semi-circle exactly

Bit stumped here.
First of all, here is what's going on:
As you can see, my ball is not following the curved quarter-circle path exactly, but vaguely.
Here is the code creating the quarter-circle (p.s. - my container view is 294 units tall and wide):
let startAngle = CGFloat(Double.pi * 2) // top of circle
let endAngle = startAngle + 2 * Double.pi * 0.25
view.layoutIfNeeded()
smallCircleView.parentVC = self
smallCircleView.layer.cornerRadius = 45/2
let circlePath = UIBezierPath(arcCenter: CGPoint(x: 0, y: 0), radius: containerView.frame.self.width, startAngle: startAngle, endAngle: endAngle, clockwise: true)
And here is the code shifting the ball around:
func shiftSmallCircleView(newX : CGFloat){
smallCircleViewLeadingConstraint.constant = newX
let angle = (newX/containerView.frame.self.width)*90 + 180
let y = containerView.frame.size.width * cos((Double.pi * 2 * angle) / 360)
smallCircleViewBottomConstraint.constant = y + containerView.frame.origin.y
}
Since I'm using the cos function, should the ball's path be identical to the original quarter-circle path? How can they be similar but not identical?
Edit:
New outcome with updated code:
let angle = (distanceDelta/containerView.frame.self.width) * -90.0
containerView.transform = CGAffineTransform.init(rotationAngle: angle * Double.pi/180)
Most recent edit:
let angle = (distanceDelta/pathContainerView.frame.self.width) * .pi / -180.0
containerView.transform = CGAffineTransform.init(rotationAngle: angle)
All code:
class SmallCircleView : UIView {
var parentVC : ViewController!
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first as? UITouch {
let point = touch.location(in: self)
}
super.touchesBegan(touches, with: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first as? UITouch {
let point = touch.location(in: self.superview)=
parentVC.shiftSmallCircleView(distanceDelta: point.x)=
}
}
}
class ViewController: UIViewController {
#IBOutlet var containerView : UIView!
#IBOutlet var pathContainerView : UIView!
#IBOutlet var smallCircleView : SmallCircleView!
#IBOutlet var smallCircleViewLeadingConstraint : NSLayoutConstraint!
#IBOutlet var smallCircleViewBottomConstraint : NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
let startAngle = CGFloat(Double.pi * 2) // top of circle
let endAngle = startAngle + 2 * Double.pi * 0.25
view.layoutIfNeeded()
smallCircleView.parentVC = self
smallCircleView.layer.cornerRadius = 45/2
let circlePath = UIBezierPath(arcCenter: CGPoint(x: 0, y: 0), radius: pathContainerView.frame.self.width, startAngle: startAngle, endAngle: endAngle, clockwise: true)
let shapeLayer = CAShapeLayer()
// The Bezier path that we made needs to be converted to
// a CGPath before it can be used on a layer.
shapeLayer.path = circlePath.cgPath
// apply other properties related to the path
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.fillColor = UIColor.white.cgColor
shapeLayer.lineWidth = 1.0
shapeLayer.position = CGPoint(x: 0, y: 0)
// add the new layer to our custom view
pathContainerView.layer.addSublayer(shapeLayer)
containerView.bringSubviewToFront(smallCircleView)
}
func shiftSmallCircleView(distanceDelta : CGFloat){
let degrees = min(1, (distanceDelta/pathContainerView.frame.size.width)) * -90
containerView.transform = CGAffineTransform.init(rotationAngle: degrees * M_PI/180)
}
}
I’m on my phone right now and so I can’t provide the code but there is a much much easier way to do this. Don’t bother trying to work out what coordinates the ball needs to be at. Just place the ball into a rectangular view with the centre of this view being at the centre of your circle and the ball being on the path. (Make the container view invisible).
Now… rotate the container view.
That’s it.
Because the ball is a child of the view it will be moved as part of the rotation. And the movement will follow a circle centred around the point of rotation. Which is the centre of the container view.
Example
I made a quick example to show what I mean. In essence... cheat. Don't actually do the hard maths to work out where the ball will be. Use methods to make it look the same in an easier way...
Here is my storyboard...
And the code...
class ViewController: UIViewController {
#IBOutlet weak var containerView: UIView!
#IBOutlet weak var circleView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
circleView.layer.cornerRadius = 20
}
#IBAction func sliderChanged(_ sender: UISlider) {
let rotationAngle = sender.value * .pi / 180
containerView.transform = CGAffineTransform(rotationAngle: CGFloat(rotationAngle))
}
}
And an animation...
And if you make the container background clear...
To answer your original question...
I haven't double-checked your math, but this is another method of positioning your "small circle" view.
Using these two "helper" extensions:
extension CGPoint {
static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
return CGPoint(x: x, y: y)
}
}
extension CGFloat {
var degreesToRadians: Self { self * .pi / 180 }
var radiansToDegrees: Self { self * 180 / .pi }
}
We can find the point on the arc for a given angle like this:
let arcCenter: CGPoint = .zero
let radius: CGFloat = 250
let degree: CGFloat = 45
let p = CGPoint.pointOnCircle(center: arcCenter, radius: radius, angle: degree.degreesToRadians)
We can then move the "ball" to that point:
circleView.center = p
To get the circle view to "roll along the inside" of the arc, we use the same center point, but decrease the radius of the arc by the radius of the circle (half the width of the view).
If you want to use that approach (rather than rotating a view with the circle in the corner), here is some example code.
Start with our extensions, an enum, and a "small circle view":
extension CGPoint {
static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
return CGPoint(x: x, y: y)
}
}
extension CGFloat {
var degreesToRadians: Self { self * .pi / 180 }
var radiansToDegrees: Self { self * 180 / .pi }
}
enum FollowType: Int {
case inside, center, outside
}
class SmallCircleView : UIView {
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.size.height / 2.0
}
}
Next, a UIView subclass that will handle drawing the arc, adding the circle subview, and it will use a UIViewPropertyAnimator with key frames to make it interactive:
class FollowArcView: UIView {
public var circleColor: UIColor = .red {
didSet {
circleView.backgroundColor = circleColor
}
}
public var arcColor: UIColor = .blue { didSet { setNeedsLayout() } }
public var arcLineWidth: CGFloat = 1 { didSet { setNeedsLayout() } }
public var arcInset: CGFloat = 0 { didSet { setNeedsLayout() } }
public var circleRadius: CGFloat = 25 { didSet { setNeedsLayout() } }
public var followType: FollowType = .inside { didSet { setNeedsLayout() } }
public var fractionComplete: CGFloat = 0 {
didSet {
if animator != nil {
animator.fractionComplete = fractionComplete
}
}
}
private let circleView = SmallCircleView()
private let arcLayer = CAShapeLayer()
private var animator: UIViewPropertyAnimator!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
arcLayer.fillColor = UIColor.clear.cgColor
layer.addSublayer(arcLayer)
circleView.frame = CGRect(x: 0, y: 0, width: circleRadius * 2.0, height: circleRadius * 2.0)
circleView.backgroundColor = circleColor
addSubview(circleView)
}
override func layoutSubviews() {
super.layoutSubviews()
var curFraction: CGFloat = 0
// we may be changing properties (such as arc color, inset, etc)
// after we've moved the circleView, so
// save the current .fractionComplete
if animator != nil {
curFraction = animator.fractionComplete
animator.stopAnimation(true)
}
// these properties can be changed after initial view setup
arcLayer.lineWidth = arcLineWidth
arcLayer.strokeColor = arcColor.cgColor
circleView.frame.size = CGSize(width: circleRadius * 2.0, height: circleRadius * 2.0)
let arcCenter = CGPoint(x: arcInset, y: arcInset)
let arcRadius = bounds.width - (arcInset * 2.0)
let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
let pth = UIBezierPath(arcCenter: arcCenter, radius: arcRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
arcLayer.path = pth.cgPath
let p = CGPoint.pointOnCircle(center: arcCenter, radius: followRadius, angle: CGFloat(90).degreesToRadians)
circleView.center = p
// the animator will take the current position of the circleView
// as Frame 0, so we need to let UIKit update the circleView's position
// before setting up the animator
DispatchQueue.main.async {
self.setupAnim()
self.animator.fractionComplete = curFraction
}
}
private func setupAnim() {
if animator != nil {
animator.stopAnimation(true)
}
// starting point
var startDegrees: CGFloat = 90
// ending point
let endDegrees: CGFloat = 0
// we'll be using percentages
let numSteps: CGFloat = 100
let arcCenter = CGPoint(x: arcInset, y: arcInset)
let arcRadius = bounds.width - (arcInset * 2.0)
let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
animator = UIViewPropertyAnimator(duration: 0.3, curve: .linear)
animator.addAnimations {
UIView.animateKeyframes(withDuration: 0.1, delay: 0.0, animations: {
let stepDegrees: Double = (startDegrees - endDegrees) / Double(numSteps)
for i in 1...Int(numSteps) {
// decrement degrees by step value
startDegrees -= stepDegrees
// get point on discPathRadius circle
let p = CGPoint.pointOnCircle(center: arcCenter, radius: followRadius, angle: startDegrees.degreesToRadians)
// duration is 1 divided by number of steps
let duration = 1.0 / Double(numSteps)
// start time for this frame is duration * this step
let startTime = duration * Double(i)
// add the keyframe
UIView.addKeyframe(withRelativeStartTime: startTime, relativeDuration: duration) {
self.circleView.center = p
}
}
})
}
// start and immediately pause the animation
animator.startAnimation()
animator.pauseAnimation()
}
}
and an example controller class:
class FollowArcVC: UIViewController {
let followArcView = FollowArcView()
override func viewDidLoad() {
super.viewDidLoad()
let slider = UISlider()
let typeControl = UISegmentedControl(items: ["Inside", "Center", "Outside"])
[followArcView, typeControl, slider].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
followArcView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
followArcView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
followArcView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
// 1:1 ratio (square)
followArcView.heightAnchor.constraint(equalTo: followArcView.widthAnchor),
typeControl.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
typeControl.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
typeControl.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
slider.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
typeControl.selectedSegmentIndex = 0
typeControl.addTarget(self, action: #selector(typeChanged(_:)), for: .valueChanged)
slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
// if we want to see the view frame
//followArcView.backgroundColor = .yellow
}
#objc func typeChanged(_ sender: UISegmentedControl) -> Void {
switch sender.selectedSegmentIndex {
case 0:
followArcView.followType = .inside
case 1:
followArcView.followType = .center
case 2:
followArcView.followType = .outside
default:
()
}
}
#objc func sliderChanged(_ sender: Any?) {
guard let sldr = sender as? UISlider else { return }
followArcView.fractionComplete = CGFloat(sldr.value)
}
}
The result:
Edit
After playing around a bit, this is another way to interactively follow the path -- it uses layer path animation, and avoids the need to manually calculate keyframe positions.
Works with the same sample view controller as above - just replace the FollowArcView class:
class FollowArcView: UIView {
public var circleColor: UIColor = .red {
didSet {
circleView.backgroundColor = circleColor
}
}
public var arcColor: UIColor = .blue {
didSet {
arcLayer.strokeColor = arcColor.cgColor
}
}
public var arcLineWidth: CGFloat = 1 {
didSet {
arcLayer.lineWidth = arcLineWidth
}
}
public var arcInset: CGFloat = 0 { didSet { setNeedsLayout() } }
public var circleRadius: CGFloat = 25 { didSet { setNeedsLayout() } }
public var followType: FollowType = .inside { didSet { setNeedsLayout() } }
public var fractionComplete: CGFloat = 0 {
didSet {
circleView.layer.timeOffset = CFTimeInterval(fractionComplete)
}
}
private let circleView = SmallCircleView()
private let arcLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
arcLayer.fillColor = UIColor.clear.cgColor
arcLayer.lineWidth = arcLineWidth
arcLayer.strokeColor = arcColor.cgColor
layer.addSublayer(arcLayer)
circleView.frame = CGRect(x: 0, y: 0, width: circleRadius * 2.0, height: circleRadius * 2.0)
circleView.backgroundColor = circleColor
addSubview(circleView)
}
override func layoutSubviews() {
super.layoutSubviews()
circleView.frame.size = CGSize(width: circleRadius * 2.0, height: circleRadius * 2.0)
let arcCenter = CGPoint(x: arcInset, y: arcInset)
let arcRadius = bounds.width - (arcInset * 2.0)
let pth = UIBezierPath(arcCenter: arcCenter, radius: arcRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
arcLayer.path = pth.cgPath
self.setupAnim()
}
private func setupAnim() {
let arcCenter = CGPoint(x: arcInset, y: arcInset)
let arcRadius = bounds.width - (arcInset * 2.0)
let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
let pth = UIBezierPath(arcCenter: arcCenter, radius: followRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
let animation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.position))
animation.duration = 1
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
animation.path = pth.cgPath
circleView.layer.speed = 0
circleView.layer.timeOffset = 0
circleView.layer.add(animation, forKey: "PathAnim")
DispatchQueue.main.async {
self.circleView.layer.timeOffset = self.fractionComplete
}
}
}

Animate maskedCorners in Swift when some corners are always rounded?

I have a rect. Top corners are always rounded. Bottom corners have animation - rounded or not.
Previous my solution was to split this rect into top and bottom rects (top one is constant, bottom one is animated). The reason is maskedCorners is not animated - you can animate cornerRadius only.
But now I need to add a colored border around the rect which should be animated too. So my solution is not suitable anymore. How to solve this issue?
You can do this by animating a CGPath for the view's layer, and constructing the path by adding arcs at the corners for individual radii.
Here's an example class:
class AnimCornerView: UIView {
public var fillColor: UIColor = .white
public var borderColor: UIColor = .clear
public var borderWidth: CGFloat = 0.0
private var _tl: CGFloat = 0
private var _tr: CGFloat = 0
private var _bl: CGFloat = 0
private var _br: CGFloat = 0
private var theShapeLayer: CAShapeLayer!
override class var layerClass: AnyClass {
return CAShapeLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
backgroundColor = .clear
theShapeLayer = self.layer as? CAShapeLayer
}
override func layoutSubviews() {
super.layoutSubviews()
setCorners(topLeft: _tl, topRight: _tr, botLeft: _bl, botRight: _br, animated: false)
}
public func setCorners(topLeft tl: CGFloat, topRight tr: CGFloat, botLeft bl: CGFloat, botRight br: CGFloat, animated: Bool, duration: CFTimeInterval = 0.3) -> Void {
_tl = tl
_tr = tr
_bl = bl
_br = br
theShapeLayer.fillColor = fillColor.cgColor
theShapeLayer.strokeColor = borderColor.cgColor
theShapeLayer.lineWidth = borderWidth
let newPath: CGPath = getPath(topLeft: tl, topRight: tr, botLeft: bl, botRight: br)
if animated {
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "path")
animation.duration = duration
animation.toValue = newPath
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
CATransaction.setCompletionBlock({
self.theShapeLayer.path = newPath
self.theShapeLayer.removeAllAnimations()
})
self.theShapeLayer.add(animation, forKey: "path")
CATransaction.commit()
} else {
theShapeLayer.path = newPath
}
}
private func getPath(topLeft tl: CGFloat, topRight tr: CGFloat, botLeft bl: CGFloat, botRight br: CGFloat) -> CGPath {
var pt = CGPoint.zero
let myBezier = UIBezierPath()
// top-left corner plus top-left radius
pt.x = tl
pt.y = 0
myBezier.move(to: pt)
pt.x = bounds.maxX - tr
pt.y = 0
// add "top line"
myBezier.addLine(to: pt)
pt.x = bounds.maxX - tr
pt.y = tr
// add "top-right corner"
myBezier.addArc(withCenter: pt, radius: tr, startAngle: .pi * 1.5, endAngle: 0, clockwise: true)
pt.x = bounds.maxX
pt.y = bounds.maxY - br
// add "right-side line"
myBezier.addLine(to: pt)
pt.x = bounds.maxX - br
pt.y = bounds.maxY - br
// add "bottom-right corner"
myBezier.addArc(withCenter: pt, radius: br, startAngle: 0, endAngle: .pi * 0.5, clockwise: true)
pt.x = bl
pt.y = bounds.maxY
// add "bottom line"
myBezier.addLine(to: pt)
pt.x = bl
pt.y = bounds.maxY - bl
// add "bottom-left corner"
myBezier.addArc(withCenter: pt, radius: bl, startAngle: .pi * 0.5, endAngle: .pi, clockwise: true)
pt.x = 0
pt.y = tl
// add "left-side line"
myBezier.addLine(to: pt)
pt.x = tl
pt.y = tl
// add "top-left corner"
myBezier.addArc(withCenter: pt, radius: tl, startAngle: .pi, endAngle: .pi * 1.5, clockwise: true)
myBezier.close()
return myBezier.cgPath
}
}
You can specify different radii for each corner, and tell it to animate to the new settings (or not).
For example, you could start with:
testView.setCorners(topLeft: 40, topRight: 40, botLeft: 0, botRight: 0, animated: false)
to round the top-left and top-right corners, then later call:
testView.setCorners(topLeft: 40, topRight: 40, botLeft: 40, botRight: 40, animated: true)
to animate the bottom corners.
Here's a sample controller class to demonstrate. Each time you tap, the bottom corners will animate between rounded and non-rounded:
class AnimCornersViewController : UIViewController {
let testView: AnimCornerView = {
let v = AnimCornerView()
v.translatesAutoresizingMaskIntoConstraints = false
v.fillColor = .green
v.borderColor = .blue
v.borderWidth = 2
v.setCorners(topLeft: 40, topRight: 40, botLeft: 0, botRight: 0, animated: false)
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(testView)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
testView.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.8),
testView.heightAnchor.constraint(equalTo: g.heightAnchor, multiplier: 0.6),
testView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
testView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
view.addGestureRecognizer(t)
}
var shouldRoundBottom: Bool = false
#objc func gotTap(_ g: UITapGestureRecognizer?) {
shouldRoundBottom.toggle()
if shouldRoundBottom {
testView.setCorners(topLeft: 40, topRight: 40, botLeft: 40, botRight: 40, animated: true)
} else {
testView.setCorners(topLeft: 40, topRight: 40, botLeft: 0, botRight: 0, animated: true)
}
}
}
Note: this is example code only!!!

CATextLayer position and Transformation issue

I am trying to build a control like attached circle image with multiple segment having equal space for each part. Number of segments can change depend upon provided array.
I have developed this so far using CAShapeLayer and UIBezierPath. Also added text in the center of each shape Layer.
I have added my code so far for generating this control.
let circleLayer = CALayer()
func createCircle(_ titleArray:[String]) {
update(bounds: bounds, titleArray: titleArray)
containerView.layer.addSublayer(circleRenderer.circleLayer)
}
func update(bounds: CGRect, titleArray: [String]) {
let position = CGPoint(x: bounds.width / 2.0, y: bounds.height / 2.0)
circleLayer.position = position
circleLayer.bounds = bounds
update(titleArray: titles)
}
func update(titleArray: [String]) {
let center = CGPoint(x: circleLayer.bounds.size.width / 2.0, y: circleLayer.bounds.size.height / 2.0)
let radius:CGFloat = min(circleLayer.bounds.size.width, circleLayer.bounds.size.height) / 2
let segmentSize = CGFloat((Double.pi*2) / Double(titleArray.count))
for i in 0..<titleArray.count {
let startAngle = segmentSize*CGFloat(i) - segmentSize/2
let endAngle = segmentSize*CGFloat(i+1) - segmentSize/2
let midAngle = (startAngle+endAngle)/2
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = UIColor.random.cgColor
let bezierPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
bezierPath.addLine(to: center)
shapeLayer.path = bezierPath.cgPath
let height = titleArray[i].height(withConstrainedWidth: radius-20, font: UIFont.systemFont(ofSize: 15))
let frame = shapeLayer.path?.boundingBoxOfPath ?? CGRect.zero
let textLayer = TextLayer()
textLayer.frame = CGRect(x: 0, y: 0, width: 70, height: height)
textLayer.position = CGPoint(x: frame.center.x, y: frame.center.y)
textLayer.fontSize = 15
textLayer.contentsScale = UIScreen.main.scale
textLayer.alignmentMode = .center
textLayer.string = titleArray[i]
textLayer.isWrapped = true
textLayer.backgroundColor = UIColor.black.cgColor
textLayer.foregroundColor = UIColor.white.cgColor
textLayer.transform = CATransform3DMakeRotation(midAngle, 0.0, 0.0, 1.0)
shapeLayer.addSublayer(textLayer)
circleLayer.addSublayer(shapeLayer)
}
}
circleLayer is added as superlayer, containing full area of UIView.
My requirement is to add text centered vertically and horizontally within shape with angle. I am facing issue with centring text within shape while angle is fine.
Thanks
Edit:
If I remove textLayer rotation code, then It look like this image.
Your labels are not "centered" because you're using the geometric center of the wedge bounding-box:
What you need to do is calculate an "inner" circle, with 1/2 of the full radius, and then find the points on that circle to place your labels.
So, first we calculate the circle:
Then bisect each angle and find the point on the circle:
Then calculate the bounding-box for the label (I used max-width of radius * 0.6), put the center of that frame on the point on the circle, and then rotate the text layer:
And the result, without the "guides":
Note: For these images, I used radius * 0.55 - or just slightly further out than exactly 1/2 of the radius - for the "inner circle". This gave me just slightly better appearance, due to the wedges narrowing as we get to the center of the circle. Changing that to radius * 0.6 might even look better.
Here is the code to generate this view:
struct Wedge {
var color: UIColor = .cyan
var label: String = ""
}
class WedgeView: UIView {
var wedges: [Wedge] = []
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
}
override func layoutSubviews() {
super.layoutSubviews()
layer.sublayers?.forEach { $0.removeFromSuperlayer() }
setup()
}
func setup() -> Void {
// initialize local variables
var startAngle: CGFloat = 0
var outerRadius: CGFloat = 0.0
var halfRadius: CGFloat = 0.0
// initialize local constants
let viewCenter: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let diameter = bounds.width
let fontHeight: CGFloat = ceil(12.0 * (bounds.height / 300.0))
let textLayerFont = UIFont.systemFont(ofSize: fontHeight, weight: .light)
outerRadius = diameter * 0.5
halfRadius = outerRadius * 0.55
let labelMaxWidth:CGFloat = outerRadius * 0.6
startAngle = -(.pi * (1.0 / CGFloat(wedges.count)))
for i in 0..<wedges.count {
let endAngle = startAngle + 2 * .pi * (1.0 / CGFloat(wedges.count))
let shape = CAShapeLayer()
let path: UIBezierPath = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addLine(to: viewCenter)
path.close()
shape.path = path.cgPath
shape.fillColor = wedges[i].color.cgColor
shape.strokeColor = UIColor.black.cgColor
self.layer.addSublayer(shape)
let textLayer = CATextLayer()
textLayer.font = textLayerFont
textLayer.fontSize = fontHeight
let string = wedges[i].label
textLayer.string = string
textLayer.foregroundColor = UIColor.white.cgColor
textLayer.backgroundColor = UIColor.black.cgColor
textLayer.isWrapped = true
textLayer.alignmentMode = CATextLayerAlignmentMode.center
textLayer.contentsScale = UIScreen.main.scale
let bisectAngle = startAngle + ((endAngle - startAngle) * 0.5)
let p = CGPoint.pointOnCircle(center: viewCenter, radius: halfRadius, angle: bisectAngle)
var textLayerframe = CGRect(x: 0, y: 0, width: labelMaxWidth, height: 0)
let h = string.getLableHeight(labelMaxWidth, usingFont: textLayerFont)
textLayerframe.size.height = h
textLayerframe.origin.x = p.x - (textLayerframe.size.width * 0.5)
textLayerframe.origin.y = p.y - (textLayerframe.size.height * 0.5)
textLayer.frame = textLayerframe
self.layer.addSublayer(textLayer)
textLayer.transform = CATransform3DMakeRotation(bisectAngle, 0.0, 0.0, 1.0)
// uncomment this block to show the dashed-lines
/*
let biLayer = CAShapeLayer()
let dash = UIBezierPath()
dash.move(to: viewCenter)
dash.addLine(to: p)
biLayer.strokeColor = UIColor.yellow.cgColor
biLayer.lineDashPattern = [4, 4]
biLayer.path = dash.cgPath
self.layer.addSublayer(biLayer)
*/
startAngle = endAngle
}
// uncomment this block to show the half-radius circle
/*
let tempLayer: CAShapeLayer = CAShapeLayer()
tempLayer.path = UIBezierPath(ovalIn: bounds.insetBy(dx: outerRadius - halfRadius, dy: outerRadius - halfRadius)).cgPath
tempLayer.fillColor = UIColor.clear.cgColor
tempLayer.strokeColor = UIColor.green.cgColor
tempLayer.lineWidth = 1.0
self.layer.addSublayer(tempLayer)
*/
}
}
class WedgesWithRotatedLabelsViewController: UIViewController {
let wedgeView: WedgeView = WedgeView()
var wedges: [Wedge] = []
let colors: [UIColor] = [
UIColor(red: 1.00, green: 0.00, blue: 0.00, alpha: 1.0),
UIColor(red: 0.00, green: 0.50, blue: 0.00, alpha: 1.0),
UIColor(red: 0.00, green: 0.00, blue: 1.00, alpha: 1.0),
UIColor(red: 1.00, green: 0.50, blue: 0.50, alpha: 1.0),
UIColor(red: 0.00, green: 0.75, blue: 0.00, alpha: 1.0),
UIColor(red: 0.50, green: 0.50, blue: 1.00, alpha: 1.0),
]
let labels: [String] = [
"This is long text for Label 1",
"Label 2",
"Longer Label 3",
"Label 4",
"Label 5",
"Label 6",
]
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
for (c, s) in zip(colors, labels) {
wedges.append(Wedge(color: c, label: s))
}
wedgeView.wedges = wedges
view.addSubview(wedgeView)
wedgeView.translatesAutoresizingMaskIntoConstraints = false
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
wedgeView.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.8),
wedgeView.heightAnchor.constraint(equalTo: wedgeView.widthAnchor),
wedgeView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
wedgeView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
}
}
Couple of "helper" extensions used in the above code:
// get a random color
extension UIColor {
static var random: UIColor {
return UIColor(red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1),
alpha: 1.0)
}
}
// get the point on a circle at specific radian
extension CGPoint {
static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
return CGPoint(x: x, y: y)
}
}
// get height of word-wrapping string with max-width
extension String {
func getLableHeight(_ forWidth: CGFloat, usingFont: UIFont) -> CGFloat {
let constraintRect = CGSize(width: forWidth, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: usingFont], context: nil)
return ceil(boundingBox.height)
}
}

How do I get the coordinates from CAShapeLayer

So I am trying to make a progress bar. So I have made circular path, but I want the dot to be at the end of the progress bar, but how do I get the position of the dot to be att the end of the current progress?
private func simpleShape() {
let width: CGFloat = 10
createCircle()
//make circle transparant in middle
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.strokeColor = UIColor.blue.cgColor
progressLayer.lineCap = CAShapeLayerLineCap.round
progressLayer.lineWidth = width
progressLayer.strokeStart = 0
progressLayer.strokeEnd = 0
//unfilled
backLayer.lineWidth = width
backLayer.strokeColor = #colorLiteral(red: 0.1411764706, green: 0.1725490196, blue: 0.2431372549, alpha: 1).cgColor
backLayer.strokeEnd = 1
self.layer.addSublayer(gradientLayer)
}
private func createCircle() {
//create circle
let circle = UIView(frame: bounds)
circle.layoutIfNeeded()
let centerPoint = CGPoint (x: circle.bounds.width / 2, y: circle.bounds.width / 2)
let circleRadius: CGFloat = circle.bounds.width / 2 * 0.83
let circlePath = UIBezierPath(arcCenter: centerPoint, radius: circleRadius, startAngle: CGFloat(-0.475 * Double.pi), endAngle: CGFloat(1.525 * Double.pi), clockwise: true)
//add layers
progressLayer.path = circlePath.cgPath
backLayer.path = circlePath.cgPath
circle.layer.addSublayer(backLayer)
circle.layer.addSublayer(progressLayer)
addSubview(circle)
circle.layer.addSublayer(dotLayer)
}
let dotLayer = CAShapeLayer()
public func setProgress(_ progress: CGFloat) {
progressLayer.strokeEnd = CGFloat(progress)
if let progressEndpoint = progressLayer.path?.currentPoint {
dotLayer.position = progressEndpoint
}
}
This is what I'm getting
This is what I want
You’re going to have to calculate it yourself. So figure out the angle from the start and end angles for your arcs:
let angle = (endAngle - startAngle) * progress + startAngle
And then use basic trigonometry to determine where that point falls:
let point = CGPoint(x: centerPoint.x + radius * cos(angle),
y: centerPoint.y + radius * sin(angle))
dotLayer.position = point
By the way, I’d suggest separating the adding of the sublayers (which is part of the initial configuration process) from the updating paths (which is part of the view layout process, which may be called again if the frame of the view changes, constraints are applied, etc). Thus, perhaps:
#IBDesignable
class ProgressView: UIView {
var progress: CGFloat = 0 { didSet { updateProgress() } }
private var centerPoint: CGPoint = .zero
private var radius: CGFloat = 0
private let startAngle: CGFloat = -0.475 * .pi
private let endAngle: CGFloat = 1.525 * .pi
private let lineWidth: CGFloat = 10
private lazy var progressLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.lineCap = .round
shapeLayer.lineWidth = lineWidth
shapeLayer.strokeStart = 0
shapeLayer.strokeEnd = progress
return shapeLayer
}()
private lazy var backLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.lineWidth = lineWidth
shapeLayer.strokeColor = #colorLiteral(red: 0.1411764706, green: 0.1725490196, blue: 0.2431372549, alpha: 1).cgColor
return shapeLayer
}()
private lazy var dotLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.path = UIBezierPath(arcCenter: .zero, radius: lineWidth / 2 * 1.75, startAngle: 0, endAngle: 2 * .pi, clockwise: true).cgPath
shapeLayer.fillColor = UIColor.white.withAlphaComponent(0.5).cgColor
return shapeLayer
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSublayers()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
addSublayers()
}
override func layoutSubviews() {
super.layoutSubviews()
updatePaths()
updateProgress()
}
}
private extension ProgressView {
func addSublayers() {
layer.addSublayer(backLayer)
layer.addSublayer(progressLayer)
layer.addSublayer(dotLayer)
}
func updatePaths() {
centerPoint = CGPoint(x: bounds.midX, y: bounds.midY)
radius = min(bounds.width, bounds.height) / 2 * 0.83
let circlePath = UIBezierPath(arcCenter: centerPoint, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
progressLayer.path = circlePath.cgPath
backLayer.path = circlePath.cgPath
}
func updateProgress() {
progressLayer.strokeEnd = progress
let angle = (endAngle - startAngle) * progress + startAngle
let point = CGPoint(x: centerPoint.x + radius * cos(angle),
y: centerPoint.y + radius * sin(angle))
dotLayer.position = point
}
}
What you need is rotation Animation
let progressLayer = CAShapeLayer()
let backLayer = CAShapeLayer()
private func simpleShape() {
let width: CGFloat = 15
createCircle()
//make circle transparant in middle
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.strokeColor = #colorLiteral(red: 0.888897419, green: 0.5411034822, blue: 0.04008810222, alpha: 1)
progressLayer.lineCap = CAShapeLayerLineCap.round
progressLayer.lineWidth = width
progressLayer.strokeStart = 0
progressLayer.strokeEnd = 0
//unfilled
backLayer.lineWidth = width
backLayer.strokeColor = #colorLiteral(red: 0.1411764706, green: 0.1725490196, blue: 0.2431372549, alpha: 1)
backLayer.strokeEnd = 1
// self.layer.addSublayer(gradientLayer)
}
private func createCircle() {
//create circle
let circle = UIView(frame: bounds)
let centerPoint = CGPoint (x: circle.bounds.width / 2, y: circle.bounds.width / 2)
let circleRadius: CGFloat = circle.bounds.width / 2 * 0.83
let distance = circle.bounds.width / 2 * 0.17
let circlePath = UIBezierPath(arcCenter: centerPoint, radius: circleRadius, startAngle: CGFloat(-0.475 * Double.pi), endAngle: CGFloat(1.525 * Double.pi), clockwise: true)
//add layers
progressLayer.path = circlePath.cgPath
backLayer.path = circlePath.cgPath
circle.layer.addSublayer(backLayer)
circle.layer.addSublayer(progressLayer)
addSubview(circle)
let circleCenter = CGPoint(x:centerPoint.x - distance,y:centerPoint.y - circleRadius )
let dotCircle = UIBezierPath()
dotCircle.addArc(withCenter:circleCenter, radius: 3, startAngle: CGFloat(-90).deg2rad(), endAngle: CGFloat(270).deg2rad(), clockwise: true)
dotLayer.path = dotCircle.cgPath
dotLayer.position = CGPoint(x:centerPoint.x,y:centerPoint.y )
dotLayer.strokeColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.6496753961)
dotLayer.lineWidth = 10
dotLayer.fillColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
dotLayer.isHidden = true
circle.layer.addSublayer(dotLayer)
}
let dotLayer = CAShapeLayer()
public func setProgress(_ progress: CGFloat) {
print(progress)
// progressLayer.strokeEnd = progress
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.beginTime = CACurrentMediaTime() + 0.5;
animation.fromValue = 0
animation.toValue = progress
animation.duration = 2
animation.autoreverses = false
animation.repeatCount = .nan
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
progressLayer.add(animation, forKey: "line")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.dotLayer.isHidden = false
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
// rotateAnimation.beginTime = CACurrentMediaTime() + 0.5;
rotateAnimation.fromValue = (CGFloat( -90)).deg2rad()
rotateAnimation.toValue = (360*progress - 98).deg2rad()
rotateAnimation.duration = 2
rotateAnimation.fillMode = .forwards
rotateAnimation.isRemovedOnCompletion = false
self.dotLayer.add(rotateAnimation, forKey: nil)
}
}

UIBezierPath Custom lineCapStyle different on both Ends

What i want to achieve is
What i tried is to draw three arcs with different colors with lineCapStyle .rounded. My code for drawing these arcs are below
private func circularActivityPath(rect:CGRect, configuration:PathConfiguration)-> CGPath {
let center = CGPoint(x: rect.maxX / 2, y: rect.maxY / 2)
let longestSide = rect.height < rect.width ? rect.height : rect.width
let path = UIBezierPath(arcCenter: center, radius: (longestSide / 2) - (configuration.lineWidth / 2), startAngle: configuration.startAngle.deg2rad() , endAngle: configuration.endAngle.deg2rad(), clockwise: true)
path.lineCapStyle = .round
return path.cgPath
}
override func draw(_ rect: CGRect) {
let config1 = PathConfiguration(color: .red, lineWidth: lineWidth, startAngle: CGFloat(-90), endAngle: CGFloat(10), type: .track , shape: shape )
let trackLayer1 = CAShapeLayer()
trackLayer1.drawActivityCircles(in: rect, configuration: config1)
let config2 = PathConfiguration(color: .blue, lineWidth: lineWidth, startAngle: CGFloat(25), endAngle: CGFloat(80), type: .track , shape: shape )
let trackLayer2 = CAShapeLayer()
trackLayer2.drawActivityCircles(in: rect, configuration: config2)
let config3 = PathConfiguration(color: .green, lineWidth: lineWidth, startAngle: CGFloat(95), endAngle: CGFloat(255), type: .track , shape: shape )
let trackLayer3 = CAShapeLayer()
trackLayer3.drawActivityCircles(in: rect, configuration: config3)
self.layer.addSublayer(trackLayer2) // blue
self.layer.addSublayer(trackLayer3) // green
self.layer.addSublayer(trackLayer1) // red
}
where PathConfiguration is a struct
struct PathConfiguration {
let color: UIColor
let lineWidth: CGFloat
let startAngle: CGFloat
let endAngle: CGFloat
let type: TrackType
let shape: TrackShape
}
What i get is below with rounded caps on both sides... i want to achieve one rounded and one arc cap on respective ends. i will be very thankful to you if i get some pointers how i can get the same shape
See the below code to achieve this result.
import UIKit
// MARK: - Enums
public enum AnimationStyle: Int {
case animationFanAll
case animationFan
case animationFadeIn
case animationthreeD
case none
}
public enum PercentageStyle : Int {
case none
case inward
case outward
case over
}
open class Circular: UIView {
// MARK: - Public Properties
public var animationType: AnimationStyle {
get {
return _animationType
}
set(newValue) {
_animationType = newValue
setNeedsDisplay()
}
}
public var showPercentageStyle: PercentageStyle {
get {
return _showPercentageStyle
}
set(newValue) {
_showPercentageStyle = newValue
setNeedsDisplay()
}
}
public var lineWidth: CGFloat {
get {
return _lineWidth
}
set(newValue) {
_lineWidth = newValue
setNeedsDisplay()
}
}
// MARK:- Private Variable
private var _percentages: [Double]
private var _colors: [UIColor]
private var _lineWidth = CGFloat( 10.0)
private var _animationType: AnimationStyle
private var _showPercentageStyle: PercentageStyle
//MARK:- draw
override public func draw(_ rect: CGRect) {
var startAngle = -90.0
for i in 0..<_percentages.count {
let endAngle = startAngle + ( _percentages[i] * 3.6 ) - 4
let shapeLayer = self.addArac(with: _colors[i], in: rect, startAngle: startAngle, endAngle: endAngle)
showAnimationStyle(index: Double(i), shapeLayer: shapeLayer, startAngle: startAngle, endAngle: endAngle)
showPercentages(midAngel:startAngle + (endAngle - startAngle)/2, percentage: _percentages[i])
startAngle = (endAngle + 4 )
}
}
//MARK:- inializer
public init(percentages:[Double],colors:[UIColor],aimationType:AnimationStyle = .animationFanAll , showPercentageStyle: PercentageStyle = .none) {
self._percentages = percentages
self._colors = colors
self._animationType = aimationType
self._showPercentageStyle = showPercentageStyle
super.init(frame:CGRect.zero)
self.backgroundColor = .clear
self.clipsToBounds = false
}
required public init?(coder: NSCoder) {
// super.init(coder: coder)
fatalError("init(coder:) has not been implemented")
}
//MARK:- Animations Functions
private func showAnimationStyle(index:Double,shapeLayer:CAShapeLayer,startAngle:Double,endAngle:Double) {
switch _animationType {
case .animationFanAll:
maskEachLayerAnimation(startAngal: startAngle, endAngal: endAngle + 4 , shape: shapeLayer)
case .animationFan:
if Int(index) == _percentages.count - 1 {
maskAnimation()
}
case .animationFadeIn:
oppacityAnimation(index: index, shape: shapeLayer)
case .animationthreeD:
transformAnimation(index: index, shape: shapeLayer)
case .none:
break
}
}
private func oppacityAnimation(index:Double,shape:CAShapeLayer) {
shape.opacity = 0
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index)/2.5 ) {
shape.opacity = 1
let animation = CABasicAnimation(keyPath: "opacity")
animation.fromValue = 0
animation.toValue = 1
animation.duration = 1
shape.add(animation, forKey: nil)
}
}
private func transformAnimation(index:Double,shape:CAShapeLayer){
shape.opacity = 0
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index)/2.5 ) {
shape.opacity = 1
let animation = CABasicAnimation(keyPath: "transform")
animation.fromValue = CATransform3DMakeScale(0, 0, 1)
animation.toValue = CATransform3DIdentity
animation.duration = 1
shape.add(animation, forKey: nil)
}
}
private func maskEachLayerAnimation(startAngal:Double,endAngal:Double,shape:CAShapeLayer){
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.green.cgColor
shapeLayer.lineWidth = max( bounds.maxX,bounds.maxY)/5
shapeLayer.frame = bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let longestSide = max(bounds.height,bounds.width)
shapeLayer.path = UIBezierPath(arcCenter: center, radius: longestSide/2, startAngle: CGFloat(startAngal).deg2rad(), endAngle: CGFloat(endAngal ).deg2rad(), clockwise: true).cgPath
shapeLayer.strokeEnd = 0
shape.mask = shapeLayer
addAnimationToLayer(toLayer: shape, fromLayer: shapeLayer)
}
private func maskAnimation() {
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.white.cgColor
shapeLayer.lineWidth = max( bounds.maxX,bounds.maxY)/2
shapeLayer.frame = bounds
let path = UIBezierPath(arcCenter: CGPoint(x:bounds.midX,y:bounds.midY), radius:max( bounds.maxX/2,bounds.maxY/2), startAngle: CGFloat(-89.0).deg2rad(), endAngle: CGFloat( 270.0).deg2rad(), clockwise: true)
shapeLayer.path = path.cgPath
shapeLayer.strokeEnd = 0
self.layer.mask = shapeLayer
addAnimationToLayer(toLayer: self.layer, fromLayer: shapeLayer)
}
private func addAnimationToLayer(toLayer:CALayer , fromLayer:CAShapeLayer) {
CATransaction.begin()
CATransaction.setCompletionBlock {
toLayer.mask = nil
}
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.beginTime = CACurrentMediaTime() + 0.3
animation.fromValue = 0
animation.toValue = 1
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
animation.duration = 1
fromLayer.add(animation, forKey: "line")
CATransaction.commit()
}
//MARK:- show percentages
private func showPercentages(midAngel:Double, percentage:Double) {
guard let radius = getRadiusOfPercentage() else {
return
}
let center = CGPoint(x: bounds.maxX / 2, y: bounds.maxY / 2)
let x = center.x + (radius) * CGFloat(cos(CGFloat(midAngel).deg2rad()))
let y = center.y + (radius) * CGFloat(sin(CGFloat(midAngel).deg2rad()))
let percentageLabel = UILabel(frame: CGRect.zero)
percentageLabel.frame = CGRect.zero
percentageLabel.text = String(percentage)
percentageLabel.textColor = .black
percentageLabel.font = UIFont.boldSystemFont(ofSize: 12)
percentageLabel.sizeToFit()
percentageLabel.center = CGPoint(x:x,y:y)
addSubview(percentageLabel)
percentageLabel.alpha = 0
var delay = 1.5
if self.animationType == .none {
delay = 0
}
UIView.animate(withDuration: 0.5, delay: delay, options: .curveEaseOut, animations: {
percentageLabel.alpha = 1
})
}
private func getRadiusOfPercentage() -> CGFloat? {
let longestSide = max(bounds.height,bounds.width)
switch self.showPercentageStyle {
case .inward:
return longestSide/3 - lineWidth
case .over:
return longestSide/2 - lineWidth
case .outward:
return longestSide/2 + lineWidth + 5
case .none:
return nil
}
}
//MARK:- Drawing Code
private func addArac(with color:UIColor ,in rect:CGRect, startAngle:Double , endAngle:Double)-> CAShapeLayer {
let center = CGPoint(x: rect.maxX / 2, y: rect.maxY / 2)
let longestSide = max(rect.height,rect.width)
let lineWidth = CGFloat(self._lineWidth / 20)
let smallCircleRadious = (longestSide / (2 + lineWidth))
let startAngle = CGFloat(startAngle)
let endAngle = CGFloat(endAngle)
let outerRadious = (longestSide / 2)
let midPoint = (longestSide / (2 + lineWidth/2.7))
let path = UIBezierPath()
let x3 = center.x + (outerRadious) * CGFloat(cos(startAngle.deg2rad()))
let y3 = center.y + (outerRadious) * CGFloat(sin(startAngle.deg2rad()))
let x4 = center.x + (smallCircleRadious) * CGFloat(cos(startAngle.deg2rad()))
let y4 = center.y + (smallCircleRadious) * CGFloat(sin(startAngle.deg2rad()))
let x5 = center.x + (midPoint) * CGFloat(cos((startAngle + self._lineWidth * 0.5).deg2rad()))
let y5 = center.y + (midPoint) * CGFloat(sin((startAngle + self._lineWidth * 0.5).deg2rad()))
path.move(to: CGPoint(x:x4,y:y4))
path.addQuadCurve(to: CGPoint(x:x3,y:y3), controlPoint: CGPoint(x:x5,y:y5))
path.addArc(withCenter:center, radius:outerRadious, startAngle: startAngle.deg2rad() , endAngle: endAngle.deg2rad(), clockwise: true)
let x1 = center.x + (outerRadious) * CGFloat(cos(endAngle.deg2rad()))
let y1 = center.y + (outerRadious) * CGFloat(sin(endAngle.deg2rad()))
let x6 = center.x + (midPoint) * CGFloat(cos((endAngle + self._lineWidth * 0.6).deg2rad()))
let y6 = center.y + (midPoint) * CGFloat(sin((endAngle + self._lineWidth * 0.6).deg2rad()))
let x2 = center.x + (smallCircleRadious) * CGFloat(cos(endAngle.deg2rad()))
let y2 = center.y + (smallCircleRadious) * CGFloat(sin(endAngle.deg2rad()))
path.move(to: CGPoint(x:x1,y:y1))
path.addQuadCurve(to: CGPoint(x:x2,y:y2), controlPoint: CGPoint(x:x6,y:y6))
path.addArc(withCenter:center, radius: smallCircleRadious, startAngle: endAngle.deg2rad(), endAngle: startAngle.deg2rad(), clockwise: false)
let shape = CAShapeLayer()
shape.frame = bounds
shape.lineCap = .round
shape.fillColor = color.cgColor
shape.path = path.cgPath
layer.addSublayer(shape)
return shape
}
}
extension CGFloat {
func deg2rad() -> CGFloat {
return self * .pi / 180
}
}

Resources