iosswiftcashapelayerquartz-corecatextlayer

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.

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


Solution

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