如何在弧形文本视图中实现字符等间距排列?
问题:SwiftUI弧形文本实现字符等间距(非等宽字体)
我正在绘制一个居中于弧形内的弧形文本视图,想要实现每个字符间的间距相等。尝试设置.font(.system(size: 14, design: .monospaced))虽然能实现等间距,但这不是我需要的正确字体。
当前效果:
理想效果(使用正确字体):
相关SwiftUI代码如下:
struct ContentView: View { @State private var letterWidths: [Int: Double] = [:] private let size: CGFloat = 300 var body: some View { ZStack { ArcShape(start: 180, end: 230) .frame(width: size, height: size) .foregroundColor(.gray) textView("AVENIR", start: 180, end: 230) ArcShape(start: 233, end: 300) .frame(width: size, height: size) .foregroundColor(.gray) textView("DEMIBOLD", start: 233, end: 300) } .frame(width: size) } func textView(_ title: String, start: CGFloat, end: CGFloat) -> some View { ZStack { let firstLetterAngle = calculateLetterAngle(at: 0) let lastLetterAngle = calculateLetterAngle(at: title.count - 1) let titleAngleDifference: Angle = lastLetterAngle - firstLetterAngle let segmentAngleDifference: Angle = .degrees(end) - .degrees(start) let titleOffsetAngle = (segmentAngleDifference - titleAngleDifference) / 2 ForEach(Array(title.enumerated()), id: \.offset) { index, letter in VStack { Text(String(letter)) .font(.custom("AvenirNext-DemiBold", size: 14)) .kerning(3) .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(calculateLetterAngle(at: index)) } // to center title in arc shape .frame(width: size, height: size * 0.75) .rotationEffect(titleOffsetAngle + .degrees(start) + .degrees(90)) } } private func calculateLetterAngle(at letterPosition: Int) -> Angle { let times2Pi: (CGFloat) -> CGFloat = { $0 * 2 * .pi } let radius: CGFloat = 125 let circumference = times2Pi(radius) let letterAngle = times2Pi( letterWidths .filter { $0.key < letterPosition } .map(\.value) .reduce(0, +) / circumference ) return .degrees(letterAngle * (180 / .pi)) // to convert radians to degrees } } struct LetterWidthPreferenceKey: PreferenceKey { static var defaultValue: Double = 0 static func reduce(value: inout Double, nextValue: () -> Double) { value = nextValue() } } 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) } }
解决方案
核心思路是不依赖字符本身的宽度计算角度,而是将整个文本段的总角度平均分配给每个字符的间隔,让字符之间的弧长间距一致,而非基于字符宽度累加。
修改步骤:
移除字符宽度获取相关逻辑(
LetterWidthPreferenceKey、background(GeometryReader)、onPreferenceChange),因为我们不再需要单个字符的宽度。重新计算字符的角度位置:
- 计算文本段可用的总角度(
segmentAngleDifference) - 计算每个字符间隔的角度:总角度 / (字符数 + 1),这样字符会均匀分布在弧形段内,两端也有留白
- 每个字符的角度位置 = 起始角度 + 间隔角度 * (索引 + 1)
- 计算文本段可用的总角度(
调整文本视图的旋转和定位,确保字符正确居中在弧形上。
修改后的核心代码:
struct ContentView: View { private let size: CGFloat = 300 var body: some View { ZStack { ArcShape(start: 180, end: 230) .frame(width: size, height: size) .foregroundColor(.gray) textView("AVENIR", start: 180, end: 230) ArcShape(start: 233, end: 300) .frame(width: size, height: size) .foregroundColor(.gray) textView("DEMIBOLD", start: 233, end: 300) } .frame(width: size) } func textView(_ title: String, start: CGFloat, end: CGFloat) -> some View { ZStack { let totalAngle = end - start // 每个字符之间的间隔角度,包含两端的留白 let anglePerGap = totalAngle / (CGFloat(title.count) + 1) ForEach(Array(title.enumerated()), id: \.offset) { index, letter in VStack { Text(String(letter)) .font(.custom("AvenirNext-DemiBold", size: 14)) Spacer() } // 计算当前字符的角度位置 .rotationEffect(.degrees(start + anglePerGap * (CGFloat(index) + 1))) } .frame(width: size, height: size * 0.75) // 旋转到弧形的正确朝向(原代码的90度偏移保留) .rotationEffect(.degrees(90)) } } }
说明:
- 这种方式直接基于角度分配位置,不管字符本身宽度,确保字符之间的弧长间距完全相等。
- 如果需要微调字符之间的紧凑程度,可以调整
anglePerGap的计算方式,比如去掉两端留白:let anglePerGap = totalAngle / (CGFloat(title.count) - 1),但这样字符会紧贴弧形的两端。 - 原代码中的
ArcShape和其他扩展可以保留,无需修改。
内容的提问来源于stack exchange,提问作者Nadia Siddiqah
相关产品推荐
相关产品推荐

