🙄

SwiftUIで丸いスライダー(CircleSlider)を作成する方法

2021/10/21に公開

丸いスライダー

この記事でいう丸いスライダー(CircleSlider)とは以下のようなものです。

altテキスト

SwiftUIで実装

Circle()DragGesture()を使って実装可能です。

import SwiftUI

struct CircleSlider: View {
    @State var angle: Double = 0.5
    
    let width: CGFloat = 250
    let height: CGFloat = 250

    let beginAngle: Double = 0.1
    let endAngle: Double = 0.9
    let minimumValue: Double = 0
    let maximumValue: Double = 100
    
    var value: Int {
        let percent = (self.angle - self.beginAngle) / (self.endAngle - self.beginAngle)
                
        var value = (maximumValue - minimumValue) * percent + minimumValue
        
        if value < minimumValue {
            value = minimumValue
        }
        
        if maximumValue < value {
            value = maximumValue
        }
        
        return Int(value)
    }
        
    func onChanged(value: DragGesture.Value) {
        let vector = CGVector(dx: value.location.x, dy: value.location.y)
        
        // 中央からタップ位置までの距離
        let distanceX: Double = self.width  / 2 - vector.dx
        let distanceY: Double = self.height / 2 - vector.dy

        // Circle()の中央からタップ位置のラジアンアークタンジェント2を求める
        let radians: Double = atan2(distanceX, distanceY)
        
        let center: Double = (self.endAngle + self.beginAngle) / 2.0
        
        let angle: Double = center - (radians / (2.0 * .pi))
        
        // アニメーションをつけているが、つけなくてももちろん問題ない
        withAnimation(Animation.linear(duration: 0.1)){
            self.angle = self.endAngle < angle ? self.endAngle : angle
        }
    }
    
    var body: some View {
        ZStack {
            Text(String(self.value))
            Circle()
                .trim(from: self.beginAngle, to: self.endAngle)
                .stroke(Color.black, lineWidth: 20)
                .frame(width: self.width, height: self.height)
                .rotationEffect(.init(degrees: 90))
                .gesture(
                    DragGesture().onChanged(self.onChanged(value:))
                )
            Circle()
                .trim(from: self.beginAngle, to: self.angle)
                .stroke(Color.orange, lineWidth: 20)
                .frame(width: self.width, height: self.height)
                .rotationEffect(.init(degrees: 90))
                .gesture(
                    DragGesture().onChanged(self.onChanged(value:))
                )
        }
    }
}

使用例(1)

let beginAngle: Double = 0.0
let endAngle: Double = 1.0
let minimumValue: Double = 0
let maximumValue: Double = 100

altテキスト

使用例(2)

let beginAngle: Double = 0.25
let endAngle: Double = 0.75
let minimumValue: Double = 0
let maximumValue: Double = 100

altテキスト

使用例(3)

エアコンなどの最低値と最大値が決まっているパターン

let beginAngle: Double = 0.15
let endAngle: Double = 0.85
let minimumValue: Double = 19
let maximumValue: Double = 30

altテキスト

つまづいたところ

  • もうatan2(アークタンジェント)の使い方とかを忘れていた。(久しぶりに数学をした気分)
  • スライダーがMin(0), Max(100)までいかないことがあった。
  • Circleは最初から傾いている(最初に90°回転させてあげる必要がある)
  • スライダーをMin(0), Max(100)にするとジェスチャーが動作しなくなる(背景のCircleにもGestureを実装で回避)

参考にしたもの

https://qiita.com/arthur87/items/23d3c896dafbc8223fd5

https://www.youtube.com/watch?v=pzU-R4189yQ

より使いやすく

CircleSliderの基本は理解できたのでBinding(@State)を使えるように改良してみましょう


import SwiftUI

import SwiftUI

struct ContentView: View {
    @State var value: Double = 21
    
    var body: some View {
        ZStack {
            VStack {
                Text("\(self.value)")
                Text("\(Int(self.value))")
                Text("\(String(format: "%.2f", self.value))")
            }
            
            CircleSlider(self.$value, beginAngle: 0.1, endAngle: 0.9, minimumValue: 16, maximumValue: 30)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct CircleSlider: View {
    @Binding private var bindingValue: Double
    
    @State private var angle: Double = 0
    
    private let width: CGFloat
    private let height: CGFloat
    
    private let beginAngle: Double
    private let endAngle: Double
    
    private let minimumValue: Double
    private let maximumValue: Double
    
    
    private var value: Double {
        let percent: Double = (self.angle - self.beginAngle) / (self.endAngle - self.beginAngle)
        
        var value: Double = (self.maximumValue - self.minimumValue) * percent + self.minimumValue
        
        if value < self.minimumValue {
            value = self.minimumValue
        }
        
        if self.maximumValue < value {
            value = self.maximumValue
        }
        
        return value
    }
    
    private func calculateAngle(from value: Double) -> Double {
        let percent: Double = (value - self.minimumValue) / (self.maximumValue - self.minimumValue)
                
        let angle: Double = percent * (self.endAngle - self.beginAngle) + self.beginAngle
                
        return angle
    }
    
    public init(_ binding: Binding<Double>,
                width: CGFloat = 250, height: CGFloat = 250,
                beginAngle: Double = 0.0, endAngle: Double = 1.0,
                minimumValue: Double = 0, maximumValue: Double = 100) {
        self._bindingValue = binding
        
        self.width = width
        self.height = height
        
        self.beginAngle = beginAngle
        self.endAngle = endAngle
        
        self.minimumValue = minimumValue
        self.maximumValue = maximumValue
    }
    
    private func onChanged(value: DragGesture.Value) {
        let vector = CGVector(dx: value.location.x, dy: value.location.y)
        
        // 中央からタップ位置までの距離
        let distanceX: Double = self.width  / 2 - vector.dx
        let distanceY: Double = self.height / 2 - vector.dy
        
        // Circle()の中央からタップ位置のラジアンアークタンジェント2を求める
        let radians: Double = atan2(distanceX, distanceY)
        
        let center: Double = (self.endAngle + self.beginAngle) / 2.0
        
        let angle: Double = center - (radians / (2.0 * .pi))
        
        // アニメーションをつけているが、つけなくてももちろん問題ない
        withAnimation(Animation.linear(duration: 0.1)){
            self.angle = self.endAngle < angle ? self.endAngle : angle
        }
    }
    
    var body: some View {
        ZStack {
            Circle()
                .trim(from: self.beginAngle, to: self.endAngle)
                .stroke(Color.black.opacity(0.2), lineWidth: 20)
                .frame(width: self.width, height: self.height)
                .rotationEffect(.init(degrees: 90))
                .gesture(
                    DragGesture().onChanged(self.onChanged(value:))
                )
            Circle()
                .trim(from: self.beginAngle, to: self.angle)
                .stroke(Color.orange, lineWidth: 20)
                .frame(width: self.width, height: self.height)
                .rotationEffect(.init(degrees: 90))
                .gesture(
                    DragGesture().onChanged(self.onChanged(value:))
                )
        }
        .onChange(of: self.value) { value in
            self.bindingValue = value
        }
        .onAppear {
            self.angle = self.calculateAngle(from: self.bindingValue)
        }
    }
}

Discussion