如何保持不同 CALayer 之间的间距

How to provide maintain spacing between different CALayers

标题 ##我正在尝试学习图表但遇到了问题

  1. 在切片之间添加一致的 space。
  2. 按顺序开始播放动画。

原因是,我不想将分隔符作为一个单独的拱门,因为它的两个边都圆角。添加分隔符作为另一层重叠圆角。

非常感谢任何帮助或指点。

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 [=10=] + .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)

更新:

根据 @jaferAli

的建议,更新了代码以包含适当的间距,而不考虑图表上的 single/multiple 个项目,总间距的平均分布

未决问题: 处理图层上的点击手势,以便我可以根据所选类别执行自定义操作。

屏幕 2

更新代码:

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 [=11=] + .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)

您需要计算间距,然后从开始角度和结束角度添加和减去它。所以用这个

更新您的 calcualteStartAndEndAngle 方法
 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 [=10=] + .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
    }

它将产生这个动画

如果你想要线性动画,那么改变你的动画方法

 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)
    }

如果您想了解更多信息,可以查看此框架 RingPieChart

问题是 1.重新计算保持间距百分比的百分比

    //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. 完成后,类别总数 + 间距将等于 100%。

  2. 剩下的唯一问题是,对于非常小的百分比类别(小于间距百分比),起始角度将大于结束角度。这是因为我们要从端角中减去间距。 有两个选项可以更正, 一种。翻转角度。


            if angle.start > angle.end {
                let start = angle.start
                angle.start = angle.end
                angle.end = start
            }

b。在 Beizer 路径中逆时针绘制它,仅针对该切片。

        let circularPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: angle.start, endAngle: angle.end, clockwise: **angle.start < angle.end**)

这应该可以解决所有问题,我会将我的发现上传到 GIT 存储库并在此处发布 link。