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

SwiftUI:如何为分段圆形的每个扇区添加对应标签

问题:SwiftUI分段圆形扇区标签定位失败

我是SwiftUI新手,开发带自定义Shape组件的应用,已经实现了可点击的分段圆形扇区按钮,但给每个扇区添加标签时,用.overlay和参考Stack Overflow的方案都没法让标签正确对应到所属扇区。

当前无标签状态实现代码

struct EmotionWheelView: View {
    // Prepare array of emotions for the selector
    let emotions: [Emotion] = Bundle.main.decode("emotions.json")
    
    // Bringing in the tapped and selected emotions to set in the parent
    @Binding public var tappedEmotion: String
    @Binding public var selectedEmotion: String
    @Binding public var selectedSegment: Int?
    
    var selectableEmotions: [Emotion] {
        var results: [Emotion] = []
        
        if selectedEmotion.isEmpty {
            for emotion in emotions {
                if emotion.level == 1 {
                    results.append(emotion)
                }
            }
        } else {
            var baseEmotion: Emotion {
                var result: Emotion = emotions[0]
                
                for emotion in emotions {
                    if emotion.name == selectedEmotion {
                        result = emotion
                    }
                }
                
                return result
            }
            
            for emotion in emotions {
                for subID in baseEmotion.subID {
                    if emotion.id == subID {
                        results.append(emotion)
                    }
                }
            }
        }
        
        return results
    }
    
    var data: [Double] {
        var results: [Double] = []
        
        for _ in selectableEmotions {
            results.append(2.0)
        }
        
        return results
    }
    
    var colors: [Color] {
        var results: [Color] = []
        var color: Color = .red
        
        for emotion in selectableEmotions {
            switch emotion.color {
            case "purple":
                color = Color.purple
            case "pink":
                color = Color.pink
            case "orange":
                color = Color.orange
            case "yellow":
                color = Color.yellow
            case "green":
                color = Color.green
            case "teal":
                color = Color.teal
            case "blue":
                color = Color.blue
            default:
                color = Color.black
            }
            results.append(color)
        }
        
        return results
    }
    
    var body: some View {
        EmotionWheel(selectableEmotions: selectableEmotions, data: data, colors: colors, tappedEmotion: $tappedEmotion, selectedSegment: $selectedSegment)
            .frame(maxHeight: 200)
    }
}

struct EmotionWheel: View {
    var selectableEmotions: [Emotion]
    var data: [Double]
    var colors: [Color]
    
    @Binding var tappedEmotion: String
    @Binding var selectedSegment: Int?
    
    private var totalValue: Double {
        data.reduce(0, +)
    }
    
    private var startAngles: [Double] {
        var angles: [Double] = []
        var currentAngle = -Double.pi / 2
        for value in data {
            angles.append(currentAngle)
            currentAngle += value / totalValue * 2 * .pi
        }
        return angles
    }
    
    private var endAngles: [Double] {
        var angles: [Double] = []
        var currentAngle = -Double.pi / 2
        for value in data {
            currentAngle += value / totalValue * 2 * .pi
            angles.append(currentAngle)
        }
        return angles
    }
    
    var body: some View {
        GeometryReader { geo in
            ZStack {
                ForEach(0..<data.count, id: \.self) { index in
                    WheelSegment(startAngle: startAngles[index], endAngle: endAngles[index])
                        .stroke(Color(UIColor.systemBackground), lineWidth: 1)
                        .fill(colors[index].gradient)
                        .onTapGesture {
                            withAnimation {
                                selectedSegment = index
                                tappedEmotion = selectableEmotions[index].name
                            }
                        }
                        .scaleEffect(selectedSegment == index ? 1.1 : 1.0)
                        .animation(.spring(dampingFraction: 0.8), value: selectedSegment)
                }
                
                Circle()
                    .foregroundStyle(Color(UIColor.systemBackground))
                    .frame(width: geo.size.width / 2)
                    .overlay {
                            Text(tappedEmotion)
                                .fontWeight(.bold)
                                .foregroundStyle(getTappedColor(tappedEmotion: tappedEmotion))
                                .padding()
                    }
            }
            .padding(40)
        }
    }
}

struct WheelSegment: Shape {
    var startAngle: Double
    var endAngle: Double
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height)
        path.move(to: center)
        path.addArc(center: center, radius: radius, startAngle: Angle(radians: startAngle), endAngle: Angle(radians: endAngle), clockwise: false)
        path.closeSubpath()
        return path
    }
}

func getTappedColor(tappedEmotion: String) -> Color {
    let emotions: [Emotion] = Bundle.main.decode("emotions.json")
    var color: Color = .red
    
    var toColor: Emotion {
        for emotion in emotions {
            if emotion.name == tappedEmotion {
                return emotion
            }
        }
        return emotions[0]
    }
    
    switch toColor.color {
    case "purple":
        color = Color.purple
    case "pink":
        color = Color.pink
    case "orange":
        color = Color.orange
    case "yellow":
        color = Color.yellow
    case "green":
        color = Color.green
    case "teal":
        color = Color.teal
    case "blue":
        color = Color.blue
    default:
        color = Color.black
    }
    
    return color
}

当前效果

无标签的情感轮

预期效果(示意)

带标签的情感轮

我试过用.overlay,也试过通过ZStack分别绘制颜色层和标签层,但均未成功。


解决方案:基于扇区中心角定位标签

核心思路是计算每个扇区的中心角度,通过三角函数计算标签的精准位置,让标签对应到扇区外侧。修改EmotionWheel结构体的代码如下:

struct EmotionWheel: View {
    var selectableEmotions: [Emotion]
    var data: [Double]
    var colors: [Color]
    
    @Binding var tappedEmotion: String
    @Binding var selectedSegment: Int?
    
    private var totalValue: Double {
        data.reduce(0, +)
    }
    
    private var startAngles: [Double] {
        var angles: [Double] = []
        var currentAngle = -Double.pi / 2
        for value in data {
            angles.append(currentAngle)
            currentAngle += value / totalValue * 2 * .pi
        }
        return angles
    }
    
    private var endAngles: [Double] {
        var angles: [Double] = []
        var currentAngle = -Double.pi / 2
        for value in data {
            currentAngle += value / totalValue * 2 * .pi
            angles.append(currentAngle)
        }
        return angles
    }
    
    // 新增:计算扇区的中心角度
    private func centerAngle(for index: Int) -> Double {
        (startAngles[index] + endAngles[index]) / 2
    }
    
    var body: some View {
        GeometryReader { geo in
            ZStack {
                // 扇区绘制层
                ForEach(0..<data.count, id: \.self) { index in
                    WheelSegment(startAngle: startAngles[index], endAngle: endAngles[index])
                        .stroke(Color(UIColor.systemBackground), lineWidth: 1)
                        .fill(colors[index].gradient)
                        .onTapGesture {
                            withAnimation {
                                selectedSegment = index
                                tappedEmotion = selectableEmotions[index].name
                            }
                        }
                        .scaleEffect(selectedSegment == index ? 1.1 : 1.0)
                        .animation(.spring(dampingFraction: 0.8), value: selectedSegment)
                }
                
                // 中心圆形层
                Circle()
                    .foregroundStyle(Color(UIColor.systemBackground))
                    .frame(width: geo.size.width / 2)
                    .overlay {
                            Text(tappedEmotion)
                                .fontWeight(.bold)
                                .foregroundStyle(getTappedColor(tappedEmotion: tappedEmotion))
                                .padding()
                    }
                
                // 新增:标签层
                ForEach(0..<data.count, id: \.self) { index in
                    let centerAngle = centerAngle(for: index)
                    // 计算标签位置:基于中心角度,偏移到扇区外侧
                    let x = geo.size.width/2 + (geo.size.width/2 + 30) * cos(centerAngle)
                    let y = geo.size.height/2 + (geo.size.height/2 + 30) * sin(centerAngle)
                    
                    Text(selectableEmotions[index].name)
                        .font(.caption)
                        .fontWeight(.medium)
                        .position(x: x, y: y)
                        // 调整文本方向,避免倒转
                        .rotationEffect(Angle(radians: centerAngle + .pi/2))
                }
            }
            .padding(40)
        }
    }
}

关键修改说明

  • 计算中心角:新增centerAngle(for:)方法,取扇区起始角和结束角的平均值,得到扇区的正中间方向
  • 定位标签:用三角函数cos和sin计算标签的position,+30是标签到轮盘边缘的距离,可根据需求调整
  • 调整文本方向:添加rotationEffect让文本顺着扇区方向显示,避免出现倒转的情况

这样修改后,标签就能精准对应到每个扇区的外侧位置,和预期效果一致。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.06 00:02:06