swiftuigeometryshapestext-alignment

How to center text in a custom arc shape in SwiftUI?


I want to center textView in ArcShape, currently textView's startAngle is set to ArcShape's startAngle. So far, I've tried finding the midpoint of the ArcShape's startAngle and endAngle, but this shifts the textView to the right of the ArcShape.

How do I calculate the startAngle of the textView so that it centered in the ArcShape?

This is what it looks like currently:

enter image description here

Ideally, I want it to look like this:

enter image description here

import SwiftUI

struct ContentView: View {

    @State private var letterWidths: [Int: Double] = [:]
    let size: CGFloat = 300

    var body: some View {
        ZStack {
            ArcShape(startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 230))
                .foregroundColor(.green)
            textView("HELLO", startAngle: Angle(degrees: 180))

            ArcShape(startAngle: Angle(degrees: 233), endAngle: Angle(degrees: 300))
                .foregroundColor(.red)
            textView("BYEBYE", startAngle: Angle(degrees: 233))
        }
        .frame(width: size, height: size)
        .frame(height: size / 2)
        .offset(y: size / 4.5)
    }

    @ViewBuilder
    func textView(_ title: String, startAngle: Angle) -> some View {
        ZStack {
            ForEach(Array(title.enumerated()), id: \.offset) { index, letter in
                VStack {
                    Text(String(letter))
                        .font(.system(size: 14, weight: .semibold, design: .monospaced))
                        .kerning(2)
                        .background(
                            GeometryReader { geometry in // using this to get the width of EACH letter
                                Color.clear
                                    .preference(
                                        key: LetterWidthPreferenceKey.self,
                                        value: geometry.size.width
                                    )
                            }
                        )
                        .onPreferenceChange(LetterWidthPreferenceKey.self, perform: { width in
                            letterWidths[index] = width
                        })
                    Spacer()
                }
                .rotationEffect(fetchAngle(at: index))
            }
            .frame(width: size, height: size * 0.75)
            .rotationEffect(startAngle + .degrees(90))
        }
    }

    func fetchAngle(at letterPosition: Int) -> Angle {
        let timesPi: (Double) -> Double = { $0 * .pi }

        let radius: Double = 125
        let circumference = timesPi(radius)

        let finalAngle = timesPi(
            letterWidths
                .filter { $0.key <= letterPosition }
                .map(\.value)
                .reduce(0, +) / circumference
        )

        return .radians(finalAngle)
    }

}

struct LetterWidthPreferenceKey: PreferenceKey {
    static var defaultValue: Double = 0
    static func reduce(value: inout Double, nextValue: () -> Double) {
        value = nextValue()
    }
}

#Preview {
    ContentView()
}

private struct ArcShape: Shape {
    let startAngle: Angle?
    let endAngle: Angle?

    func path(in rect: CGRect) -> Path {
        let shorterLength = min(rect.width, rect.height)

        let path = UIBezierPath(
            roundedArcCenter: rect.center,
            innerRadius: (shorterLength / 2) * 0.54,
            outerRadius: (shorterLength / 2) * 0.90,
            startAngle: startAngle ?? .zero,
            endAngle: endAngle ?? .zero,
            cornerRadiusPercentage: 0.01
        )
        return Path(path.cgPath)
    }
}

extension UIBezierPath {
    public convenience init(roundedArcCenter center: CGPoint, innerRadius: CGFloat, outerRadius: CGFloat, startAngle: Angle, endAngle: Angle, cornerRadiusPercentage: CGFloat) {
        let maxCornerRadiusBasedOnInnerArcLength = abs((endAngle - startAngle).radians) * innerRadius / 2
        let maxCornerRadiusBasedOnOuterArcLength = abs((endAngle - startAngle).radians) * outerRadius / 2
        let maxCornerRadiusBasedOnEndCapLength = (outerRadius - innerRadius) / 2
        let outerCornerRadius = min(2 * .pi * outerRadius * cornerRadiusPercentage, maxCornerRadiusBasedOnOuterArcLength, maxCornerRadiusBasedOnEndCapLength)
        let outerCornerRadiusPercentage = outerCornerRadius / (2 * .pi * outerRadius)
        let innerCornerRadius = min(2 * .pi * innerRadius * outerCornerRadiusPercentage, maxCornerRadiusBasedOnInnerArcLength, maxCornerRadiusBasedOnEndCapLength)
        let innerInsetAngle = Angle(radians: innerCornerRadius / innerRadius)
        let outerInsetAngle = Angle(radians: outerCornerRadius / outerRadius)
        self.init()
        var arcStartAngle = (startAngle + outerInsetAngle).radians
        var arcEndAngle = (endAngle - outerInsetAngle).radians
        addArc(
            withCenter: .zero,
            radius: outerRadius,
            startAngle: min(arcStartAngle, arcEndAngle),
            endAngle: max(arcStartAngle, arcEndAngle),
            clockwise: true
        )
        addCorner(
            to: .pointOnCircle(radius: outerRadius - outerCornerRadius, angle: endAngle),
            controlPoint: .pointOnCircle(radius: outerRadius, angle: endAngle)
        )
        addLine(to: .pointOnCircle(radius: innerRadius + innerCornerRadius, angle: endAngle))
        addCorner(
            to: .pointOnCircle(radius: innerRadius, angle: endAngle - innerInsetAngle),
            controlPoint: .pointOnCircle(radius: innerRadius, angle: endAngle)
        )
        arcStartAngle = (endAngle - innerInsetAngle).radians
        arcEndAngle = (startAngle + innerInsetAngle).radians
        addArc(
            withCenter: .zero,
            radius: innerRadius,
            startAngle: max(arcStartAngle, arcEndAngle),
            endAngle: min(arcStartAngle, arcEndAngle),
            clockwise: false
        )
        addCorner(
            to: .pointOnCircle(radius: innerRadius + innerCornerRadius, angle: startAngle),
            controlPoint: .pointOnCircle(radius: innerRadius, angle: startAngle)
        )
        addLine(to: .pointOnCircle(radius: outerRadius - outerCornerRadius, angle: startAngle))
        addCorner(
            to: .pointOnCircle(radius: outerRadius, angle: startAngle + outerInsetAngle),
            controlPoint: .pointOnCircle(radius: outerRadius, angle: startAngle)
        )
        apply(.init(translationX: center.x, y: center.y))
    }

    private func addCorner(to: CGPoint, controlPoint: CGPoint) {
        let circleApproximationConstant = 0.551915
        addCurve(
            to: to,
            controlPoint1: currentPoint + (controlPoint - currentPoint) * circleApproximationConstant,
            controlPoint2: to + (controlPoint - to) * circleApproximationConstant
        )
    }

}
private extension CGPoint {
    static func pointOnCircle(radius: CGFloat, angle: Angle) -> CGPoint {
        CGPoint(x: radius * Darwin.cos(angle.radians), y: radius * Darwin.sin(angle.radians))
    }
    static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
    }
    static func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
    }
    static func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
        CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
    }
}
public extension CGRect {
    var center: CGPoint {
        CGPoint(x: size.width / 2.0, y: size.height / 2.0)
    }
}

Solution

  • With your current code, you can do it in a similar way to how you'd centre something horizontally/vertically.

    You calculate the available space, and how much space the text takes up. Subtract the latter from the former and divide by 2. Then you "shift" the text by that amount. Of course, in this case the "space" we are talking about is measured in degrees/radians.

    This means textView should also take in the end angle:

    // pass the same end angles as you pass to the ArcShapes
    func textView(_ title: String, startAngle: Angle, endAngle: Angle) -> some View
    
    // before the ForEach
    let firstAngle = fetchAngle(at: 0)
    let lastAngle = fetchAngle(at: title.count - 1)
    let textAngleDifference = lastAngle - firstAngle
    let availableSpace = endAngle - startAngle
    let offset = (availableSpace - textAngleDifference) / 2
    

    The rotationEffect applied to the ForEach should have the offset added:

    .rotationEffect(offset + startAngle + .degrees(90))
    

    Finally, note that your fetchAngle is incorrect. The "H" in "Hello" should be completely horizontal (without offset), but you can see that is not the case in your screenshot. The comparison in the filter should be <, not <=.

    .filter { $0.key < letterPosition }
    

    Output:

    enter image description here