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

SwiftUI沿圆弧路径添加文本 解决布局中心重叠问题

SwiftUI环形图分段文本对齐方案

问题说明

需要在自定义环形图的各圆弧段内部添加对应标注文本,测试阶段尝试为earthyStatsInTonnes对应的角度位置添加角度文本,使用GeometryReader实现布局时,所有文本都堆叠在视图中心互相重叠,无法对齐到对应圆弧段。
效果参考截图

原有实现代码:

struct Shapes: View {

private enum Params {
    static var viewSizeOffset: CGFloat = 100
    static let lineWidth: CGFloat = 20
    static var halfLineWidth: CGFloat {
        lineWidth / 2
    }

    static let animationDuration: CGFloat =
        0.05 // nominal animation to smooth things
    static let circleDegrees: CGFloat = 360
    static let circleRotation: Angle = .init(degrees: -90)
    static let offsetFactor: CGFloat = 0.4
    static let minCircleAngle: CGFloat = 00
    static let maxCircleAngle: CGFloat = 360
    static var minCircleTrim: CGFloat {
        minCircleAngle / circleDegrees
    }

    static var maxCircleTrim: CGFloat {
        maxCircleAngle / circleDegrees
    }

    static let progressColor = Color.red
    static let backgroundColor = Color.blue
    static let arcFrame: CGFloat = 225
}

    @State private var rotation: Double = 0
    @State var earthyStatsInTonnes = [2.6, 3.0, 5.4, 6.75, 0.0]
    @State private var increment = 0
    let arcColor = [Color.red, Color.gray, Color.black, Color.green, Color.purple, Color.blue]


    var body: some View {

        GeometryReader { geo in
            VStack {
                ZStack {
                    Text("Text \(angle)")
                        .position(
                            x: 0,
                            y: 0
                        )
                    Circle()
                        .fill(.red.opacity(0.2))
                        .frame(width: Params.arcFrame + 36, height: Params.arcFrame + 36)
                        ForEach(earthyStatsInTonnes.indices) { stat in
                            VStack {
                                ZStack {
                                    arcView(startAngle: stat == 0 ? 00 : earthyStatsInTonnes[stat],
                                            endAngle: earthyStatsInTonnes[stat],
                                            total: 17.8,
                                            arcColor: arcColor[stat])

                                }
                                .zIndex(-Double(stat))
                            }
                        }
                }
                .frame(width: Params.arcFrame, height: Params.arcFrame)
                .padding(45)
            }
            .position(x: geo.size.width / 2, y: geo.size.height / 2)
        }
    }

    private func arcView(startAngle: Double, endAngle: Double, total: Double, arcColor: Color) -> some View {

        return Arc(startAngle: .degrees(startAngle * total),
                   endAngle: .degrees(startAngle * total + (endAngle * total)),
                   clockwise: true)
        .stroke(arcColor,
                style: .init(lineWidth: 36,
                             lineCap: .round,
                             lineJoin: .miter,
                             miterLimit: 0,
                             dash: [2, 3],
                             dashPhase: 1))
        .frame(width: 226, height: 229)
    }

    func onDrag(value: DragGesture.Value) {
        let circleDegrees = Params.circleDegrees
        // Calculate radians
        let vector = CGVector(dx: value.location.x, dy: value.location.y)
        let radians = atan2(
            vector.dy - Params.halfLineWidth,
            vector.dx - Params.halfLineWidth
        )

        var angle = radians * circleDegrees / 2 / .pi

        // atan2 gives -180 to 180, so set range to 0 to 360
        if angle < 0 {
            angle += circleDegrees
        }

        // constrain angle between desired range
        angle = max(angle, Params.minCircleAngle)
        angle = min(angle, Params.maxCircleAngle)

        withAnimation(Animation.linear(duration: Params.animationDuration)) {
            self.angle = Double(angle)
        }
    }
}


struct Arc: Shape {
    var startAngle: Angle
    var endAngle: Angle
    var clockwise: Bool

    func path(in rect: CGRect) -> Path {
        let rotationAdjustment = Angle.degrees(90)
        let modifiedStart = startAngle - rotationAdjustment
        let modifiedEnd = endAngle - rotationAdjustment

        var path = Path()
        path.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
                    radius: rect.width / 2,
                    startAngle: modifiedStart,
                    endAngle: modifiedEnd,
                    clockwise: !clockwise)

        return path
    }
}

修复步骤

文本重叠的核心原因是没有根据每段圆弧的实际位置计算文本坐标,直接固定位置放置自然会全部堆叠在中心,原有代码还存在angle状态变量未声明、圆弧渲染尺寸不统一的问题,按以下逻辑修改即可:

  • 统一圆弧尺寸,把arcView里的frame从226/229调整为和Params中定义的arcFrame一致,避免坐标计算和实际渲染尺寸偏差
  • 遍历分段数据时累计每段的起始角度,计算每段圆弧的中点角度(段起始角度 + 段自身角度/2),文本需要放在这个中点角度对应的半径位置
  • 角度转坐标时和Arc形状的角度偏移对齐:Arc默认把0度偏移到了顶部,计算三角函数时要做-90度的角度转换,同时适配SwiftUI y轴向下的坐标系规则
  • 文本放置半径设为圆弧半径减去线宽的一半,刚好落在圆弧段内部中心位置

核心坐标计算逻辑:

// 环形基础参数
let totalValue = earthyStatsInTonnes.reduce(0, +)
let arcCenter: CGFloat = Params.arcFrame / 2
// 36为圆弧线宽,减去一半让文本落在圆弧内部中心
let textLayoutRadius = arcCenter - 18
var accumulatedAngle: Double = 0

// ZStack内和圆弧同级渲染文本
ForEach(Array(earthyStatsInTonnes.enumerated()), id: \.offset) { index, value in
    let segmentAngle = value / totalValue * 360
    let midAngle = accumulatedAngle + segmentAngle / 2
    accumulatedAngle += segmentAngle
    
    // 角度转iOS坐标系弧度
    let radian = (midAngle - 90) * .pi / 180
    let textPosX = arcCenter + textLayoutRadius * cos(radian)
    let textPosY = arcCenter + textLayoutRadius * sin(radian)
    
    Text("\(value)")
        .position(x: textPosX, y: textPosY)
}

把上述文本渲染逻辑加入ZStack后,文本会自动对齐到对应圆弧段的中心位置,不会出现重叠问题。


内容的提问来源于stack exchange,提问作者Robert

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.29 17:24:21