[SwiftUI] 中央から始まるSliderを実装してみた。

# はじめに

SwiftUIの標準コンポーネントの[Slider](https://developer.apple.com/documentation/swiftui/slider)はこのような実装になります。

```swift
Slider(value: $value,
              in: -100...100,
              step: 1,
              onEditingChanged: { _ in
})
.frame(width:300, height:30)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92539/9a2eaa4a-4ef7-a1d9-0af1-bf74b3d9996c.png)

しかし、標準Sliderでは、中央を0とし、そこ始まり、左右に移動するとバーの色が伸びていくSliderは実装できません。
そこで、今回は調べながらそれっぽいのを作ってみたという記事になります。

:::note warn
注意
若干未完成で、少し微妙なところが残っています。
修正アイデア頂ける方は、コメント頂けると助かります。
:::

まず実装したコンポーネントの挙動はこのような感じです。
![CenterOriginSlider.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92539/ffd755fd-4339-97d6-e776-0b76783e4e15.gif)

全体のコードはこのようになりました。

```swift:CenterOriginSlider.swift
struct CenterOriginSlider: View {
    typealias onEditingChangedType = () -> Void
    @Binding var value: Double

    private var bounds: ClosedRange<Double>
    private var tintColor: Color
    private var onEditingChanged: onEditingChangedType

    private enum ThumbDirection {
        case left, right
    }

    init(value: Binding<Double>,
         in bounds: ClosedRange<Double> = 1...100,
         tint: Color = .accentColor,
         onEditingChanged: @escaping onEditingChangedType = {}) {
        self._value = value
        self.bounds = bounds
        self.tintColor = tint
        self.onEditingChanged = onEditingChanged
    }

    var body: some View {
        GeometryReader { gr in
            let adjustValue = 5.0
            let minValue = adjustValue
            let maxValue = gr.size.width - adjustValue
            let scale = (maxValue - minValue) / (bounds.upperBound - bounds.lowerBound)
            let lower = bounds.lowerBound
            let sliderValue = ((value - minValue - lower) * scale)
            let valueBarWidth = fabs(value) * scale
            let thumbDirection: ThumbDirection = value < 0 ? .left : .right

            ZStack {
                TrackBar(width: gr.size.width)
                ValueTrackBar(valueWidth: valueBarWidth,
                              barWidth: maxValue,
                              thumbDirection: thumbDirection,
                              color: tintColor)
                Thumb(value: $value,
                      offset: sliderValue,
                      minValue: minValue,
                      maxValue: maxValue,
                      scale: scale,
                      lower: lower) {
                    onEditingChanged()
                }
            }
        }
    }

    private struct TrackBar: View {
        let width: Double
        private let height = 4.0
        private let color = Color(white: 0.9, opacity: 1.0)

        var body: some View {
            RoundedRectangle(cornerRadius: height)
                .fill(color)
                .frame(width: width,
                       height: height)
        }
    }

    private struct ValueTrackBar: View {
        let valueWidth: Double
        let barWidth: Double
        let barheight = 4.0
        let thumbDirection: ThumbDirection
        let color: Color

        var body: some View {
            HStack {
                if thumbDirection == .left {
                    Spacer()
                }
                Rectangle()
                    .fill(color)
                    .frame(width: valueWidth, height: barheight)
                    .padding(thumbDirection == .left ? .trailing : .leading, barWidth * 0.5)
                if thumbDirection == .right {
                    Spacer()
                }
            }
        }
    }

    private struct Thumb: View {
        @Binding var value: Double
        let offset: Double
        let minValue: Double
        let maxValue: Double
        let scale: Double
        let lower: Double
        let step: Double = 1.0
        let onEditingChanged: () -> Void
        @State private var lastCoordinate: Double = 0.0
        private let color = Color.white

        var body: some View {
            HStack {
                Circle()
                    .fill(.white)
                    .frame(width: 26)
                    .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 4)
                    .offset(x: offset)
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .onChanged { value in
                                let translationWidth = value.translation.width
                                if abs(translationWidth) < 0.1 {
                                    lastCoordinate = offset
                                }
                                guard abs(translationWidth) > 0.1 else { return }
                                let distance = lastCoordinate + translationWidth
                                let nextCoordinate = translationWidth > 0 ? min(maxValue, distance) : max(minValue, distance)
                                self.value = ((nextCoordinate - minValue) / scale) + lower
                                onEditingChanged()
                            }
                    )
                Spacer()
            }
        }
    }
}
```

使用するときは、このようになります。

```swift:ContentView.swift
struct ContentView: View {

    @State private var value: Double = 0

    var body: some View {

        VStack {
            CenterOriginSlider(value: $value,
                               in: -100...100,
                               onEditingChanged: {
            })
            .frame(width:300, height: 30)
            Text("value: \(value)")
        }.padding()
    }
}
```

# 解説

今回作ったコンポーネントは大きく3つのコンポーネントに分解できます。
一番親となる`CenterOriginSlider`のbody内を見てもらうと、`TrackBar`、`ValueTrackBar`、`Thumb`が`Ztack`内に登録しています。

```swift
ZStack {
    TrackBar(width: gr.size.width)
    ValueTrackBar(valueWidth: valueBarWidth,
                  barWidth: maxValue,
                  thumbDirection: thumbDirection,
                  color: tintColor)
    Thumb(value: $value,
          offset: sliderValue,
          minValue: minValue,
          maxValue: maxValue,
          scale: scale,
          lower: lower) {
        onEditingChanged()
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92539/d8d14a88-afec-089b-4978-69bac1828fd4.png)

## ポイント1

まず`ValueTrackBar`で行っている、"中央から伸びた分だけ色を付ける"というのをどう実装していくかがポイントになります。

今回は、`Spacer`と`padding`を使い、マイナス方向とプラス方向で、配置を変えることで実現しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92539/b0b036d6-32a3-880e-828c-620ff0247f1e.png)

```swift
HStack {
    if thumbDirection == .left {
        Spacer()
    }
    Rectangle()
        .fill(color)
        .frame(width: valueWidth, height: barheight)
        .padding(thumbDirection == .left ? .trailing : .leading, barWidth * 0.5)
    if thumbDirection == .right {
        Spacer()
    }
}
```

## ポイント2

次のポイントが`Thumb`の`DragGesture`によって変化量を計算する部分で、この部分は[こちらの記事](https://swdevnotes.com/swift/2021/how-to-customise-the-slider-in-swiftui/)を参考にさせてもらいながら、実装しましたが、若干カクついたりする場合があり、その補正が自分でもまだクリアになっていないところです。

```swift
Circle()
    .fill(.white)
    .frame(width: 26)
    .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 4)
    .offset(x: offset)
    .gesture(
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                let translationWidth = value.translation.width
                if abs(translationWidth) < 0.1 {
                    lastCoordinate = offset
                }
                guard abs(translationWidth) > 0.1 else { return }
                let distance = lastCoordinate + translationWidth
                let nextCoordinate = translationWidth > 0 ? min(maxValue, distance) : max(minValue, distance)
                self.value = ((nextCoordinate - minValue) / scale) + lower
                onEditingChanged()
            }
    )
```

`if abs(translationWidth) < 0.1`で `lastCoordinate`を保持することで、タップした瞬間の位置を保持していて、これがないと、Dragした瞬間に移動してしまったりします。
このあたりもっとスマートな実装があればコメント頂けると嬉しいです。

## 微妙なところ

標準のSliderと並べるとこんな感じで、近い見た目にはなりましたが、より標準のSliderに寄せるにはまだ課題が残っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92539/50e6bf16-1cee-7f9f-20bc-c28fb7e24fcf.png)

- Darkモードのときに`TabBar`の色を変える
- `Thumb`の変化量が違う
- `step`に対応していない

# おわりに

ということで、課題がありますが、それっぽいものを作ることができたのと、意外と面倒なことがわかりました。