0

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.

enter image description here

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

Community
  • 1
  • 1
Surjeet Singh
  • 11,691
  • 2
  • 37
  • 54
  • If you want help with this, try to include enough code to produce what you have so far. See [mre]. – DonMag Jun 07 '20 at 13:25
  • A layer is usually rotated around its center. If you cut the rotation code, are the text layers positioned (centered) correctly? – matt Jun 07 '20 at 13:27
  • @matt No, even if i cut rotation code, text doesn't looks centered for layer. I'll add another image without rotation code. – Surjeet Singh Jun 07 '20 at 16:39
  • No need for that. The point is, you just need to get the center right to start with. The center doesn't move during rotation (normally), so if you get the position right without rotation, it will be right with rotation too. – matt Jun 07 '20 at 16:41

1 Answers1

5

Your labels are not "centered" because you're using the geometric center of the wedge bounding-box:

enter image description here

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:

enter image description here

Then bisect each angle and find the point on the circle:

enter image description here

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:

enter image description here

And the result, without the "guides":

enter image description here

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)
    }
}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • That works like a charm. I got the problem, which I missed in my code due to incorrect position, Thanks for your solution. – Surjeet Singh Jun 09 '20 at 08:42