Custom Controls for iOS are always offset - ios

I'm making a few custom controls for iOS. Each of these controls draws various layers in drawRect and every single one of the layers seems to be offset for some reason. They're suppose to be centered in the view. Here's an example of a pie chart control:
override public func drawRect(rect: CGRect) {
super.drawRect(rect)
_valueLayers.forEach { (sublayer : CALayer) -> () in
sublayer.removeFromSuperlayer()
}
_valueLayers.removeAll(keepCapacity: false)
let minDim = min(rect.size.width, rect.size.height)
let maxSliceWidthScale : CGFloat = self.sliceWidthScales.count > 0 ? min(self.sliceWidthScales.sort(>).first!, CGFloat(1)) : kDefaultSliceWidthScale
let maxSliceWidth = maxSliceWidthScale * minDim * 0.5
let center = rect.center()
let sum : Float = self.values.reduce(0, combine: { $0.floatValue + $1.floatValue }).floatValue
let maxRadius = (minDim - maxSliceWidth) * 0.5
var currentStartAngle = self.startAngle
for var x = 0; x < self.values.count; x++ {
let ratio = self.values[x].floatValue / sum
let angle = CGFloat(2.0 * M_PI * Double(ratio))
let endAngle = currentStartAngle + angle
let sliceWidthScale = self.sliceWidthScales.count > x ? self.sliceWidthScales[x] : kDefaultSliceWidthScale
let sliceWidth = sliceWidthScale * minDim * 0.5
var radius = maxRadius
switch self.sliceAlignment {
case .Inner:
radius -= 0.5 * (maxSliceWidth - sliceWidth)
case .Outer:
radius += 0.5 * (maxSliceWidth - sliceWidth)
case .Center:
radius = maxRadius
}
let arcLayer = CAShapeLayer()
arcLayer.frame = self.layer.frame
arcLayer.fillColor = UIColor.clearColor().CGColor
arcLayer.path = UIBezierPath(arcCenter: center, radius: radius, startAngle: currentStartAngle, endAngle: endAngle, clockwise: self.clockwise).CGPath
arcLayer.strokeColor = self.colors.count > x ? self.colors[x].CGColor : kDefaultSliceColor.CGColor
arcLayer.lineWidth = sliceWidth
_valueLayers.append(arcLayer)
self.layer.addSublayer(arcLayer)
currentStartAngle = endAngle
}
if self.showShadow {
self.layer.masksToBounds = false
self.layer.shadowColor = self.shadowColor.CGColor
self.layer.shadowOpacity = self.shadowOpacity
self.layer.shadowOffset = self.shadowOffset
self.layer.shadowRadius = self.shadowRadius
}
}
I cannot figure out why these controls are offset in the frame. Am I doing something wrong here?

The problem was this line of code:
arcLayer.frame = self.layer.frame
When the layer's origin wasn't (0,0), the sublayer was being offset.

Related

How to create Circle with step progress (gaps in it) and animate it?

I need to create a progressive with gaps in it and Animate the layers. I have achieved it. But the problem is it is starting (0) from Right centre. But the requirement is it should start from top centre. In image You can see that it is started from right side.
I have attached my code sample along with Image for your understanding. Can somebody help me where I'm doing wrong or how should I make it from top.
extension ViewController {
func sampleProgress() {
let totalSteps = 6
let frame = CGRect(x: 50, y: 50, width: 120, height: 120)
let circlePath = UIBezierPath(ovalIn: frame)
let gapSize: CGFloat = 0.0125
let segmentAngle: CGFloat = 0.167 // (1/totalSteps)
var startAngle = 0.0
let lineWidth = 8.0
for index in 0 ... totalSteps {
// Background layer
let backgroundLayer = CAShapeLayer()
backgroundLayer.strokeStart = startAngle
backgroundLayer.strokeEnd = backgroundLayer.strokeStart + segmentAngle - gapSize
backgroundLayer.path = circlePath.cgPath
backgroundLayer.name = String(index)
backgroundLayer.strokeColor = UIColor.lightGray.cgColor
backgroundLayer.lineWidth = lineWidth
backgroundLayer.lineCap = CAShapeLayerLineCap.butt
backgroundLayer.fillColor = UIColor.clear.cgColor
self.view.layer.addSublayer(backgroundLayer)
// Foreground layer
let foregroundLayer = CAShapeLayer()
foregroundLayer.strokeStart = startAngle
foregroundLayer.strokeEnd = backgroundLayer.strokeStart + segmentAngle - gapSize
foregroundLayer.isHidden = true
foregroundLayer.name = String(index) + String(index)
foregroundLayer.path = circlePath.cgPath
foregroundLayer.strokeColor = UIColor.green.cgColor
foregroundLayer.lineWidth = lineWidth
foregroundLayer.lineCap = CAShapeLayerLineCap.butt
foregroundLayer.fillColor = UIColor.clear.cgColor
self.view.layer.addSublayer(foregroundLayer)
print("Start angle: \(startAngle)")
startAngle = startAngle + segmentAngle
}
}
func animateLayer(isAnimate: Bool, stepsToAnimate: Int) {
let segmentAngle: CGFloat = (360 * 0.166) / 360
let gapSize: CGFloat = 0.0125
var startAngle = 0.0
for index in 0 ... stepsToAnimate {
if let foregroundLayers = self.view.layer.sublayers {
for animateLayer in foregroundLayers {
if animateLayer.name == String(index) + String(index) {
if index == stepsToAnimate && isAnimate {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = startAngle
animation.toValue = startAngle + segmentAngle - gapSize
animation.duration = 1.0
animateLayer.add(animation, forKey: "foregroundAnimation")
animateLayer.isHidden = false
} else {
animateLayer.isHidden = false
}
startAngle = startAngle + segmentAngle
}
}
}
}
}
}
You can "move the start" to the top by rotating the layer(s) minus 90-degrees:
let tr = CATransform3DMakeRotation(-(.pi * 0.5), 0, 0, 1)
I would assume this would be wrapped into a UIView subclass, but to get your example (adding sublayers to the main view's layer) to work right, we'll want to use a Zero-based origin for the path rect:
// use 0,0 for the origin of the PATH frame
let frame = CGRect(x: 0, y: 0, width: 120, height: 120)
let circlePath = UIBezierPath(ovalIn: frame)
and then an offset rect for the position:
let layerFrame = frame.offsetBy(dx: 50, dy: 50)
and we set the .anchorPoint of the layers to the center of that rect -- so it will rotate around its center:
// set the layer's frame
backgroundLayer.frame = layerFrame
// set the layer's anchor point
backgroundLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
// apply the rotation transform
backgroundLayer.transform = tr
// set the layer's frame
foregroundLayer.frame = layerFrame
// set the layer's anchor point
foregroundLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
// apply the rotation transform
foregroundLayer.transform = tr
So, slight modifications to your code:
extension ViewController {
func sampleProgress() {
let totalSteps = 6
// use 0,0 for the origin of the PATH frame
let frame = CGRect(x: 0, y: 0, width: 120, height: 120)
let circlePath = UIBezierPath(ovalIn: frame)
// use this for the POSITION of the path
let layerFrame = frame.offsetBy(dx: 50, dy: 50)
let gapSize: CGFloat = 0.0125
let segmentAngle: CGFloat = 0.167 // (1/totalSteps)
var startAngle = 0.0
let lineWidth = 8.0
// we want to rotate the layer by -90 degrees
let tr = CATransform3DMakeRotation(-(.pi * 0.5), 0, 0, 1)
for index in 0 ... totalSteps {
// Background layer
let backgroundLayer = CAShapeLayer()
backgroundLayer.strokeStart = startAngle
backgroundLayer.strokeEnd = backgroundLayer.strokeStart + segmentAngle - gapSize
backgroundLayer.path = circlePath.cgPath
backgroundLayer.name = String(index)
backgroundLayer.strokeColor = UIColor.lightGray.cgColor
backgroundLayer.lineWidth = lineWidth
backgroundLayer.lineCap = CAShapeLayerLineCap.butt
backgroundLayer.fillColor = UIColor.clear.cgColor
self.view.layer.addSublayer(backgroundLayer)
// set the layer's frame
backgroundLayer.frame = layerFrame
// set the layer's anchor point
backgroundLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
// apply the rotation transform
backgroundLayer.transform = tr
// Foreground layer
let foregroundLayer = CAShapeLayer()
foregroundLayer.strokeStart = startAngle
foregroundLayer.strokeEnd = backgroundLayer.strokeStart + segmentAngle - gapSize
foregroundLayer.isHidden = true
foregroundLayer.name = String(index) + String(index)
foregroundLayer.path = circlePath.cgPath
foregroundLayer.strokeColor = UIColor.green.cgColor
foregroundLayer.lineWidth = lineWidth
foregroundLayer.lineCap = CAShapeLayerLineCap.butt
foregroundLayer.fillColor = UIColor.clear.cgColor
self.view.layer.addSublayer(foregroundLayer)
// set the layer's frame
foregroundLayer.frame = layerFrame
// set the layer's anchor point
foregroundLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
// apply the rotation transform
foregroundLayer.transform = tr
print("Start angle: \(startAngle)")
startAngle = startAngle + segmentAngle
}
}
func animateLayer(isAnimate: Bool, stepsToAnimate: Int) {
let segmentAngle: CGFloat = (360 * 0.166) / 360
let gapSize: CGFloat = 0.0125
var startAngle = 0.0
for index in 0 ... stepsToAnimate {
if let foregroundLayers = self.view.layer.sublayers {
for animateLayer in foregroundLayers {
if animateLayer.name == String(index) + String(index) {
if index == stepsToAnimate && isAnimate {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = startAngle
animation.toValue = startAngle + segmentAngle - gapSize
animation.duration = 1.0
animateLayer.add(animation, forKey: "foregroundAnimation")
animateLayer.isHidden = false
} else {
animateLayer.isHidden = false
}
startAngle = startAngle + segmentAngle
}
}
}
}
}
}
and an example controller - each tap anywhere animates the next step:
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
sampleProgress()
}
var p: Int = 0
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
animateLayer(isAnimate: true, stepsToAnimate: p)
p += 1
}
}

How to provide maintain spacing between different CALayers

Heading ##I'm trying to learn the charts and having trouble
Adding consistent space between the slices.
Start the animation in sequence.
The reason, I didn't want the separator as a separate arch is to have both the edges rounded. Adding a separator as another layer overlaps the rounded corners.
Any help or pointers is highly appreciated.
import UIKit
import PlaygroundSupport
var str = "Hello, playground"
struct dataItem {
var color: UIColor
var percentage: CGFloat
}
typealias pieAngle = (start: CGFloat, end: CGFloat, color: UIColor)
let pieDataToDisplay = [dataItem(color: .red, percentage: 10),
dataItem(color: .blue, percentage: 20),
dataItem(color: .green, percentage: 25),
dataItem(color: .yellow, percentage: 25),
dataItem(color: .orange, percentage: 10)]
class USBCircleChart: UIView {
private var piesToDisplay: [dataItem] = [] { didSet { setNeedsLayout() } }
private var seperatorSpace: Double = 2.0 { didSet { setNeedsLayout() } }
func fillDataForChart(with items: [dataItem] ) {
self.piesToDisplay.append(contentsOf: items)
print("getting data \(self.piesToDisplay)")
layoutIfNeeded()
}
override func layoutSubviews() {
super.layoutSubviews()
guard piesToDisplay.count > 0 else { return }
print("laying out data")
let angles = calcualteStartAndEndAngle(items: piesToDisplay)
for i in angles {
var dataItem = i
addSpace(data: &dataItem)
addShapeToCircle(data: dataItem)
}
}
func addSpace(data:inout pieAngle) -> pieAngle {
// If space is not added, then its collated at the end, we have to scatter it between each item.
//data.end -= CGFloat(seperatorSpace)
return data
}
func addShapeToCircle(data : pieAngle, percent: CGFloat) {
let center = CGPoint(x: bounds.origin.x + bounds.size.width / 2, y: bounds.origin.y + bounds.size.height / 2)
var shapeLayer = CAShapeLayer()
// radians = degrees * pi / 180
// x*2 + y*2 = r*2
//cos teta = x/r --> x = r * cos teta
// sinn teta = y/ r --> y = r * sin teta
// let x = 100 * cos(data.start)
// let y = 100 * sin(data.end)
let radius = (bounds.origin.x + bounds.size.width / 2 - (sliceThickness)) / 2
//This is the circle path drawn.
let circularPath = UIBezierPath(arcCenter: .zero, radius: self.frame.width / 2, startAngle: data.start, endAngle: data.end, clockwise: true) //2*CGFloat.pi
shapeLayer.path = circularPath.cgPath
//Provide a bounding box for the shape layer to handle events
//Removing the below line works but will not handle touch events :(
shapeLayer.bounds = circularPath.cgPath.boundingBox
//Start the angle from anyplace you need { + - of Pi} // {0, 0.5 pi, 1 pi, 1.5pi}
// shapeLayer.transform = CATransform3DMakeRotation(-CGFloat.pi / 2 , 0, 0, 1)
// color of the stroke
shapeLayer.strokeColor = data.color.cgColor
//Width of stoke
shapeLayer.lineWidth = sliceThickness
//Starts from the center of the view
shapeLayer.position = center
//To provide a rounded cap on the stroke
shapeLayer.lineCap = .round
//Fills the entire circle with this color
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeEnd = 0
basicAnim(shapeLayer: &shapeLayer, percentage: percent)
layer.addSublayer(shapeLayer)
}
func basicAnim(shapeLayer: inout CAShapeLayer) {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = 10
//Forwards will hold the layer after completion
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.add(basicAnimation, forKey: "shapeLayerAniamtion")
}
// //Calucate percentage based on given values
// public func calculateAngle(percentageVal:Double)-> CGFloat {
// return CGFloat((percentageVal / 100) * 360)
// let val = CGFloat (percentageVal / 100.0)
// return val * 2 * CGFloat.pi
// }
private func calcualteStartAndEndAngle(items : [dataItem])-> [pieAngle] {
var angle: pieAngle
var angleToStart: CGFloat = 0.0
//Add the total separator space to the circle so we can accurately measure the start point with space.
var totalSeperatorSpace = Double(items.count) * separatorSpace
var totalSum = items.reduce(CGFloat(totalSeperatorSpace)) { return $0 + $1.percentage }
var angleList: [pieAngle] = []
for item in items {
//Find the end angle based on the percentage in the total circle
let endAngle = (item.percentage / totalSum * 2 * .pi) + angleToStart
angle.0 = angleToStart
angle.1 = endAngle
angle.2 = item.color
angleList.append(angle)
angleToStart = endAngle
//print(angle)
}
return angleList
}
}
let container = UIView()
container.frame.size = CGSize(width: 360, height: 360)
container.backgroundColor = .white
PlaygroundPage.current.liveView = container
PlaygroundPage.current.needsIndefiniteExecution = true
let m = USBCircleChart(frame: CGRect(x: 0, y: 0, width: 215, height: 215))
m.center = CGPoint(x: container.bounds.size.width / 2, y: container.bounds.size.height / 2)
m.fillDataForChart(with: pieDataToDisplay)
container.addSubview(m)
UPDATED :
Updated the code to include proper spacing irrespective of single/multiple items on the chart with equal distribution of total spacing, based on a suggestion from #jaferAli
Open Issue: Handling tap gesture on the layer so I can perform custom actions based on the category selected.
Screen 2
UPDATED CODE:
import UIKit
import PlaygroundSupport
var str = "Hello, playground"
struct dataItem {
var color: UIColor
var percentage: CGFloat
}
func hexStringToUIColor (hex:String) -> UIColor {
var cString:String = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if (cString.hasPrefix("#")) {
cString.remove(at: cString.startIndex)
}
if ((cString.count) != 6) {
return UIColor.gray
}
var rgbValue:UInt64 = 0
Scanner(string: cString).scanHexInt64(&rgbValue)
return UIColor(
red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
alpha: CGFloat(1.0)
)
}
typealias pieAngle = (start: CGFloat, end: CGFloat, color: UIColor, percent: CGFloat)
let pieDataToDisplay = [
dataItem(color: hexStringToUIColor(hex: "#E61628"), percentage: 10),
dataItem(color: hexStringToUIColor(hex: "#50B7FB"), percentage: 20),
dataItem(color: hexStringToUIColor(hex: "#38BE72"), percentage: 25),
dataItem(color: hexStringToUIColor(hex: "#FFAA4C"), percentage: 15),
dataItem(color: hexStringToUIColor(hex: "#B6BE33"), percentage: 30)
]
let pieDataToDisplayWhite = [dataItem(color: .white, percentage: 10),
dataItem(color: .white, percentage: 20),
dataItem(color: .white, percentage: 25),
dataItem(color: .white, percentage: 25),
dataItem(color: .orange, percentage: 10)]
class USBCircleChart: UIView {
private var piesToDisplay: [dataItem] = [] { didSet { setNeedsLayout() } }
private var seperatorSpace: Double = 5.0 { didSet { setNeedsLayout() } }
private var sliceThickness: CGFloat = 10.0 { didSet { setNeedsLayout() } }
func fillDataForChart(with items: [dataItem] ) {
self.piesToDisplay.append(contentsOf: items)
print("getting data \(self.piesToDisplay)")
layoutIfNeeded()
}
override func layoutSubviews() {
super.layoutSubviews()
guard piesToDisplay.count > 0 else { return }
print("laying out data")
let angles = calcualteStartAndEndAngle(items: piesToDisplay)
for i in angles {
var dataItem = i
addSpace(data: &dataItem)
addShapeToCircle(data: dataItem, percent:i.percent)
}
}
func addSpace(data:inout pieAngle) -> pieAngle {
// If space is not added, then its collated at the end, we have to scatter it between each item.
//data.end -= CGFloat(seperatorSpace)
return data
}
func addShapeToCircle(data : pieAngle, percent: CGFloat) {
let center = CGPoint(x: bounds.origin.x + bounds.size.width / 2, y: bounds.origin.y + bounds.size.height / 2)
var shapeLayer = CAShapeLayer()
// radians = degrees * pi / 180
// x*2 + y*2 = r*2
//cos teta = x/r --> x = r * cos teta
// sinn teta = y/ r --> y = r * sin teta
// let x = 100 * cos(data.start)
// let y = 100 * sin(data.end)
let radius = (bounds.origin.x + bounds.size.width / 2 - (sliceThickness)) / 2
//This is the circle path drawn.
let circularPath = UIBezierPath(arcCenter: .zero, radius: self.frame.width / 2, startAngle: data.start, endAngle: data.end, clockwise: true) //2*CGFloat.pi
shapeLayer.path = circularPath.cgPath
//Provide a bounding box for the shape layer to handle events
//shapeLayer.bounds = circularPath.cgPath.boundingBox
//Start the angle from anyplace you need { + - of Pi} // {0, 0.5 pi, 1 pi, 1.5pi}
// shapeLayer.transform = CATransform3DMakeRotation(-CGFloat.pi / 2 , 0, 0, 1)
// color of the stroke
shapeLayer.strokeColor = data.color.cgColor
//Width of stoke
shapeLayer.lineWidth = sliceThickness
//Starts from the center of the view
shapeLayer.position = center
//To provide a rounded cap on the stroke
shapeLayer.lineCap = .round
//Fills the entire circle with this color
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeEnd = 0
basicAnim(shapeLayer: &shapeLayer, percentage: percent)
layer.addSublayer(shapeLayer)
}
func basicAnim(shapeLayer: inout CAShapeLayer) {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = 10
//Forwards will hold the layer after completion
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.add(basicAnimation, forKey: "shapeLayerAniamtion")
}
private var timeOffset:CFTimeInterval = 0
func basicAnim(shapeLayer: inout CAShapeLayer, percentage:CGFloat) {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = CFTimeInterval(percentage / 50)
basicAnimation.beginTime = CACurrentMediaTime() + timeOffset
print("timeOffset:\(timeOffset),")
//Forwards will hold the layer after completion
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.add(basicAnimation, forKey: "shapeLayerAniamtion")
timeOffset += CFTimeInterval(percentage / 50)
}
private func calcualteStartAndEndAngle(items : [dataItem])-> [pieAngle] {
var angle: pieAngle
var angleToStart: CGFloat = 0.0
//Add the total separator space to the circle so we can accurately measure the start point with space.
let totalSeperatorSpace = Double(items.count)
let totalSum = items.reduce(CGFloat(seperatorSpace)) { return $0 + $1.percentage }
let spacing = CGFloat(seperatorSpace ) / CGFloat (totalSum)
print("total Sum:\(spacing)")
var angleList: [pieAngle] = []
for item in items {
//Find the end angle based on the percentage in the total circle
let endAngle = (item.percentage / totalSum * 2 * CGFloat.pi) + angleToStart
print("start:\(angleToStart) end:\(endAngle)")
angle.0 = angleToStart + spacing
angle.1 = endAngle - spacing
angle.2 = item.color
angle.3 = item.percentage
angleList.append(angle)
angleToStart = endAngle + spacing
//print(angle)
}
return angleList
}
}
extension USBCircleChart {
#objc func handleTap() {
print("getting tap action")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first
guard let loca = touch?.location(in: self) else { return }
let point = self.convert(loca, from: nil)
guard let sublayers = self.layer.sublayers as? [CAShapeLayer] else { return }
for layer in sublayers {
print("checking paths \(point) \(loca) \(layer.path) \n")
if let path = layer.path, path.contains(point) {
print(layer)
}
}
}
}
let container = UIView()
container.frame.size = CGSize(width: 300, height: 300)
container.backgroundColor = .white
PlaygroundPage.current.liveView = container
PlaygroundPage.current.needsIndefiniteExecution = true
let m = USBCircleChart(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
//m.center = CGPoint(x: container.bounds.size.width / 2, y: container.bounds.size.height / 2)
m.center = container.center
m.fillDataForChart(with: pieDataToDisplay)
container.addSubview(m)
You need to calculate spacing and then add and Subtract it from start and end angle .. so update your calcualteStartAndEndAngle Method with this one
private func calcualteStartAndEndAngle(items : [dataItem])-> [pieAngle] {
var angle: pieAngle
var angleToStart: CGFloat = 0.0
//Add the total separator space to the circle so we can accurately measure the start point with space.
let totalSeperatorSpace = Double(items.count)
let totalSum = items.reduce(CGFloat(totalSeperatorSpace)) { return $0 + $1.percentage }
let spacing = CGFloat( totalSeperatorSpace + 1 ) / totalSum
print("total Sum:\(spacing)")
var angleList: [pieAngle] = []
for item in items {
//Find the end angle based on the percentage in the total circle
let endAngle = (item.percentage / totalSum * 2 * .pi) + angleToStart
print("start:\(angleToStart) end:\(endAngle)")
angle.0 = angleToStart + spacing
angle.1 = endAngle - spacing
angle.2 = item.color
angleList.append(angle)
angleToStart = endAngle + spacing
//print(angle)
}
return angleList
}
It will Result this Animation
and if you want linear Animation then change your animation method
private var timeOffset:CFTimeInterval = 0
func basicAnim(shapeLayer: inout CAShapeLayer, percentage:CGFloat) {
let basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
basicAnimation.toValue = 1
basicAnimation.duration = CFTimeInterval(percentage)
basicAnimation.beginTime = CACurrentMediaTime() + timeOffset
print("timeOffset:\(timeOffset),")
//Forwards will hold the layer after completion
basicAnimation.fillMode = .forwards
basicAnimation.isRemovedOnCompletion = false
shapeLayer.add(basicAnimation, forKey: "shapeLayerAniamtion")
timeOffset += CFTimeInterval(percentage)
}
And if you want to learn more you can see this framework RingPieChart
The problem was
1. Re - Calculate the percentages by keeping the spacing percentage.
that is,
//This is to recalculate the percentage by adding the total spacing percentage.
/// Example : The percenatge of each category is recalculated - for instance , lets assume Apple - 60 %,
/// Android - 40 %, now we add Samsung as 10 %, which equates to 110%, To correct this
/// Apple 60 * (100- Samsung) / 100 = 54 %, Android = 36 %, which totals to 100 %.
///
/// - Parameter buffer: total spacing between the splices.
func updatedPercentage(with buffer: CGFloat ) -> CGFloat {
return percentage * (100 - buffer) / 100
}
Once this is done, the total categories + spacings will equate to 100 %.
The only problem left is, for very smaller percentage categories (lesser than spacing percentage), the start angle will be greater than end angle. This is because we are subtracting the spacing from end angle.
there are two options to correct,
a. flip the angles.
if angle.start > angle.end {
let start = angle.start
angle.start = angle.end
angle.end = start
}
b. draw it anti clock wise in Beizer path , only for that slice.
let circularPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: angle.start, endAngle: angle.end, clockwise: **angle.start < angle.end**)
this should solve all the problems, i will upload my findings on a GIT repo and publish the link here.

How to find the endangle for beizerpath for a curve drawn in swift?

I have drawn a beizer path with start & end angle which create a whole circle
circle = UIView(frame: CGRect(x: 100, y: 100, width: 200, height:200))
circle.layoutIfNeeded()
self.view.addSubview(circle)
let centerPoint = CGPoint (x: circle.bounds.width / 2, y: circle.bounds.width / 2)
let circleRadius : CGFloat = circle.bounds.width / 2 * 0.83
circlePath = UIBezierPath(arcCenter: centerPoint, radius: circleRadius, startAngle: CGFloat(-0.5 * M_PI), endAngle: CGFloat(1.5 * M_PI), clockwise: true)
After that i draw a Curve on that circle & does not fill it full to the circle.
progressCircle = CAShapeLayer ()
progressCircle.path = circlePath?.cgPath
progressCircle.strokeColor = UIColor.red.cgColor
progressCircle.fillColor = UIColor.clear.cgColor
progressCircle.lineWidth = 4.0
progressCircle.strokeStart = 0
progressCircle.strokeEnd = 0.7
circle.layer.addSublayer(progressCircle)
After that i want to add image at then end of curve so to do that i created another biezer path.But here problem is it should be ended as the curve end.So i am not able to find the end angle for the beizer path based on the endStroke of CASHAPELAYER. Please tell me how can i find the end angle of new beizer path based on the curve end point.
let centerPoint = CGPoint (x: circle.bounds.width / 2, y: circle.bounds.width / 2)
let circleRadius : CGFloat = circle.bounds.width / 2 * 0.83
let arcStartAngle: Double = 0.0
let rotationDiff = 360 - abs((0.0 - 270))
let startAngle: CGFloat = -1.57
let endAngle: CGFloat = CGFloat(Double.pi * 0.7)
let bpath = UIBezierPath(arcCenter: centerPoint, radius: circleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
class ViewController: UIViewController {
var array = [[CGFloat:CGFloat]]()
var midX : CGFloat = CGFloat()
var midY : CGFloat = CGFloat()
let radius : CGFloat = 100.0
override func viewDidLoad() {
super.viewDidLoad()
midX = self.view.frame.midX
midY = self.view.frame.midY
let path = UIBezierPath()
path.addArc(withCenter: CGPoint.init(x: midX, y: midY), radius: radius, startAngle: 0, endAngle:2 * .pi, clockwise: true)
let shape = CAShapeLayer()
shape.path = path.cgPath
shape.lineWidth = 5.0
shape.fillColor = UIColor.clear.cgColor
shape.strokeColor = UIColor.lightGray.cgColor
self.view.layer.addSublayer(shape)
var temp090x = [CGFloat:CGFloat]()
var temp90180x = [CGFloat:CGFloat]()
var temp180270x = [CGFloat:CGFloat]()
var temp270360x = [CGFloat:CGFloat]()
for i in 1...360 {
let path3 = UIBezierPath()
path3.move(to: CGPoint.init(x: midX, y: midY))
path3.addLine(to:CGPoint.init(x: midX + radius * sin(CGFloat(i)), y:midY + radius*cos(CGFloat(i))))
//design path in layer
if midX + radius * sin(CGFloat(i)) > midX && midY + radius*cos(CGFloat(i)) < midY {
temp090x[midX + radius * sin(CGFloat(i))] = midY + radius*cos(CGFloat(i))
}else if midX + radius * sin(CGFloat(i)) > midX && midY + radius*cos(CGFloat(i)) > midY {
temp90180x[midX + radius * sin(CGFloat(i))] = midY + radius*cos(CGFloat(i))
}else if midX + radius * sin(CGFloat(i)) < midX && midY + radius*cos(CGFloat(i)) > midY {
temp180270x[midX + radius * sin(CGFloat(i))] = midY + radius*cos(CGFloat(i))
}else if midX + radius * sin(CGFloat(i)) < midX && midY + radius*cos(CGFloat(i)) < midY {
temp270360x[midX + radius * sin(CGFloat(i))] = midY + radius*cos(CGFloat(i))
}
let shapeLayer3 = CAShapeLayer()
shapeLayer3.path = path3.cgPath
shapeLayer3.strokeColor = UIColor.clear.cgColor
shapeLayer3.lineWidth = 1.0
self.view.layer.addSublayer(shapeLayer3)
}
let tempSorted090x = BubbleAsceSort(array: temp090x)
let tempSorted90180x = BubbleDescSort(array: temp90180x)
let tempSorted180270x = BubbleDescSort(array: temp180270x)
let tempSorted270360x = BubbleAsceSort(array: temp270360x)
for item in tempSorted090x {
array.append(item)
}
for item in tempSorted90180x {
array.append(item)
}
for item in tempSorted180270x {
array.append(item)
}
for item in tempSorted270360x {
array.append(item)
}
let lbl = UILabel()
lbl.frame = CGRect.init(x: 0, y: 0, width: 30, height: 30)
lbl.backgroundColor = UIColor.black
lbl.layer.cornerRadius = 15
for i in 1...180 {
let xpos = Array(array[i-1].keys)[0]
let ypos = Array(array[i-1].values)[0]
let xpos1 = Array(array[i].keys)[0]
let ypos1 = Array(array[i].values)[0]
let path3 = UIBezierPath()
path3.move(to: CGPoint.init(x: xpos, y: ypos))
path3.addLine(to:CGPoint.init(x: xpos1, y:ypos1))
let shapeLayer3 = CAShapeLayer()
shapeLayer3.path = path3.cgPath
shapeLayer3.strokeColor = UIColor.red.cgColor
shapeLayer3.lineWidth = 5.0
self.view.layer.addSublayer(shapeLayer3)
}
lbl.center = CGPoint.init(x: Array(array[180].keys)[0], y: Array(array[180].values)[0])
self.view.addSubview(lbl)
}
func BubbleDescSort(array : [CGFloat:CGFloat]) -> [[CGFloat:CGFloat]] {
var sortedArray = Array(array.keys)
var sortedvalueArray = Array(array.values)
var sortedAboveIndex = sortedArray.count-1 // Assume all values are not in order
repeat {
var lastSwapIndex = 0
for i in 1...sortedAboveIndex{
if (sortedArray[i] as AnyObject) as! CGFloat > (sortedArray[i - 1] as AnyObject) as! CGFloat {
sortedArray.swapAt(i, i-1)
sortedvalueArray.swapAt(i, i-1)
lastSwapIndex = i
}
}
sortedAboveIndex = lastSwapIndex
} while (sortedAboveIndex != 0)
var index = 0
var arr = [[CGFloat:CGFloat]]()
for item in sortedArray {
var dic = [CGFloat:CGFloat]()
dic[item] = sortedvalueArray[index]
arr.append(dic)
index += 1
}
return arr
}
func BubbleAsceSort(array : [CGFloat:CGFloat]) -> [[CGFloat:CGFloat]] {
var sortedArray = Array(array.keys)
var sortedvalueArray = Array(array.values)
var sortedAboveIndex = sortedArray.count-1 // Assume all values are not in order
repeat {
var lastSwapIndex = 0
for i in 1...sortedAboveIndex{
if (sortedArray[i - 1] as AnyObject) as! CGFloat > (sortedArray[i] as AnyObject) as! CGFloat {
sortedArray.swapAt(i, i-1)
sortedvalueArray.swapAt(i, i-1)
lastSwapIndex = i
}
}
sortedAboveIndex = lastSwapIndex
} while (sortedAboveIndex != 0)
var index = 0
var arr = [[CGFloat:CGFloat]]()
for item in sortedArray {
var dic = [CGFloat:CGFloat]()
dic[item] = sortedvalueArray[index]
arr.append(dic)
index += 1
}
return arr
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}
In this code you need to Set Progress Percentage based on the 360 degree. in the upper limit of for loop and Round lbl Center point x and y pos. Here is need only 180 replace to your progress percentage

Swift 3: Animate color fill of arc added to UIBezierPath

I wish to animate the color fill of a section of a pie chart. I create the pie chart by creating a UIBezierPath() for each piece of the pie and then use the addArc method to specify the size/constraints of the arc. To animate the pie chart segment, I want the color fill of the arc to animate from the center of the circle to the radius end. However, I am having trouble. I heard the strokeEnd keyPath animated from 0 to 1 should work, but there is no animation happening on the arcs (the arcs are just appearing at app launch).
let rad = 2 * Double.pi
let pieCenter: CGPoint = CGPoint(x: frame.width / 2, y: frame.height / 2)
var start: Double = 0
for i in 0...data.count - 1 {
let size: Double = Double(data[i])! / 100 // the percentege of the circle that the given arc will take
let end: Double = start + (size * rad)
let path = UIBezierPath()
path.move(to: pieCenter)
path.addArc(withCenter: pieCenter, radius: frame.width / 3, startAngle: CGFloat(start), endAngle: CGFloat(end), clockwise: true)
start += size * rad
let lineLayer = CAShapeLayer()
lineLayer.bounds = self.bounds
lineLayer.position = self.layer.position
lineLayer.path = path.cgPath
lineLayer.strokeColor = UIColor.white.cgColor
lineLayer.fillColor = colors[i]
lineLayer.lineWidth = 0
self.layer.addSublayer(lineLayer)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeForwards
animation.fromValue = pieCenter
animation.toValue = frame.width / 3 // radius
animation.duration = 2.5
lineLayer.add(animation, forKey: nil)
}
I've seen a solution to a similar problem here, but it does not work for the individual arcs.
When you animate strokeEnd, that animates the stroke around the path, but not the fill of the path.
If you're looking for just any animation of the fill, easy options include animating the fillColor key path from UIColor.clear.cgColor to the final color. Or animate the opacity key path from 0 to 1.
func addPie(_ animated: Bool = true) {
shapeLayers.forEach { $0.removeFromSuperlayer() }
shapeLayers.removeAll()
guard let dataPoints = dataPoints else { return }
let center = pieCenter
let radius = pieRadius
var startAngle = -CGFloat.pi / 2
let sum = dataPoints.reduce(0.0) { $0 + $1.value }
for (index, dataPoint) in dataPoints.enumerated() {
let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
let shape = CAShapeLayer()
shape.fillColor = dataPoint.color.cgColor
shape.strokeColor = UIColor.black.cgColor
shape.lineWidth = lineWidth
shape.path = path.cgPath
layer.addSublayer(shape)
shapeLayers.append(shape)
shape.frame = bounds
if animated {
shape.opacity = 0
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count)) {
shape.opacity = 1
let animation = CABasicAnimation(keyPath: "opacity")
animation.fromValue = 0
animation.toValue = 1
animation.duration = 1
shape.add(animation, forKey: nil)
}
}
startAngle = endAngle
}
}
That yields:
The delaying of the animations give it a slightly more dynamic effect.
If you want to get fancy, you can play around with animations of transform of the entire CAShapeLayer. For example, you can scale the pie wedges:
func addPie(_ animated: Bool = true) {
shapeLayers.forEach { $0.removeFromSuperlayer() }
shapeLayers.removeAll()
guard let dataPoints = dataPoints else { return }
let center = pieCenter
let radius = pieRadius
var startAngle = -CGFloat.pi / 2
let sum = dataPoints.reduce(0.0) { $0 + $1.value }
for (index, dataPoint) in dataPoints.enumerated() {
let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
let shape = CAShapeLayer()
shape.fillColor = dataPoint.color.cgColor
shape.strokeColor = UIColor.black.cgColor
shape.lineWidth = lineWidth
shape.path = path.cgPath
layer.addSublayer(shape)
shapeLayers.append(shape)
shape.frame = bounds
if animated {
shape.opacity = 0
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count) + 1) {
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)
}
}
startAngle = endAngle
}
}
Yielding:
Or you can rotate the pie wedge shape layer about its center angle making it appear to angularly expand:
func addPie(_ animated: Bool = true) {
shapeLayers.forEach { $0.removeFromSuperlayer() }
shapeLayers.removeAll()
guard let dataPoints = dataPoints else { return }
let center = pieCenter
let radius = pieRadius
var startAngle = -CGFloat.pi / 2
let sum = dataPoints.reduce(0.0) { $0 + $1.value }
for (index, dataPoint) in dataPoints.enumerated() {
let endAngle = startAngle + CGFloat(dataPoint.value / sum) * 2 * .pi
let path = closedArc(at: center, with: radius, start: startAngle, end: endAngle)
let shape = CAShapeLayer()
shape.fillColor = dataPoint.color.cgColor
shape.strokeColor = UIColor.black.cgColor
shape.lineWidth = lineWidth
shape.path = path.cgPath
layer.addSublayer(shape)
shapeLayers.append(shape)
shape.frame = bounds
if animated {
shape.opacity = 0
let centerAngle = startAngle + CGFloat(dataPoint.value / sum) * .pi
let transform = CATransform3DMakeRotation(.pi / 2, cos(centerAngle), sin(centerAngle), 0)
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) / Double(dataPoints.count)) {
shape.opacity = 1
let animation = CABasicAnimation(keyPath: "transform")
animation.fromValue = transform
animation.toValue = CATransform3DIdentity
animation.duration = 1
shape.add(animation, forKey: nil)
}
}
startAngle = endAngle
}
}
That yields:
I'd encourage you to not get too lost in the details of my CAShapeLayer and my model, but rather focus on the CABasicAnimation and the various keyPath values we can animate.
It sounds like what you are after is a "clock wipe" effect that reveals your graph. If that's the case then there is a simpler way than creating a separate mask layer for each separate wedge of your pie chart. Instead, make each wedge of your graph a sublayer of a single layer, install a mask layer on that super-layer, and run a clock wipe animation on that super layer.
Here is a GIF illustrating a clock wipe animation of a static image:
I wrote a post explaining how to do it, and linking to a Github project demonstrating it:
How do you achieve a "clock wipe"/ radial wipe effect in iOS?
In order to ensure a smooth, clockwise animation of the pie chart, you must perform the following steps in order:
Create a new parent layer of type CAShapeLayer
In a loop each pie chart slice to the parent layer
In another loop, iterate through the sublayers (pie slices) of the parent layer and assign each sublayer a mask and animate that mask in the loop
Add the parent layer to the main layer: self.layer.addSublayer(parentLayer)
In a nutshell, the code will look like this:
// Code above this line adds the pie chart slices to the parentLayer
for layer in parentLayer.sublayers! {
// Code in this block creates and animates the same mask for each layer
}
Each animation applied to each pie slice will be a strokeEnd keypath animation from 0 to 1. When creating the mask, be sure its fillColor property is set to UIColor.clear.cgColor.
reduce the arc radius to half and make the line twice as thick as the radius
let path = closedArc(at: center, with: radius * 0.5, start: startAngle, end: endAngle)
lineLayer.lineWidth = radius
set the fillColor to clear
I may be very late to the party. But if anyone are looking for smooth circle Pie chart animation try this:-
In your ViewController class use below
class YourViewController: UIViewController {
#IBOutlet weak var myView: UIView!
let chartView = PieChartView()
override func viewDidLoad() {
super.viewDidLoad()
myView.addSubview(chartView)
chartView.translatesAutoresizingMaskIntoConstraints = false
chartView.leftAnchor.constraint(equalTo: myView.leftAnchor).isActive = true
chartView.rightAnchor.constraint(equalTo: myView.rightAnchor).isActive = true
chartView.topAnchor.constraint(equalTo: myView.topAnchor).isActive = true
chartView.bottomAnchor.constraint(equalTo: myView.bottomAnchor).isActive = true
// Do any additional setup after loading the view.
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let segments = [Segment(value: 70, color: .systemBlue), Segment(value: 40, color: .systemCyan), Segment(value: 5, color: .systemTeal), Segment(value: 4, color: .systemMint), Segment(value: 5, color: .systemPurple)]
chartView.segments = segments
}
}
Create below PieChartView class which will animate pieChart as well
class PieChartView: UIView {
/// An array of structs representing the segments of the pie chart
var pieSliceLayer = CAShapeLayer()
var count = 0
var startAngle = -CGFloat.pi * 0.5
var segments = [Segment]() {
didSet {
setNeedsDisplay() // re-draw view when the values get set
}
}
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false // when overriding drawRect, you must specify this to maintain transparency.
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func draw(_ rect: CGRect) {
addSlices()
}
func addSlices() {
guard count < segments.count else {return}
let radius = min(bounds.width, bounds.height) / 2.0 - 20
let center: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let valueCount = segments.reduce(0, {$0 + $1.value})
for value in segments {
let pieSliceLayer = CAShapeLayer()
pieSliceLayer.strokeColor = value.color.cgColor
pieSliceLayer.fillColor = UIColor.clear.cgColor
pieSliceLayer.lineWidth = radius
layer.addSublayer(pieSliceLayer)
let endAngle = CGFloat(value.value) / valueCount * CGFloat.pi * 2.0 + startAngle
pieSliceLayer.path = UIBezierPath(arcCenter: center, radius: radius/2, startAngle: startAngle, endAngle: endAngle, clockwise: true).cgPath
startAngle = endAngle
}
pieSliceLayer.strokeColor = UIColor.white.cgColor
pieSliceLayer.fillColor = UIColor.clear.cgColor
pieSliceLayer.lineWidth = radius
let startAngle: CGFloat = 3 * .pi / 2
let endAngle: CGFloat = -3 * .pi / 2
pieSliceLayer.path = UIBezierPath(arcCenter: center, radius: radius/2, startAngle: startAngle, endAngle: endAngle, clockwise: false).cgPath
pieSliceLayer.strokeEnd = 1
layer.addSublayer(pieSliceLayer)
startCircleAnimation()
}
private func startCircleAnimation() {
pieSliceLayer.strokeEnd = 0
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 1
animation.toValue = 0
animation.duration = 1.0
animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.42, 0.00, 0.09, 1.00)
pieSliceLayer.add(animation, forKey: nil)
}
}

shadow not visible on view with custom shape

on my imageview shadow is not visible after i changed the shape of it to hexagon here's how i'm changing imageView's shape :
extension UIView {
func makeHexagon(){
let lineWidth: CGFloat = 3
let path = UIBezierPath(roundedPolygonPathWithRect: self.bounds, lineWidth: lineWidth, sides: 6, cornerRadius: 1)
let mask = CAShapeLayer()
mask.path = path.cgPath
mask.lineWidth = lineWidth
mask.strokeColor = UIColor.clear.cgColor //controls the stroke width
mask.fillColor = UIColor.white.cgColor
self.layer.mask = mask
let border = CAShapeLayer()
border.path = path.cgPath
border.lineWidth = lineWidth
border.strokeColor = UIColor.black.cgColor
border.fillColor = UIColor.clear.cgColor
self.layer.addSublayer(border)
}
}
............................
extension UIBezierPath {
convenience init(roundedPolygonPathWithRect rect: CGRect, lineWidth: CGFloat, sides: NSInteger, cornerRadius: CGFloat) {
self.init()
let theta = CGFloat(2.0 * M_PI) / CGFloat(sides)
let offSet = CGFloat(cornerRadius) / CGFloat(tan(theta/2.0))
let squareWidth = min(rect.size.width, rect.size.height)
var length = squareWidth - lineWidth
if sides%4 != 0 {
length = length * CGFloat(cos(theta / 2.0)) + offSet/2.0
}
let sideLength = length * CGFloat(tan(theta / 2.0))
var point = CGPoint(x: squareWidth / 2.0 + sideLength / 2.0 - offSet, y: squareWidth - (squareWidth - length) / 2.0)
var angle = CGFloat(M_PI)
move(to: point)
for _ in 0 ..< sides {
point = CGPoint(x: point.x + CGFloat(sideLength - offSet * 2.0) * CGFloat(cos(angle)), y: point.y + CGFloat(sideLength - offSet * 2.0) * CGFloat(sin(angle)))
addLine(to: point)
let center = CGPoint(x: point.x + cornerRadius * CGFloat(cos(angle + CGFloat(M_PI_2))), y: point.y + cornerRadius * CGFloat(sin(angle + CGFloat(M_PI_2))))
addArc(withCenter: center, radius:CGFloat(cornerRadius), startAngle:angle - CGFloat(M_PI_2), endAngle:angle + theta - CGFloat(M_PI_2), clockwise:true)
point = currentPoint // we don't have to calculate where the arc ended ... UIBezierPath did that for us
angle += theta
}
close()
}
}
using extension :
firstImageView.makeHexagon()
and here's how i'm adding my shadow Effect on my ImageView :
firstImageView.layer.contentsScale = UIScreen.main.scale;
firstImageView.layer.shadowColor = UIColor.black.cgColor;
firstImageView.layer.shadowOffset = CGSize.zero;
firstImageView.layer.shadowRadius = 5.0;
firstImageView.layer.shadowOpacity = 2;
firstImageView.layer.masksToBounds = false;
firstImageView.clipsToBounds = false;
anyone can point out my shadow is not visible after changing the shape of imageView ???
You have clipped your view to hexagon, so to display shadow you have to set shadow on ShapeLayer.
let border = CAShapeLayer()
border.path = path.cgPath
border.lineWidth = lineWidth
border.strokeColor = UIColor.black.cgColor
border.fillColor = UIColor.clear.cgColor
border.shadowColor = UIColor.red.cgColor;
border.shadowRadius = 5.0;
border.shadowOpacity = 2;

Resources