🙄
SwiftUIで丸いスライダー(CircleSlider)を作成する方法
丸いスライダー
この記事でいう丸いスライダー(CircleSlider)とは以下のようなものです。
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
使用例(2)
let beginAngle: Double = 0.25
let endAngle: Double = 0.75
let minimumValue: Double = 0
let maximumValue: Double = 100
使用例(3)
エアコンなどの最低値と最大値が決まっているパターン
let beginAngle: Double = 0.15
let endAngle: Double = 0.85
let minimumValue: Double = 19
let maximumValue: Double = 30
つまづいたところ
- もうatan2(アークタンジェント)の使い方とかを忘れていた。(久しぶりに数学をした気分)
- スライダーがMin(0), Max(100)までいかないことがあった。
- Circleは最初から傾いている(最初に90°回転させてあげる必要がある)
- スライダーをMin(0), Max(100)にするとジェスチャーが動作しなくなる(背景のCircleにもGestureを実装で回避)
参考にしたもの
より使いやすく
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