Search code examples
iosswiftcore-animationcalayeruibezierpath

How to provide maintain spacing between different CALayers


Heading ##I'm trying to learn the charts and having trouble

  1. Adding consistent space between the slices.
  2. 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.enter image description here

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 enter image description here

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)


Solution

  • 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
        }
    
    
    1. Once this is done, the total categories + spacings will equate to 100 %.

    2. 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.