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

SwiftUI交互式半环形进度条开发:滑块拖拽不顺畅问题求助

解决SwiftUI交互式半环形进度条拖拽不顺畅问题

我尝试用SwiftUI开发一款交互式半环形进度条,期望实现类似参考图的效果,但当前滑块拖拽不够顺畅,无法在半环形范围内平滑拖动以展示进度,现有代码如下:

import SwiftUI
struct SemiCircularProgressBar: View {
    @State var progressValue: Float = 0.0
    @State private var degrees: Double = -90
    
    var body: some View {
        VStack {
            ZStack {
                ProgressBar(progress: self.$progressValue)
                    .frame(width: 250.0, height: 250.0)
                    .padding(40.0)
                
                ProgressThumb(progress: self.$progressValue)
                    .frame(width: 30, height: 30)
                    .offset(x: self.thumbOffset().x, y: self.thumbOffset().y)
                    .rotationEffect(.degrees(54.5))
                    .gesture(
                        DragGesture()
                            .onChanged { gesture in
                                let angle = self.angle(for: gesture.location)
                                self.updateProgress(for: angle)
                            }
                    )
            }
            Spacer()
        }
    }
    
    private func angle(for location: CGPoint) -> Double {
        let vector = CGVector(dx: location.x, dy: location.y)
        let angle = atan2(vector.dy, vector.dx)
        return Double(angle * 180 / .pi)
    }
    
    private func updateProgress(for angle: Double) {
        let totalAngle: Double = 220
        let minAngle: Double = -110
        let maxAngle: Double = minAngle + totalAngle
        var normalizedAngle = min(max(minAngle, angle), maxAngle)
        if normalizedAngle > 88.8 {
            normalizedAngle = 88.8
        }
        let progress = Float((normalizedAngle - minAngle) / totalAngle)
        self.progressValue = progress
        self.degrees = normalizedAngle
    }
    
    private func thumbOffset() -> CGPoint {
        let thumbRadius: CGFloat = 125 // half of progress bar diameter
        let radians = CGFloat(degrees) * .pi / -100
        let x = thumbRadius * cos(radians)
        let y = thumbRadius * sin(radians)
        return CGPoint(x: x, y: y)
    }
}
struct ProgressBar: View {
    @Binding var progress: Float
    var body: some View {
        ZStack {
            Circle()
                .trim(from: 0.3, to: 0.9)
                .stroke(style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round))
                .opacity(0.3)
                .foregroundColor(Color.gray)
                .rotationEffect(.degrees(54.5))
            
            Circle()
                .trim(from: 0.3, to: CGFloat(self.progress))
                .stroke(style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round))
                .fill(AngularGradient(gradient: Gradient(stops: [
                    .init(color: Color(hex: "ED4D4D"), location: 0.39000002),
                    .init(color: Color(hex: "E59148"), location: 0.48000002),
                    .init(color: Color(hex: "EFBF39"), location: 0.5999999),
                    .init(color: Color(hex: "EEED56"), location: 0.7199998),
                    .init(color: Color(hex: "32E1A0"), location: 0.8099997)]), center: .center))
                .rotationEffect(.degrees(54.5))
            
            VStack {
                Text("824").font(Font.system(size: 44)).bold().foregroundColor(Color(hex: "314058"))
                Text("Great Score!").bold().foregroundColor(Color(hex: "32E1A0"))
            }
        }
    }
}
struct ProgressThumb: View {
    @Binding var progress: Float
    var body: some View {
        Circle()
            .fill(Color.blue)
            .frame(width: 30, height: 30)
    }
}

extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 3: // RGB (12-bit)
            (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24-bit)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (32-bit)
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (1, 1, 1, 0)
        }
        
        self.init(
            .sRGB,
            red: Double(r) / 255,
            green: Double(g) / 255,
            blue:  Double(b) / 255,
            opacity: Double(a) / 255
        )
    }
}


#Preview {
    SemiCircularProgressBar()
}

问题根源分析

  1. 坐标系统错误:拖拽手势的location是相对于视图左上角的坐标,计算角度时未转换为以进度条中心为原点的坐标系,导致角度计算偏差。
  2. 弧度转换错误:thumbOffset()中把角度转弧度时使用了/ -100的错误系数,应该用标准的/ 180转换,导致滑块位置偏移,拖拽时无法贴合进度条轨迹。
  3. 进度映射不匹配:进度条的trim范围是0.3到0.9(对应220度半环),但原代码直接将progressValue作为trim的终点值,导致进度与半环范围不匹配。
  4. 多余的角度限制:updateProgress()中额外限制normalizedAngle > 88.8属于冗余逻辑,已经通过min(max(...))限制了角度范围。

修复后的完整代码

import SwiftUI

struct SemiCircularProgressBar: View {
    @State var progressValue: Float = 0.0
    @State private var degrees: Double = -110 // 初始位置对应半环起点
    
    var body: some View {
        VStack {
            ZStack(alignment: .center) {
                ProgressBar(progress: $progressValue)
                    .frame(width: 250.0, height: 250.0)
                    .padding(40.0)
                
                ProgressThumb()
                    .frame(width: 30, height: 30)
                    .offset(x: thumbOffset().x, y: thumbOffset().y)
                    .gesture(
                        DragGesture()
                            .onChanged { gesture in
                                // 将拖拽位置转换为以进度条中心为原点的坐标
                                let relativeLocation = CGPoint(
                                    x: gesture.location.x - 125 - 40,
                                    y: gesture.location.y - 125 - 40
                                )
                                let angle = angle(for: relativeLocation)
                                updateProgress(for: angle)
                            }
                    )
            }
            Spacer()
        }
    }
    
    private func angle(for location: CGPoint) -> Double {
        // 计算相对于中心的角度,转换为0-360度适配SwiftUI坐标系
        let angle = atan2(location.y, location.x) * 180 / .pi
        return angle < 0 ? angle + 360 : angle
    }
    
    private func updateProgress(for angle: Double) {
        let totalAngle: Double = 220
        let minAngle: Double = 250 // 对应原-110度(360-110)
        let maxAngle: Double = minAngle + totalAngle // 对应原110度
        
        // 将角度限制在半环区间内
        let normalizedAngle = min(max(minAngle, angle.truncatingRemainder(dividingBy: 360)), maxAngle)
        let progress = Float((normalizedAngle - minAngle) / totalAngle)
        progressValue = progress
        degrees = normalizedAngle - 360 // 转换回-110到110的范围
    }
    
    private func thumbOffset() -> CGPoint {
        let thumbRadius: CGFloat = 125 // 进度条半径
        let radians = CGFloat(degrees) * .pi / 180 // 正确的角度转弧度公式
        let x = thumbRadius * cos(radians)
        let y = thumbRadius * sin(radians)
        return CGPoint(x: x, y: y)
    }
}

struct ProgressBar: View {
    @Binding var progress: Float
    var body: some View {
        ZStack {
            // 背景半环
            Circle()
                .trim(from: 0.3, to: 0.9)
                .stroke(style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round))
                .opacity(0.3)
                .foregroundColor(Color.gray)
                .rotationEffect(.degrees(54.5))
            
            // 进度半环:将0-1的progress映射到0.3-0.9的trim范围
            Circle()
                .trim(from: 0.3, to: 0.3 + CGFloat(progress) * 0.6)
                .stroke(style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round))
                .fill(AngularGradient(gradient: Gradient(stops: [
                    .init(color: Color(hex: "ED4D4D"), location: 0.39),
                    .init(color: Color(hex: "E59148"), location: 0.48),
                    .init(color: Color(hex: "EFBF39"), location: 0.6),
                    .init(color: Color(hex: "EEED56"), location: 0.72),
                    .init(color: Color(hex: "32E1A0"), location: 0.81)]), center: .center))
                .rotationEffect(.degrees(54.5))
            
            VStack {
                // 根据进度动态计算分数
                Text("\(Int(progress * 1000))")
                    .font(Font.system(size: 44)).bold().foregroundColor(Color(hex: "314058"))
                // 根据进度动态切换提示文本
                Text(progress > 0.8 ? "Great Score!" : "Keep Going!")
                    .bold().foregroundColor(Color(hex: "32E1A0"))
            }
        }
    }
}

struct ProgressThumb: View {
    var body: some View {
        Circle()
            .fill(Color.blue)
            .shadow(radius: 3)
    }
}

extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 3: // RGB (12-bit)
            (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24-bit)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (32-bit)
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (1, 1, 1, 0)
        }
        
        self.init(
            .sRGB,
            red: Double(r) / 255,
            green: Double(g) / 255,
            blue:  Double(b) / 255,
            opacity: Double(a) / 255
        )
    }
}

#Preview {
    SemiCircularProgressBar()
}

关键修改说明

  1. 坐标转换:将拖拽手势的位置转换为以进度条中心为原点的坐标,确保角度计算准确。
  2. 角度范围修正:将角度统一到0-360度范围处理,再映射回半环的-110到110度区间,避免角度跳转问题。
  3. 进度映射:将progressValue(0-1)映射到进度条trim的0.3-0.9范围,确保进度与半环长度匹配。
  4. 动态内容优化:根据进度动态计算分数和提示文本,提升交互合理性。
  5. 冗余逻辑移除:删除updateProgress()中多余的角度限制代码,简化逻辑。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.27 02:53:13