UISliderの停止位置をInt値に固定する

# UISliderとは

UIKitで提供されている下記のようなUIパーツです。主につまみ部分(Thumb)をTrack上でドラッグすることで値を変えることができます。公式リファレンスの絵では、画面輝度の設定を想定したものになっています。

![UISliderの構成要素(公式リファレンスより)](https://docs-assets.developer.apple.com/published/e0791617a4/14f9f16e-55c0-474a-ae62-f42ebf2cab33.png)

# 使い勝手が微妙に悪い…

標準で提供されているのですが、いくつか使い勝手が悪い点があります。

- 値がFloat型になっており、Int型を扱いずらい
- 目盛りの設置が難しい

今回、この辺りを試行錯誤してみました。

# 完成版

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/9e9b8b39-7f44-a2de-bd45-a34cc7eaa0f0.gif)
サンプルプロジェクト
https://github.com/shcahill/IntSlider

### ポイント

ポイントは以下です。

- 最小値(`minimumValue`)は0固定
- 最大値(`maximumValue`)はInt値
- Sliderを中途半端な位置で止めた場合は四捨五入してInt値に丸めてThumbの位置を自動調整する
- １ごとに目盛りを配置
- 目盛りはAutoLayoutを使用しているため、縦横切り替えにも追従可能(TinyConstraints使用)

### あえて作り込まなかった点

また、今回のサンプルでは作り込まなかった点は以下のとおりです。

- 目盛りのデザインはカスタマイズできない
- コード上でInt以外の値を設定できてしまう

作り込まなかったのは私がサボっているだけですので、ご容赦ください…

# 実装方法

## Thumbの停止位置をInt値に固定する

やり方としては、ドラッグの終了を検知したタイミングで、Thumb(つまみ)の位置を強制的にInt値の位置へ移動させます。

#### ドラッグの終了イベントの検知

ドラッグイベントは`touchesEnded`をoverrideすることで検知できます。

```swift:IntSlider
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesEnded(touches, with: event)
    // ドラッグ終了後にThumb位置を調整する
    fixSliderPosition()
}
```

#### Thumbの位置を調整する

Sliderの値はFloat型ですので、Int型へ丸め込みます。（Thumbの位置はSliderのvalueに追従します）
Int型への丸め込みは `round`関数を使って四捨五入でやっていますが、利用シーンによっては切り捨て・切り上げに変更しても良いかと思います。ただ、Thumをドラッグしたときの操作感的には四捨五入が良いのではというのが私見です。

```swift:IntSlider
/// Slideの値変更通知(四捨五入して整数で通知されます)
var onValueChanged: ((Int) -> Void)?

private func fixSliderPosition() {
    // 現在の値を四捨五入でIntに丸める
    let index = round(self.value)
    self.value = index

    // コールバック通知
    onValueChanged?(Int(index))
}
```

`onValueChanged`はコールバック通知ですので、読み飛ばしていただいても問題ありません。
ポイントとしては、値の変更通知はInt型として通知を行っているところです。

## 目盛りの表示

Thumbの停止位置（Int値の部分）に目盛りとなるViewを配置します。
Sliderが何段階設定が可能なのか、どこで止まるのか、ということを示すには重要なパーツになります。

#### 目盛りの生成

まず、目盛りの生成箇所です。
`maximumValue`の値が変更される度に目盛りを作り直す必要があるため、目盛りのViewはフィールドで `labelList`として保持します。
また、目盛りはStackViewに詰め込み、等間隔で並べています。

```swift:IntSlider
// Max値変更の度に目盛りを作り直す必要があるため、フィールドで保持
private var labelList = [UIView]()

/// 目盛りを貼りなおします
func updateScaleLabel() {
    // 生成済みのラベルをすべて剥がす
    labelList.forEach({ $0.removeFromSuperview() })
    labelList.removeAll()

    // StackViewで目盛りを等間隔で配置する
    let labelArea = UIStackView()
    labelArea.axis = .horizontal
    labelArea.distribution = .equalSpacing
    labelArea.alignment = .fill
    insertSubview(labelArea, at: 0)
    let max = Int(maximumValue) + 1
    // 目盛りの数だけStackViewに詰め込む
    for _ in 0..<max {
        let label = createLabel()
        labelArea.addArrangedSubview(label)
        // 目盛りを作り直せるように、配列に保持する
        labelList.append(label)
    }

    /** 目盛りエリアの位置調整(後述) */
    // trackの少し下方に配置(offsetの16は適当)
    labelArea.centerYToSuperview(offset: 16)
    // track左右のマージン
    let offset = thumbCenterOffset
    labelArea.leadingToSuperview(offset: offset)
    labelArea.trailingToSuperview(offset: offset)
}
```

ここで少し面倒なのが、目盛り表示エリア(`labelArea`)の左右のマージン`thumbCenterOffset`の計算方法です。

#### 目盛り表示エリアの配置設定

目盛りのX方向の始点は、trackのboundsのstartXではありません。
目盛りのX方向中心位置は、ThumbのcenterXと一致している必要があります。よって以下のように`thumbCenterOffset`を計算します。

```swift:IntSlider
/// trackの左右両端に対する、thumb中心X座標のマージン
var thumbCenterOffset: CGFloat {
    // trackの始点
    let startOffset = trackBounds.origin.x
    // valueが０のときのThumb位置を計算
    let firstThumbPosition = positionX(at: 0)
    // track/Thumb/目盛りのサイズからoffsetを計算
    return firstThumbPosition - startOffset - labelSize / 2
}
    
/// [index]のときのthumbのX中心座標を取得します
func positionX(at index: Int) -> CGFloat {
    let rect = thumbRect(forBounds: bounds, trackRect: trackBounds, value: Float(index))
    return rect.midX
}
    
var trackBounds: CGRect {
    return trackRect(forBounds: bounds)
}
```

絵にすると以下の感じです。

![Slider.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/3cc8b201-5fc1-7ac3-0ec5-3672572f0fd8.png)

これでThumbの位置と目盛りの位置がすべて一致するようになります。

参考：[UISliderのThumbの表示領域(SizeやFrame)を計算するExtension](https://qiita.com/KikurageChan/items/7cd958576d4f939f321f)

#### 目盛りViewの生成

ここでは簡単にするために単純なドットにしていて、カスタマイズもできないようになっています。

```swift
private let labelSize: CGFloat = 4.0

func createLabel() -> UIView {
    let label = UIView()
    label.backgroundColor = .black
    label.layer.cornerRadius = CGFloat(labelSize / 2)
    label.width(labelSize)
    label.height(labelSize)
    return label
}
```

#### SliderのmaximumValueの値変更

SliderのmaximumValueの値が変更された場合は、目盛りの数と配置が変わるため、上記の`updateScaleLabel()`を呼ぶ必要があります。

```swift:IntSlider
func updateMaxValue(_ max: Int) {
    maximumValue = Float(max)
    value = min(value, maximumValue)
    updateScaleLabel()
}
```

ここでは関数化しましたが、`maximumValue`のdidSetで実行するのもいいかもしれません。（その場合、Float型をInt型に補正する必要がありますが。）


## Thumb部分以外でもドラッグ可能にする

詳細はこちらの記事が参考になります。
[UISliderのUXをトコトン追究して改善してみる](https://qiita.com/ruwatana/items/01a9c43057f71560580f#%E6%94%B9%E5%96%84-uislider%E3%81%AEthumb%E4%BB%A5%E5%A4%96%E3%82%92%E3%82%BF%E3%83%83%E3%83%97%E3%82%84%E3%83%89%E3%83%A9%E3%83%83%E3%82%B0%E3%81%97%E3%81%A6%E3%82%82%E8%AA%BF%E7%AF%80%E5%8F%AF%E8%83%BD%E3%81%AB%E3%81%99%E3%82%8B)
必要なコードだけを抜き出すと以下のようになります。

```swift:IntSlider
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
    // つまみ部分以外でもスライド可能
    return true
}
```

※iOS12以降では下記の対応が必要のようです。

https://qiita.com/n_komiya/items/15d3d755e2bed46b5da2

# 完成

以上をまとめると、コード全体は以下のようになります。

```swift:IntSlider
import UIKit
import TinyConstraints

final class IntSlider: UISlider {
    private let labelSize: CGFloat = 4.0
    /// Slideの値変更通知(四捨五入して整数で通知されます)
    var onValueChanged: ((Int) -> Void)?
    private var labelList = [UIView]()
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder:aDecoder)
        setup(max: 1)
    }
    
    private func setup(max: Int) {
        minimumValue = 0
        maximumValue = Float(max)
        
        // リアルタイムの値変更通知
        addTarget(self, action: #selector(onChange), for: .valueChanged)
    }
    
    override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        // つまみ部分以外でもスライド可能
        return true
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        // スライド終了後に位置を調整する
        fixSliderPosition()
    }

    @objc func onChange(_ sender: UISlider) {
        // スライダーの値が変更された時の処理
        onValueChanged?(Int(round(sender.value)))
    }
    
    func updateMaxValue(_ max: Int) {
        maximumValue = Float(max)
        value = min(value, maximumValue)
        updateScaleLabel()
    }
}

private extension IntSlider {
    func fixSliderPosition() {
        let index = round(self.value)
        self.value = index
        onValueChanged?(Int(index))
    }
    
    /// 目盛りを貼りなおします
    func updateScaleLabel() {
        labelList.forEach({ $0.removeFromSuperview() })
        labelList.removeAll()
        let labelArea = UIStackView()
        labelArea.axis = .horizontal
        labelArea.distribution = .equalSpacing
        labelArea.alignment = .fill
        insertSubview(labelArea, at: 0)
        // trackの少し下方
        labelArea.centerYToSuperview(offset: 16)
        // 左右のマージン
        let offset = thumbCenterOffset
        labelArea.leadingToSuperview(offset: offset)
        labelArea.trailingToSuperview(offset: offset)
        let max = Int(maximumValue) + 1
        for _ in 0..<max {
            let label = createLabel()
            labelArea.addArrangedSubview(label)
            labelList.append(label)
        }
    }
    
    /// 目盛りViewの生成
    func createLabel() -> UIView {
        let label = UIView()
        label.backgroundColor = .black
        label.layer.cornerRadius = CGFloat(labelSize / 2)
        label.width(labelSize)
        label.height(labelSize)
        return label
    }
    
    /// trackの左右両端に対する、thumb中心X座標のマージン
    var thumbCenterOffset: CGFloat {
        let startOffset = trackBounds.origin.x
        let firstThumbPosition = positionX(at: 0)
        return firstThumbPosition - startOffset - labelSize / 2
    }
    
    /// [index]のときのthumbのX中心座標を取得します
    func positionX(at index: Int) -> CGFloat {
        let rect = thumbRect(forBounds: bounds, trackRect: trackBounds, value: Float(index))
        return rect.midX
    }
    
    var trackBounds: CGRect {
        return trackRect(forBounds: bounds)
    }
}
```