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

SwiftUI中如何在自定义ArcShape内居中显示文本?

问题描述

我希望将textView在ArcShape内居中显示,目前textView的startAngle设置为ArcShape的startAngle。我尝试过计算ArcShape的startAngle与endAngle的中点,但这会导致textView偏移到ArcShape右侧。请问该如何计算textView的startAngle,使其在ArcShape中居中?

当前效果

当前效果

理想效果

理想效果

解决方案

核心逻辑是:先计算文本整体占用的弧度,再将文本向左偏移该弧度的一半,结合Arc的中点角度进行定位,让文本中点与Arc中点对齐,实现居中效果。

修改步骤

  1. 新增方法计算文本总占用弧度,确定偏移量
  2. 调整textView的起始角度计算逻辑,融合Arc中点角度与文本偏移量

完整修改代码

import SwiftUI

struct ContentView: View {

    @State private var letterWidths: [Int: Double] = [:]
    let size: CGFloat = 300
    let textRadius: Double = 125 // 提取为常量,方便统一修改

    var body: some View {
        ZStack {
            // 绿色Arc和对应的文本
            ArcShape(startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 230))
                .foregroundColor(.green)
            textView(
                "HELLO",
                arcStartAngle: Angle(degrees: 180),
                arcEndAngle: Angle(degrees: 230)
            )

            // 红色Arc和对应的文本
            ArcShape(startAngle: Angle(degrees: 233), endAngle: Angle(degrees: 300))
                .foregroundColor(.red)
            textView(
                "BYEBYE",
                arcStartAngle: Angle(degrees: 233),
                arcEndAngle: Angle(degrees: 300)
            )
        }
        .frame(width: size, height: size)
        .frame(height: size / 2)
        .offset(y: size / 4.5)
    }

    @ViewBuilder
    func textView(_ title: String, arcStartAngle: Angle, arcEndAngle: Angle) -> some View {
        // 计算Arc的中点角度
        let arcMidAngle = Angle(
            degrees: (arcStartAngle.degrees + arcEndAngle.degrees) / 2
        )
        // 计算文本总占用的弧度,再取一半作为偏移量
        let textHalfRadian = totalTextRadian(for: title) / 2

        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
                                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)
            // 核心调整:用Arc中点角度减去文本半弧度,再加上90度的基础旋转
            .rotationEffect(arcMidAngle - .radians(textHalfRadian) + .degrees(90))
        }
    }

    // 计算单个字母的旋转角度
    func fetchAngle(at letterPosition: Int) -> Angle {
        let circumference = .pi * textRadius

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

        let angleRadian = .pi * (totalWidth / circumference)
        return .radians(angleRadian)
    }

    // 计算文本整体占用的总弧度
    func totalTextRadian(for title: String) -> Double {
        let circumference = .pi * textRadius
        let totalWidth = letterWidths
            .filter { $0.key < title.count }
            .map(\.value)
            .reduce(0, +)
        return .pi * (totalWidth / circumference)
    }

}

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

关键修改说明

  • 参数调整:给textView方法新增arcEndAngle参数,用于计算Arc的中点角度
  • 新增文本弧度计算:totalTextRadian方法统计所有字母总宽度对应的弧度,取一半作为文本的偏移量
  • 旋转逻辑优化:不再直接使用Arc的startAngle,而是通过Arc中点角度 - 文本半弧度 + 90度基础旋转,让文本中点与Arc中点对齐,实现精准居中

内容的提问来源于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 15:10:56