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:
Ideally, I want it to look like this:
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)
}
}
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: