You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在弧形文本视图中实现字符等间距排列?

问题: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)
    }
}

解决方案

核心思路是不依赖字符本身的宽度计算角度,而是将整个文本段的总角度平均分配给每个字符的间隔,让字符之间的弧长间距一致,而非基于字符宽度累加。

修改步骤:

  1. 移除字符宽度获取相关逻辑(LetterWidthPreferenceKey、background(GeometryReader)、onPreferenceChange),因为我们不再需要单个字符的宽度。

  2. 重新计算字符的角度位置:

    • 计算文本段可用的总角度(segmentAngleDifference)
    • 计算每个字符间隔的角度:总角度 / (字符数 + 1),这样字符会均匀分布在弧形段内,两端也有留白
    • 每个字符的角度位置 = 起始角度 + 间隔角度 * (索引 + 1)
  3. 调整文本视图的旋转和定位,确保字符正确居中在弧形上。

修改后的核心代码:

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 19:55:54