この記事はiOS Advent Calendar 2024への寄稿です。
背景
最近のiOS標準のビデオプレイヤーを使ったことある方は、そのプレイヤーの再生プログレス調整スライダーが如何によくできているかわかると思います。あれは標準コンポーネントのスライダーと違い、いわゆる「つまみ」と言うものがありません;その代わり、指はそのスライダーのどこからでもスワイプでき、それで再生プログレスの移動が可能になっています;それどころか、指を上下に動かしてスライダーからちょっと離したことによって、再生プログレスの微調整まで可能です。
ところが残念ながら、このプログレススライダーは標準コンポーネントとして提供されていません。と言うわけでないものは作ればいい精神で、今回はこのプログレススライダーを自作してみようと思います。
基本実装
表示
まずはプログレススライダーの表示の仕方です。これは割と簡単で、二層のビューを重ねて、上の層をプログレスに応じて表示をクリッピング(例えばプログレスが60%なら、左の60%だけ表示する)して、最終的に両端を角丸にすればいいだけかと思います。ここで唯一の課題は、そのプログレスに応じたクリッピングをする標準APIがないところですが、SwiftUIには .clipShape
と言うModifierがあって、Shape
を渡してあげれば、その形に沿ったクリッピングができるから、つまりプログレスに応じた Shape
を作れば目的が達成できます。と言うわけで早速こんな Shape
を作ってみましょう:
import SwiftUI
struct ProgressShape: Shape {
var progress: Double
func path(in rect: CGRect) -> Path {
// ...
}
}
これで最低限の Shape
の宣言ができました。progress
を渡してもらえば、そのプログレスに応じた形を描画できるはずです。と言うわけでその描画の具体的な実装 func path(in:)
を入れてあげましょう。
この path(in:)
メソッドは rect
と言う引数を持ち、これは具体的なこの Shape
を置く場所の領域なので、すなわちこの領域の左端から、progress
分の幅の Path
を描けばできますよね、と言うわけで早速書いてみましょう:
func path(in rect: CGRect) -> Path {
+ let end = rect.width * progress
+ return .init { path in
+ path.move(to: .zero)
+ path.addLine(to: .init(x: end, y: 0))
+ path.addLine(to: .init(x: end, y: rect.height))
+ path.addLine(to: .init(x: 0, y: rect.height))
+ path.closeSubpath()
+ }
}
上記のコードでは、rect.origin
、すなわち描画領域の原点は必ず CGPoint.zero
と仮定しています。以前の UIKit
においては、ビューの内部領域 bound
は必ず CGRect.zero
が原点で、それ以外の場所を原点にしてしまうと逆に描画がずれてしまいます。ところが Shape
の path(in:)
の rect
引数については外部視点なのか内部視点なのかドキュメントでは説明がなく、筆者が色々試してみたところおそらく内部視点と見ていいと判断した故、直接 move(to: .zero)
と指定しています。もしこの rect
は違う意味だよ、とご存じの方がいらっしゃれば、ぜひ詳しく教えていただけると助かります。
今回の本題ではないのでここで割愛しますが、Path
の使い方についてはこちらの記事がとても詳しく書かれているので、ぜひご参考ください。
これで、たとえば progress
を 0.6
に設定してプレビューしてみると、確かに横幅の60%くらいの描画がされているとわかりますね
#Preview {
ProgressShape(progress: 0.6)
.fill(Color.red)
.frame(width: 300, height: 30)
.background(Color.blue)
}
ProgressShape
ができたらあとは簡単です、二層のビューを重ねて、上の層をこの ProgressShape
でクリッピングして、あとは高さ設定して全体で .capsule
でクリッピングするだけです。では、その二重の層はどう作ればいいのか?今回の記事はあくまで思考のアプローチをご紹介したいので、アップルの公式の実装に完全に寄せるわけではないから、とりあえず雑に Color.white.opacity(0.5)
を二重に重ねるだけにしようと思います:
import SwiftUI
struct ProgressSlider: View {
var progress: Double
var body: some View {
ZStack {
Color.white.opacity(0.5)
Color.white.opacity(0.5)
.clipShape(ProgressShape(progress: progress))
}
.frame(maxHeight: 20)
.clipShape(.capsule)
}
}
これでプレビューしてみると、こんなものが出ると思います:
#Preview {
ProgressSlider(progress: 0.5)
.background(Color.red)
}
お、それっぽくなってきましたね!
動き
ところが、これではまだプログレスを動かせない、このままでは「スライダー」とは呼べない。と言うわけで動かす仕組みを入れましょう。
まずは動かすことを可能にするには、.gesture
Modifierを使って Gesture
を受け付けさせる必要があります。Gesture
は protocol
で、普段の開発では TapGesture
や DragGesture
などがよく利用されるかと思いますが、今回のユースケースではまさに DragGesture
が一番適してますね。ただ実際にいざ使ってみたらわかると思いますが、DragGesture
はそのまま使いにくいです、なぜならこのジェスチャーがそのまま取れるのは、ジェスチャー開始時の座標、現在の座標、そして上記の2つの間の移動量ですが、これらの値は全部絶対値です;ところが我々が制御したい progress
はスライダーの幅に応じた相対値です。またそれ以外にも、ジェスチャー開始時のプログレスを自分で取っておかないと、移動量をそのままプログレスに足してあげては今までの積み重ねが全部入ってしまいます。と言うわけで、このジェスチャーをもうちょっと使いやすいように、カスタマイズしたいと思います:
import SwiftUI
struct ProgressDragGesture: Gesture {
@Binding var progress: Double
var canvasWidth: CGFloat
@State private var _progressOnGestureStart: Double?
private var progressOnGestureStart: Double {
if let _progressOnGestureStart {
return _progressOnGestureStart
} else {
_progressOnGestureStart = progress
return progress
}
}
var body: some Gesture {
DragGesture()
.onChanged { value in
let diff = value.translation.width / canvasWidth
let newProgress = (progressOnGestureStart + diff).clamped(in: 0...1)
progress = newProgress
}
}
}
この ProgressDragGesture
は、操作したい progress
を @Binding
で受け取り、必要に応じて上書きします;またさらに canvasWidth
を受け取ることで、移動量の絶対値から、移動量の相対値を計算して、さらにジェスチャー開始時の progressOnGestureStart
を保持することで、指を動かすときの新たな progress
を計算できます。
ところがここで一つだけ存在しないAPIを書いています:clamped(in:)
です。これは progress
の範囲をちゃんと 0
と 1
の間に保証したいから、計算した newProgress
がもしその範囲を超えた場合、その範囲内に収めるように直した数値を返す処理です。この処理はこのように書いています:
private extension Numeric where Self: Comparable {
func clamped(in range: ClosedRange<Self>) -> Self {
if self < range.lowerBound {
return range.lowerBound
} else if self > range.upperBound {
return range.upperBound
} else {
return self
}
}
}
さて、ここまでできたら、このジェスチャーを実際プレビューで試してみましょう、これで赤い部分をマウスでドラッグしてみると、下の数字がそれっぽく変更されてるのがわかるかと思います:
#Preview {
@Previewable @State var progress: Double = 0.5
VStack {
GeometryReader { proxy in
Color.red
.gesture(ProgressDragGesture(progress: $progress, canvasWidth: proxy.size.width))
}
Text("\(progress)")
}
}
合体
さて、これでジェスチャーができたので、早速 ProgressSlider
に入れてみましょう。先ほどの ProgressSlider
は最低限のプレビューだけ作ったので、progress
は可変ではないから、まずそれを @Binding
に変更します;次にスライダーの幅をとりたいので、一番簡単の方法として GeometryReader
使ってキャンバスをサイズをとりましょう。と言うわけでこれがこうなります:
struct ProgressSlider: View {
- var progress: Double
+ @Binding var progress: Double
var body: some View {
+ GeometryReader { proxy in
ZStack {
Color.white.opacity(0.5)
Color.white.opacity(0.5)
.clipShape(ProgressShape(progress: progress))
}
+ .gesture(ProgressDragGesture(
+ progress: $progress,
+ canvasWidth: proxy.size.width
+ ))
+ }
.frame(maxHeight: 20)
.clipShape(.capsule)
}
}
これで progress
が可変になり、さらに GeometryReader
の力でキャンバス全体の幅も取れるから、ProgressDragGesture
も楽に使えますね。これでプレビューしてみると、ドラッグでスライダーがいい感じに動いてくれるかと思います:
#Preview {
- ProgressSlider(progress: 0.5)
+ @Previewable @State var progress: Double = 0.5
+ ProgressSlider(progress: $progress)
.background(Color.red)
}
更に改善
スライディングの微調整
ここまでできたら、すでにスライダーとして気持ちよく使えるものかと思いますが、ただこれだとまだ足りない部分があります。そう、iOS標準のビデオプレイヤーのプログレススライダーは、指を上下に動かしてスライダー本体からちょっと話すと、左右のスライドでプログレスの微調整が可能です。これは特に長い動画などの場合、細かい再生位置の調整ができてとても使いやすいです。と言うわけで次はこの機能をつけてあげましょう。
この機能の理屈は非常に単純です:ドラッグ時の移動量が取れるから、それのY方向の移動量を取って、数値が大きければ大きいほど、progress
の調整を細かくしてあげればいいです。ただしそのためには、ドラッグジェスチャー全体の移動量ではなく、ドラッグ途中途中の毎回のX方向の移動量を取る必要があります。そうでないと、progress
の変更量がドラッグ開始時の値からの変更にしかできませんが、実際iOS標準のビデオプレイヤーのプログレススライダー触ってみるとわかりますが、あの変更量は直前の値からの変更になります。そうしないと目的の場面の近くまでドラッグしたら、一回指を離してもう一回ドラッグしないと微調整できないから不便ですからね。ただし結局 progressOnGestureStart
からの移動量を取る必要があるから、それらの差分を更に積分した移動量を取る必要もあります。と言うわけで、まずはジェスチャー実行中の直前のX座標を保持するプロパティーと、毎回の差分を積分したX座標の移動量を保持するプロパティーを追加しておきましょう:
struct ProgressDragGesture: Gesture {
// ...
+ @State private var lastDraggingLocationX: CGFloat?
+ @State private var scaledTranslationX: CGFloat = 0
}
次に微調整の具体的なアルゴリズムですが、これに関しては人それぞれだと思います。translation.y
の移動量を段階的に取って progress
の変更量を変えるのもいいし、簡単にそれを一次関数的な感じで translation.y
の移動量に比例して progress
の変更量を変えるのもいいです。iOSの純正の実装がどうなっているのかはわかりませんが、一次関数的な関係ではなさそうです、なぜならちょっとだけY方向を移動しても調整量は対して変わらないようですが、もうちょっとY方向を移動したら調整量がかなり細かくなるのはかなり肌感覚でわかるくらいなので、どっちかというと段階的な調整っぽい気がします;ただ筆者的には段階的に取るのもなんか面倒な気がしますので、ここは二次関数で取りたいと思います:Y方向の移動量を自乗してから適当な係数を掛けて、それを1で減らして progress
の調整量にしたいと思います。
- @State private var lastDraggingLocationX: CGFloat?
+ @State private var _lastDraggingLocationX: CGFloat?
+ func scaledTranslationFromLastDragging(against current: DragGesture.Value) -> CGFloat {
+
+ defer {
+ _lastDraggingLocationX = current.location.x
+ }
+ guard let lastX = _lastDraggingLocationX else {
+ return current.translation.width
+ }
+
+ let originalDraggingDiff = current.location.x - lastX
+ let scaleSubtrahend = current.translation.height.magnitudeSquared / 10000
+ let scale = (1 - scaleSubtrahend).clamped(in: 0.01 ... 1)
+ let scaledDraggingDiff = originalDraggingDiff * scale
+ return scaledDraggingDiff
+
+ }
この処理では、まず終了時に必ず現在の location.x
を lastDraggingLocationX
に代入して、次の呼び出しの時のために使えるようにします。ただしもし現在値が無ければ、その前にそのまま translation.width
を返します。逆にもし値が取れたら、それと現在の location.x
と比較して、生の差分を取ります。次に translation.height
を見て、どれくらいスケールを減らすべきかを計算します。今回は二次関数を使っていましたので、translation.height
を自乗して適当に 10000
で割ります。それで得られた商を 1
で減らして、その結果を 0.01
と 1
の間に収めるように clamped
します。最後に先ほど取った生の差分とこのスケールを掛けて、調整した移動量を返します。
そして最後はこの処理で取れた差分を scaledTranslationX
に追加すれば、最終的にこうなります:
var body: some Gesture {
DragGesture()
.onChanged { value in
- let diff = value.translation.width / canvasWidth
- let newProgress = (progressOnGestureStart + diff).clamped(in: 0...1)
+ let draggingDiff = scaledTranslationFromLastDragging(against: value)
+ scaledTranslationX += draggingDiff
+ let progressDiff = scaledTranslationX / canvasWidth
+ let newProgress = (progressOnGestureStart + progressDiff).clamped(in: 0...1)
progress = newProgress
}
}
実は細かい調整のためだけでしたら、_progressOnGestureStart
も scaledTranslationX
もわざわざ保持する必要が全くなくて、そのまま現在の progress
に scaledTranslationFromLastDragging
の結果を足してあげれば十分です。ただしこの場合、次の章で紹介する断続的な調整を可能にする step
を導入したとき、前回との差分しか取れないので、細かい調整するとき、差分が step
以下の数字になるとそもそも調整不可能になってしまうから、この章でも敢えてこれらの状態を保持しておくやり方を取ります。
さて、これでもう一回スライダーをプレビューしてみると、上下にドラッグしてから左右ドラッグすると、調整量が減るのがわかると思います。何より、例えば一回そのまま左右ドラッグして0.8の近くまでドラッグしたあと、上下ドラッグして更に左右ドラッグするとプログレスがずっと0.8付近で変わるのがわかると思います。
断続的な調整
このスライダーのままでは、progress
を連続的な調整しかできません。もちろん動画再生のプログレスなどの場合はその方が都合がいいですが、逆に例えばパーセント調整みたいな場合ですとむしろ1%刻みのような断続的な調整が好ましい時もあります。と言うわけで続いては step
を入れて、必要に応じて断続調整をできるようにしてみましょう。
まずは断続的な数字を表現する方法ですが、一般的にはそれらの数字の配列を思い浮かぶ人が多いかと思いますが、それだと例えば0.1%刻みみたいな非常に細かい場合だと配列がバカデカくなります。ところが実はSwiftには StrideThrough
と言う数列を表す型を標準で提供しており、ClosedRange
と似たような感じで範囲を指定しますが、間のギャップも設定できて、まさにこの用途に最適なものです。StrideThrough
はこのように作れます:
let range = stride(from: 0, through: 1, by: 0.1)
これで0から1までの0.1刻みの数列を作れます。そしてこれの使い方は普通の配列ととても似ていて、map
で要素を加工したり、min
や max
で最小値や最大値を探し出したりできます。
// この数列に0.18に一番近い数字を探し出す
let closedTo018 = range.min(by: { abs($0 - 0.18) < abs($1 - 0.18) })! // 0.2
さて、StrideThrough
がわかったところで、これをどうやってこのスライダーに組み込むのでしょうか。そう、与えられた数列に、ジェスチャーが算出した数値と一番近い数字を progress
として上書きすればいいです。と言うわけでここはこうできますね:
struct ProgressDragGesture: Gesture {
@Binding var progress: Double
+ var step: Double?
var canvasWidth: CGFloat
// ...
var body: some Gesture {
DragGesture()
.onChanged { value in
let draggingDiff = scaledTranslationFromLastDragging(against: value)
scaledTranslationX += draggingDiff
let progressDiff = scaledTranslationX / canvasWidth
let newProgress = (progressOnGestureStart + progressDiff).clamped(in: 0...1)
+ if let step {
+ guard step > 0 && step < 1 else { return }
+ let closestSliderAvailableValue = stride(from: 0, through: 1, by: step)
+ .min(by: { abs($0 - newProgress) < abs($1 - newProgress) })
+ if let closestSliderAvailableValue {
+ progress = closestSliderAvailableValue
+ }
+ } else {
progress = newProgress
+ }
}
}
これでプレビューに step
を入れてあげると、ちゃんと0.01刻みで数値が変わるのがわかると思います:
#Preview {
@Previewable @State var progress: Double = 0.5
VStack {
GeometryReader { proxy in
Color.red
- .gesture(ProgressDragGesture(progress: $progress, canvasWidth: proxy.size.width))
+ .gesture(ProgressDragGesture(progress: $progress, step: 0.01, canvasWidth: proxy.size.width))
}
Text("\(progress)")
}
}
更に ProgressSlider
にも入れてあげたら、本当にそれっぽいですね
struct ProgressSlider: View {
@Binding var progress: Double
+ var step: Double?
// ...
.gesture(ProgressDragGesture(
progress: $progress,
+ step: step,
canvasWidth: proxy.size.width
))
// ...
#Preview {
@Previewable @State var progress: Double = 0.5
- ProgressSlider(progress: $progress)
+ ProgressSlider(progress: $progress, step: 0.01)
.background(Color.red)
}
最後に
アップル、公式ビデオプレイヤーのプログレススライダーを標準コンポーネントにしてくれないかな…