UISliderのUXをトコトン追究して改善してみる

# UISliderって・・・？
UISliderはその名の通りスライダーコントロール機能を提供するUIKit標準のUIパーツで、
バー(**track**)とつまみ(**thumb**)で構成された値を調節する際などに便利なUIです:sparkles:

<img width="280" alt="slider.png" src="https://qiita-image-store.s3.amazonaws.com/0/128422/ee396a4b-201d-d394-d4b9-c138873665d4.png">


他のUIViewやUIButtonなどに比べると登場頻度は少ないかもしれませんが、
UISliderの特性を知っておいて損はないと思います:point_up:

本稿では標準のUISliderをサンプルコードを交えながらカスタムし、
UXや使い心地を向上してみたいと思います:sunglasses:

# UISliderはthumbのドラッグのみで調節可能
UISliderを用いた値の調節は白い丸のつまみ部分(**thumb**と呼びます)をドラッグして、
左右に動かすことで直感的な調節操作を実現しています。

UISliderはUIControlクラスを継承しており、
addTarget()することで様々なコントロールイベント(UIControlEvents)をフックすることが可能です。
UISliderで最も使用する頻度が高いUIControlEventsは、.valueChangedでしょう。
下記は、UISliderのvalueの変化をフックするサンプルコードです。
実行してつまみをドラッグすると値が断続的に取れているかと思います。

```swift
class ViewController: UIViewController {
    @IBOutlet weak var slider: UISlider! // InterfaceBuilderで定義

    override func viewDidLoad() {
        super.viewDidLoad()
        
        slider.addTarget(self, action: #selector(sliderDidChangeValue(_:)), for: .valueChanged)
    }
    
    @objc func sliderDidChangeValue(_ sender: UISlider) { // @IBActionでも可
        print(sender.value) // 0.0
    }
}
```

# UISliderの個人的にイケてないと感じるポイント
上で説明したように、デフォルトのUISliderは、
thumbをドラッグすることで値を調節することができます。
裏を返せば、**thumbをドラッグしなければ値を調節することができません**。

**thumbをつまもうとしてドラッグしたら、空振りして失敗してしまう**

皆さんも一度はこのような経験をされているのではないでしょうか？
何個かの改善方法とともにUISliderのユーザビリティを磨きこんでいきます:sparkles:

# 改善① UISliderのthumb以外をタップやドラッグしても調節可能にする
デフォルトでは、**UISliderのタップ領域 = thumbのタップ領域**です。
では、どのようなロジックでスライダーの調節開始を判定しているのでしょうか。

UISliderの親クラスであるUIControlのDocumentを見ると、
`beginTracking(_:with:)`というメソッドが用意されています。

```swift
func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool
```

このメソッドによって、スライダーの調節を開始するべきか判定しています。
おおよそ、デフォルトの実装はtouchされた座標がthumbの座標内にあるかというのを見て、
調節を開始させるかどうか判定しているのだと思われます。

API提供されてる範囲で実装してみるとこんな感じでしょうか。

```swift
func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
    // thumbのrectを算出する
    let thumbRect = self.thumbRect(
        forBounds: bounds,
        trackRect: trackRect(forBounds: bounds), // バー(track)のrect
        value: value
    )
    
    // tapした座標
    let tapPoint = touch.location(in: self)
    
    // tapした座標がthumbの矩形内に含まれていれば調節開始する
    return thumbRect.contains(tapPoint)
}
```

では、このメソッドを無条件にtrueを返却するようにoverrideするとどうなるでしょうか。
どんなtouchでもtrackingを開始するようになるため、
つまみ部分以外の領域を選択しても調節が可能となります。

```swift:つまみ(thumb)以外をタップしても調節可能なスライダー
class TappableSlider: UISlider {
    override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        return true // どんなtouchでもスライダー調節を行う
    }
}
```

実際に動かしてみるとこんな感じです！
つまみがない部分をタップしたりドラッグしたりできるようになっているのがわかるかと思います。

![TappableSlider.gif](https://qiita-image-store.s3.amazonaws.com/0/128422/b946bb23-3bd3-ac6a-c442-2fd6077493e8.gif)


# 改善② UISliderのタップ領域を広げる
①で説明したthumb以外をタップしても調節可能にする方法は、
UISliderの領域内であればどこでも選択可能となりました。

しかし、UISliderのbounds外をタップしても反応してくれません。
たとえば、こんな感じの目盛り付きのスライダーUIがあるとします。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/128422/37495680-6f67-ca60-604d-adda1af9fe53.png)
黄色い部分はUISliderのbounds領域です。

デフォルトではつまみの選択のみ、スライダーを動かすことができました。
改善①によって、黄色い部分のどこを選択してもスライダーが動くようになりました。
しかし、1-5の目盛りラベルを選択してもスライダーは動きません。
これを反応できるようにタップ領域を拡大してみましょう。

UISliderでタップ領域を決定しているメソッドは、UIControlの`point(inside:with:)`です。
つまり、**このメソッドでtrueが返却された後にbeginTracking(_:with:)が呼ばれます**。

```swift
func point(inside point: CGPoint, with event: UIEvent?) -> Bool
```

こちらのメソッドもDocumentを見る限り、
おおよそ、デフォルトの実装は、pointに入ってくる値が自身のbounds内にあるかどうかでしょう。

つまり、こんな感じだと想定されます。

```swift
func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    return bounds.contains(point) // pointがbounds内にあるかどうか
}
```

ラベル部分は自身のbounds外となるため、
そこを選択してもbeginTrackingが呼ばれませんでした。

では、ラベル部分も選択可能にするために、
UISliderのboundsより20px下まで判定を可能にしてみましょう。

```swift:下に20px広くタップが可能なスライダー
class WideTappableSlider: TappableSlider {
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        var wideBounds = bounds
        wideBounds.size.height += 20.0 // boundsを20.0px分下に拡張
        return wideBounds.contains(point) // pointがwideBounds内にあるかどうか
    }
}
```

これでどうでしょうか。
目盛り部分をタップするとその位置にスライダーを動かすことができるようになりました:tada:
![slider2.gif](https://qiita-image-store.s3.amazonaws.com/0/128422/537b6435-df42-437e-3fa7-469ad4629130.gif)

# 最後に
最後までお読みいただきありがとうございます:bow:
いかがだったでしょうか？

UISliderのUX改善について2つの方法をまとめてみました。
どちらの方法も親クラスのUIControlのメソッドをoverrideしているので、
UISlider以外のUIKitにも応用が可能だったりします。

他にもUISliderのUX改善法を知っている方がいましたら、
コメントしていただけると幸いです！

こうした細かいチューニングでアプリのUXをどんどん磨いていきましょう。

それでは:raised_hand:

# 参考リンク
- [beginTracking(_:with:) - UIControl | Apple Developer Documentation](https://developer.apple.com/documentation/uikit/uicontrol/1618227-begintracking)
- [point(inside:with:) - UIControl | Apple Developer Documentation](https://developer.apple.com/documentation/uikit/uiview/1622533-point)