UISliderとは
UIKitで提供されている下記のようなUIパーツです。主につまみ部分(Thumb)をTrack上でドラッグすることで値を変えることができます。公式リファレンスの絵では、画面輝度の設定を想定したものになっています。
使い勝手が微妙に悪い…
標準で提供されているのですが、いくつか使い勝手が悪い点があります。
- 値がFloat型になっており、Int型を扱いずらい
- 目盛りの設置が難しい
今回、この辺りを試行錯誤してみました。
完成版
サンプルプロジェクト
https://github.com/shcahill/IntSlider
ポイント
ポイントは以下です。
- 最小値(
minimumValue
)は0固定 - 最大値(
maximumValue
)はInt値 - Sliderを中途半端な位置で止めた場合は四捨五入してInt値に丸めてThumbの位置を自動調整する
- 1ごとに目盛りを配置
- 目盛りはAutoLayoutを使用しているため、縦横切り替えにも追従可能(TinyConstraints使用)
あえて作り込まなかった点
また、今回のサンプルでは作り込まなかった点は以下のとおりです。
- 目盛りのデザインはカスタマイズできない
- コード上でInt以外の値を設定できてしまう
作り込まなかったのは私がサボっているだけですので、ご容赦ください…
実装方法
Thumbの停止位置をInt値に固定する
やり方としては、ドラッグの終了を検知したタイミングで、Thumb(つまみ)の位置を強制的にInt値の位置へ移動させます。
ドラッグの終了イベントの検知
ドラッグイベントはtouchesEnded
をoverrideすることで検知できます。
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をドラッグしたときの操作感的には四捨五入が良いのではというのが私見です。
/// 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に詰め込み、等間隔で並べています。
// 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
を計算します。
/// trackの左右両端に対する、thumb中心X座標のマージン
var thumbCenterOffset: CGFloat {
// trackの始点
let startOffset = trackBounds.origin.x
// valueが0のときの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)
}
絵にすると以下の感じです。
これでThumbの位置と目盛りの位置がすべて一致するようになります。
参考:UISliderのThumbの表示領域(SizeやFrame)を計算するExtension
目盛りViewの生成
ここでは簡単にするために単純なドットにしていて、カスタマイズもできないようになっています。
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()
を呼ぶ必要があります。
func updateMaxValue(_ max: Int) {
maximumValue = Float(max)
value = min(value, maximumValue)
updateScaleLabel()
}
ここでは関数化しましたが、maximumValue
のdidSetで実行するのもいいかもしれません。(その場合、Float型をInt型に補正する必要がありますが。)
Thumb部分以外でもドラッグ可能にする
詳細はこちらの記事が参考になります。
UISliderのUXをトコトン追究して改善してみる
必要なコードだけを抜き出すと以下のようになります。
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
// つまみ部分以外でもスライド可能
return true
}
※iOS12以降では下記の対応が必要のようです。
完成
以上をまとめると、コード全体は以下のようになります。
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)
}
}