The Util Designer
SwiftUI 使用DragGesture創建顏色選擇旋轉盤
xcode 13.4.1, swift 5.5, iOS 15.4
2022-08-25
SwiftUI提供了很多方便的元件和方法,這次就看看如何有很短的程式代碼實現一個顏色選擇旋轉盤。
1. 首先創建一個有多種顏色的圓餅。
struct WheelPicker : View {
    let colors : [Color] = [.red, .orange, .yellow, .green, .indigo, .blue, .purple, .orange, .mint, .teal]

    var body: some View {
        GeometryReader { proxy in
            let radius = min(proxy.size.width/2, proxy.size.height/2)

            ZStack {
                ForEach(0..<colors.count, id:\.self) { i in
                    Circle()
                        .inset(by: (radius)/2)
                        .trim(from: CGFloat(i) / CGFloat(colors.count), to: CGFloat(i+1) / CGFloat(colors.count))
                        .stroke(colors[i], lineWidth: radius)
                }
            }
        }
    }
}
2. 為了達到撥動圓盤時,可以有轉動的效果,所以需要知道撥動的開始和結束角度,可以使用DragGestrue去取後第一次點按屏中的位置和圓盤的圓心來計算開始角度,然後現在拖曳的位置來計算結束的角度,這兩個角度的差就是圓盤需要轉動的角度,分析完後我們需要兩個函數來幫忙計算,就是當前位置的角度getAngle和拖曳的開始結束角度差deltaDegree。
import SwiftUI

struct WheelPicker : View {
    let colors : [Color] = [.red, .orange, .yellow, .green, .indigo, .blue, .purple, .orange, .mint, .teal]

    var body: some View {
        GeometryReader { proxy in
            let radius = min(proxy.size.width/2, proxy.size.height/2)

            ZStack {
                ForEach(0..<colors.count, id:\.self) { i in
                    Circle()
                        .inset(by: (radius)/2)
                        .trim(from: CGFloat(i) / CGFloat(colors.count), to: CGFloat(i+1) / CGFloat(colors.count))
                        .stroke(colors[i], lineWidth: radius)
                }
            }
        }
    }
    
    func getAngle(center : CGPoint, point : CGPoint) -> CGFloat {
        let deltaX = point.x - center.x
        let deltaY = point.y - center.y
        var theangle = atan(deltaY / deltaX) * 360 / (2 * CGFloat.pi)
        if deltaY > 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY < 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY == 0 && deltaX < 0 {
            theangle = 180
        } else if deltaY == 0 && deltaX > 0 {
            theangle = 0
        } else if deltaY < 0 && deltaX > 0 {
            theangle += 360
        } else if deltaY < 0 && deltaX == 0 {
            theangle = 270
        }
        return theangle
    }
    
    func deltaDegree(center : CGPoint, startPoint : CGPoint, endPoint : CGPoint) -> CGFloat {
        let startDegree = getAngle(center: center, point: startPoint)
        let endDegree = getAngle(center: center, point: endPoint)
        return (endDegree - startDegree)
    }
    

}
3. 然後使用DragGesture來取後拖曳的開始和結束位置後,並使用以上的兩個函數來計算拖曳的角度差,currentDegree代表上一次圓盤停留的角度,degree代表當前圓盤被撥動的角度,兩個加起來就是圓盤應停的的角度,實作如下:
import SwiftUI

struct WheelPicker : View {
    let colors : [Color] = [.red, .orange, .yellow, .green, .indigo, .blue, .purple, .orange, .mint, .teal]
    @State private var currentDegree = Double.zero
    @State private var degree = Double.zero

    var body: some View {
        GeometryReader { proxy in
            let center = CGPoint(x: proxy.size.width/2, y: proxy.size.height/2)
            let radius = min(proxy.size.width/2, proxy.size.height/2)

            let dragGesture = DragGesture()
                .onChanged({ v in
                    degree = deltaDegree(center: center, startPoint: v.startLocation, endPoint: v.location)
                })
                .onEnded({ v in
                    currentDegree += deltaDegree(center: center, startPoint: v.startLocation, endPoint: v.location)
                    degree = .zero
                })

            
            ZStack {
                ForEach(0..<colors.count, id:\.self) { i in
                    Circle()
                        .inset(by: (radius)/2)
                        .trim(from: CGFloat(i) / CGFloat(colors.count), to: CGFloat(i+1) / CGFloat(colors.count))
                        .stroke(colors[i], lineWidth: radius)
                }
            }
            .rotationEffect(.degrees(currentDegree + degree))
            .gesture(dragGesture)
        }
    }
    
    func getAngle(center : CGPoint, point : CGPoint) -> CGFloat {
        let deltaX = point.x - center.x
        let deltaY = point.y - center.y
        var theangle = atan(deltaY / deltaX) * 360 / (2 * CGFloat.pi)
        if deltaY > 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY < 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY == 0 && deltaX < 0 {
            theangle = 180
        } else if deltaY == 0 && deltaX > 0 {
            theangle = 0
        } else if deltaY < 0 && deltaX > 0 {
            theangle += 360
        } else if deltaY < 0 && deltaX == 0 {
            theangle = 270
        }
        return theangle
    }
    
    func deltaDegree(center : CGPoint, startPoint : CGPoint, endPoint : CGPoint) -> CGFloat {
        let startDegree = getAngle(center: center, point: startPoint)
        let endDegree = getAngle(center: center, point: endPoint)
        return (endDegree - startDegree)
    }
    

}
4. 在圓盤的右邊加一個小三角形作為指示當前圓盤選擇的位置,將指示三角形放在圓盤0度的位置。
import SwiftUI

struct WheelPicker : View {
    let colors : [Color] = [.red, .orange, .yellow, .green, .indigo, .blue, .purple, .orange, .mint, .teal]
    @State private var currentDegree = Double.zero
    @State private var degree = Double.zero

    var body: some View {
        GeometryReader { proxy in
            let center = CGPoint(x: proxy.size.width/2, y: proxy.size.height/2)
            let radius = min(proxy.size.width/2, proxy.size.height/2)

            let dragGesture = DragGesture()
                .onChanged({ v in
                    degree = deltaDegree(center: center, startPoint: v.startLocation, endPoint: v.location)
                })
                .onEnded({ v in
                    currentDegree += deltaDegree(center: center, startPoint: v.startLocation, endPoint: v.location)
                    degree = .zero
                })

            
            ZStack {
                ForEach(0..<colors.count, id:\.self) { i in
                    Circle()
                        .inset(by: (radius)/2)
                        .trim(from: CGFloat(i) / CGFloat(colors.count), to: CGFloat(i+1) / CGFloat(colors.count))
                        .stroke(colors[i], lineWidth: radius)
                }
                Image(systemName: "arrowtriangle.left.fill")
                    .offset(x: radius, y: 0)
            }
            .rotationEffect(.degrees(currentDegree + degree))
            .gesture(dragGesture)
        }
    }
    
    func getAngle(center : CGPoint, point : CGPoint) -> CGFloat {
        let deltaX = point.x - center.x
        let deltaY = point.y - center.y
        var theangle = atan(deltaY / deltaX) * 360 / (2 * CGFloat.pi)
        if deltaY > 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY < 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY == 0 && deltaX < 0 {
            theangle = 180
        } else if deltaY == 0 && deltaX > 0 {
            theangle = 0
        } else if deltaY < 0 && deltaX > 0 {
            theangle += 360
        } else if deltaY < 0 && deltaX == 0 {
            theangle = 270
        }
        return theangle
    }
    
    func deltaDegree(center : CGPoint, startPoint : CGPoint, endPoint : CGPoint) -> CGFloat {
        let startDegree = getAngle(center: center, point: startPoint)
        let endDegree = getAngle(center: center, point: endPoint)
        return (endDegree - startDegree)
    }
    

}
5. 現在只要知道圓盤0度位置的顏色,就可以達到選擇圓盤選擇的效果,以下函數就是用來計算當前圓盤在0度位置的顏色 getColor,然後計算的顏色作為背景色,實作如下。
import SwiftUI

struct WheelPicker : View {
    let colors : [Color] = [.red, .orange, .yellow, .green, .indigo, .blue, .purple, .orange, .mint, .teal]
    @State private var currentDegree = Double.zero
    @State private var degree = Double.zero
    @State private var selectedColor : Color = .red
    var body: some View {
        GeometryReader { proxy in
            let center = CGPoint(x: proxy.size.width/2, y: proxy.size.height/2)
            let radius = min(proxy.size.width/2, proxy.size.height/2)

            let dragGesture = DragGesture()
                .onChanged({ v in
                    degree = deltaDegree(center: center, startPoint: v.startLocation, endPoint: v.location)
                    selectedColor = getColor(degree: currentDegree + degree)
                })
                .onEnded({ v in
                    currentDegree += deltaDegree(center: center, startPoint: v.startLocation, endPoint: v.location)
                    degree = .zero
                    selectedColor = getColor(degree: currentDegree + degree)
                })

            
            ZStack {
                selectedColor.opacity(0.5)
                ZStack {
                    ForEach(0..<colors.count, id:\.self) { i in
                        Circle()
                            .inset(by: (radius)/2)
                            .trim(from: CGFloat(i) / CGFloat(colors.count), to: CGFloat(i+1) / CGFloat(colors.count))
                            .stroke(colors[i], lineWidth: radius)
                    }
                }
                .rotationEffect(.degrees(currentDegree + degree))
                .gesture(dragGesture)
                Image(systemName: "arrowtriangle.left.fill")
                    .offset(x: radius, y: 0)
            }
        }
    }
    
    func getAngle(center : CGPoint, point : CGPoint) -> CGFloat {
        let deltaX = point.x - center.x
        let deltaY = point.y - center.y
        var theangle = atan(deltaY / deltaX) * 360 / (2 * CGFloat.pi)
        if deltaY > 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY < 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY == 0 && deltaX < 0 {
            theangle = 180
        } else if deltaY == 0 && deltaX > 0 {
            theangle = 0
        } else if deltaY < 0 && deltaX > 0 {
            theangle += 360
        } else if deltaY < 0 && deltaX == 0 {
            theangle = 270
        }
        return theangle
    }
    
    func deltaDegree(center : CGPoint, startPoint : CGPoint, endPoint : CGPoint) -> CGFloat {
        let startDegree = getAngle(center: center, point: startPoint)
        let endDegree = getAngle(center: center, point: endPoint)
        return (endDegree - startDegree)
    }
    
    func getColor(degree : CGFloat) -> Color {
        let anglePerColor = 360.0 / CGFloat(colors.count)
        let fixDegree = (degree+360).truncatingRemainder(dividingBy: 360.0)
        return colors[colors.count-Int(fixDegree/anglePerColor)-1]
    }
}
5. 將創建顏色選擇旋轉盤打包成元件,方便在其他App上使用。
import SwiftUI

struct WheelPicker : View {
    let colors : [Color] = [.red, .orange, .yellow, .green, .indigo, .blue, .purple, .orange, .mint, .teal]
    @State private var currentDegree = Double.zero
    @State private var degree = Double.zero
    @Binding var selectedColor : Color
    var body: some View {
        GeometryReader { proxy in
            let center = CGPoint(x: proxy.size.width/2, y: proxy.size.height/2)
            let radius = min(proxy.size.width/2, proxy.size.height/2)

            let dragGesture = DragGesture()
                .onChanged({ v in
                    degree = deltaDegree(center: center, startPoint: v.startLocation, endPoint: v.location)
                    selectedColor = getColor(degree: currentDegree + degree)
                })
                .onEnded({ v in
                    currentDegree += deltaDegree(center: center, startPoint: v.startLocation, endPoint: v.location)
                    degree = .zero
                    selectedColor = getColor(degree: currentDegree + degree)
                })

            
            ZStack {
                ZStack {
                    ForEach(0..<colors.count, id:\.self) { i in
                        Circle()
                            .inset(by: (radius)/2)
                            .trim(from: CGFloat(i) / CGFloat(colors.count), to: CGFloat(i+1) / CGFloat(colors.count))
                            .stroke(colors[i], lineWidth: radius)
                    }
                }
                .rotationEffect(.degrees(currentDegree + degree))
                .gesture(dragGesture)
                Image(systemName: "arrowtriangle.left.fill")
                    .offset(x: radius, y: 0)
            }
        }
    }
    
    func getAngle(center : CGPoint, point : CGPoint) -> CGFloat {
        let deltaX = point.x - center.x
        let deltaY = point.y - center.y
        var theangle = atan(deltaY / deltaX) * 360 / (2 * CGFloat.pi)
        if deltaY > 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY < 0 && deltaX < 0 {
            theangle += 180
        } else if deltaY == 0 && deltaX < 0 {
            theangle = 180
        } else if deltaY == 0 && deltaX > 0 {
            theangle = 0
        } else if deltaY < 0 && deltaX > 0 {
            theangle += 360
        } else if deltaY < 0 && deltaX == 0 {
            theangle = 270
        }
        return theangle
    }
    
    func deltaDegree(center : CGPoint, startPoint : CGPoint, endPoint : CGPoint) -> CGFloat {
        let startDegree = getAngle(center: center, point: startPoint)
        let endDegree = getAngle(center: center, point: endPoint)
        return (endDegree - startDegree)
    }
    
    func getColor(degree : CGFloat) -> Color {
        let anglePerColor = 360.0 / CGFloat(colors.count)
        let fixDegree = (degree+360).truncatingRemainder(dividingBy: 360.0)
        return colors[colors.count-Int(fixDegree/anglePerColor)-1]
    }
}

struct WheelPickerExample : View {
    @State private var selectedColor : Color = .red
    var body: some View {
        ZStack {
            selectedColor.opacity(0.5)
            WheelPicker(selectedColor: $selectedColor)
        }
    }
}
5. 使用創建顏色選擇旋轉盤元件來改變星星的顏色。
struct WheelPickerExample : View {
    @State private var selectedColor : Color = .red
    var body: some View {
        ZStack {
            selectedColor.opacity(0.2)
            VStack {
                WheelPicker(selectedColor: $selectedColor)
                    .rotationEffect(.degrees(90))
                Image(systemName: "star.fill")
                    .resizable()
                    .scaledToFit()
                    .foregroundColor(selectedColor)
                    .padding()
            }
        }
    }
}