2つつまみがあるスライダーを実装してみた


iOS の UISlider ではつまみが1つで、これを2つにするには独自実装が必要そうだったのでやってみた。

完成したものは以下。

github: https://github.com/tanabee/RangeSlider ( Swift / xib )

OSS などあるが、そんなに難しくもないしデザインのカスタマイズもできるので独自実装したほうが良い気がした。

仕様

仕様は以下のようにした。
* 外から値の指定が可能
* 値が変更されたタイミングでデリゲートメソッドが呼ばれる
* 外側から指定したり参照できる値は 0-1 の Float 値
* つまみはそれぞれが衝突する位置まで移動できる
* IBDesinable/IBInspectable 対応

実装方針

  1. カスタムビューを作る(UIView)
  2. カスタムビューに背景のバー(グレー)を配置(UIView)
  3. カスタムビューに対して、背景のバーの Auto Layout 制約をつける
  4. 前面のバー(緑)を配置(UIView)
  5. 背景のバーに対して、前面のバーの Auto Layout 制約をつける
  6. つまみ(英語的には thumb というらしい)を 2 つ配置(UIView)
  7. 前面のバーに対して、つまみの Auto Layout 制約をつける
  8. つまみのドラッグを検知できるように UIPanGestureRecognizer を設定
  9. ドラッグの座標にしたがって 3 の Auto Layout の値を制御
  10. 9 のときに自動的につまみも移動する

ビューの構造的には以下の画像のようになる。

使い方

使い方は以下のように UIViewController などから使えるようにした。
tanabee/RangeSlider/ViewController.swift

class ViewController: UIViewController {
    @IBOutlet weak var rangeSlider: RangeSlider!

    override func viewDidLoad() {
        super.viewDidLoad()
        rangeSlider.delegate = self
    }
}

extension ViewController: RangeSliderDelegate {
    func rangeSliderValueChanged(left: Float, right: Float) {
        print(left, right)
    }
}

Interface builder 上で UIView をはりつけて、それの Class の欄に RangeSlider を指定する。IBDesinable を設定したので、どのようなデザインになるか Interface builder で確認できるはず。デリゲートを受け取るために、RangeSliderDelegate に準拠し、rangeSliderValueChanged を実装する。

ちょっと面倒だったところ

座標計算

以下の値を適宜、相互変換しなければならなかった。
* gestureRecognizer.locationInView() から得た座標
* Auto Layout の制約の Constant
* 0-1 の Float 値

UIPanGestureRecognizer の呼び出しタイミングがとびとびである

ドラッグ時に x 座標の変化を取り続けるが、速くドラッグした時に値が一気にジャンプする場合がある。
そのため、その場合の対応を入れなければならなかった。
tanabee/RangeSlider/RangeSlider.swift#L119-L130

    func didDragLeftThumb(gestureRecognizer: UIPanGestureRecognizer) {
        let x = gestureRecognizer.locationInView(backgroundBar).x

        if x < 0 {// ここで return だけしてしまうとちょうどゼロになりにくい
            leftConstraint.constant = 0
            valueChanged()
            return
        }
        if (x - rightConstraint.constant) >= backgroundBar.frame.width - thumbWidth &&
           gestureRecognizer.translationInView(backgroundBar).x > 0
        {// ここで return だけしてしまうと右のつまみと微妙に隙間が空いたまま止まってしまう
            leftConstraint.constant = backgroundBar.frame.width - thumbWidth + rightConstraint.constant
            valueChanged()
            return
        }

        leftConstraint.constant = x
        valueChanged()
    }

デバイスの回転対応

Auto Layout で座標を設定しているため、横幅が変わったタイミングで座標を再計算する必要がある。
また回転アニメーションに合わせて自然にアニメーションさせたほうが良いため UIView.animateWithDuration を使った。

tanabee/RangeSlider.swift#L171-L17

    func orientationChanged(notification: NSNotification) {
        self.layoutIfNeeded()
        UIView.animateWithDuration(0.3) {
            self.setValue(self.recentValue.0, right: self.recentValue.1)
            self.layoutIfNeeded()
        }
    }

この実装の不満点

タッチエリアの衝突

本来は操作性の観点から、つまみよりも大きいサイズのビューを用意して、そいつにドラッグイベントを設定したかったが、つまみ同士がちょうどくっつく地点でそれらのビューが重なりあって、下の方に配置されたビューのドラッグが効かなくなる。そのためつまみサイズ=タッチエリアとして実装した。

同じような理由でつまみ同士は完全にかぶさらないように衝突した位置で止める制御を入れた。

Auto Layout の constant が一部負の値になって計算しづらい

実装方針 5 の右側につけた制約が負の値になって、計算に手こずり、可読性も悪くなった。。。
本当はグレーの右端と緑の右端の X 座標の差が欲しかったが、
実際には、本当はグレーの右端と緑の右端の X 座標の差 x (-1) となってしまう。
制約の first item, second item を入れ替えてもダメだった。

まとめ

実装的に少し微妙なところもあるが、自分が求める挙動はできた。
つまみに画像を設定できるようにしたり、 IBInspectable の値を増やすなどすると良さそうと思ったが、現状に満足してしまった。
iOS 8 のサポートを切れれば UIStackView を使ってもっと簡単に実装できるかもしれない。

なにかご指摘等ありましたらコメントください!