iOSの音量調節や動画の再生プログレスみたいなスライダーを自作してみる

:::note
この記事は[iOS Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ios)への寄稿です。
:::

# 背景

最近のiOS標準のビデオプレイヤーを使ったことある方は、そのプレイヤーの再生プログレス調整スライダーが如何によくできているかわかると思います。あれは標準コンポーネントのスライダーと違い、いわゆる「つまみ」と言うものがありません；その代わり、指はそのスライダーのどこからでもスワイプでき、それで再生プログレスの移動が可能になっています；それどころか、指を上下に動かしてスライダーからちょっと離したことによって、再生プログレスの微調整まで可能です。

![Simulator Screen Recording - iPhone 16 Pro - 2024-11-30 at 04.54.05.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/7f52b687-4956-d16b-db67-f5452862cb0f.gif)

ところが残念ながら、このプログレススライダーは標準コンポーネントとして提供されていません。と言うわけでないものは作ればいい精神で、今回はこのプログレススライダーを自作してみようと思います。

# 基本実装

## 表示

まずはプログレススライダーの表示の仕方です。これは割と簡単で、二層のビューを重ねて、上の層をプログレスに応じて表示をクリッピング（例えばプログレスが60%なら、左の60%だけ表示する）して、最終的に両端を角丸にすればいいだけかと思います。ここで唯一の課題は、そのプログレスに応じたクリッピングをする標準APIがないところですが、SwiftUIには `.clipShape` と言うModifierがあって、`Shape` を渡してあげれば、その形に沿ったクリッピングができるから、つまりプログレスに応じた `Shape` を作れば目的が達成できます。と言うわけで早速こんな `Shape` を作ってみましょう：

```swift:ProgressShape.swift
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` を描けばできますよね、と言うわけで早速書いてみましょう：

```diff_swift:ProgressShape.swift
    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()
+        }
    }
```

:::note
上記のコードでは、`rect.origin`、すなわち描画領域の原点は必ず `CGPoint.zero` と仮定しています。以前の `UIKit` においては、ビューの内部領域 `bound` は必ず `CGRect.zero` が原点で、それ以外の場所を原点にしてしまうと逆に描画がずれてしまいます。ところが `Shape` の `path(in:)` の `rect` 引数については外部視点なのか内部視点なのか[ドキュメントでは説明がなく](https://developer.apple.com/documentation/swiftui/shape/path(in:))、筆者が色々試してみたところおそらく内部視点と見ていいと判断した故、直接 `move(to: .zero)` と指定しています。もしこの `rect` は違う意味だよ、とご存じの方がいらっしゃれば、ぜひ詳しく教えていただけると助かります。
:::

:::note
今回の本題ではないのでここで割愛しますが、`Path` の使い方については[こちらの記事](https://qiita.com/yamakentoc/items/a58a2c44f9480bc0e236)がとても詳しく書かれているので、ぜひご参考ください。
:::

これで、たとえば `progress` を `0.6` に設定してプレビューしてみると、確かに横幅の60%くらいの描画がされているとわかりますね

```swift:ProgressShape.swift
#Preview {
    ProgressShape(progress: 0.6)
        .fill(Color.red)
        .frame(width: 300, height: 30)
        .background(Color.blue)
}
```

![スクリーンショット 2024-11-30 16.11.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/d011bc1f-d5c7-d052-d31a-5dd03f27c91c.png)

`ProgressShape` ができたらあとは簡単です、二層のビューを重ねて、上の層をこの `ProgressShape` でクリッピングして、あとは高さ設定して全体で `.capsule` でクリッピングするだけです。では、その二重の層はどう作ればいいのか？今回の記事はあくまで思考のアプローチをご紹介したいので、アップルの公式の実装に完全に寄せるわけではないから、とりあえず雑に `Color.white.opacity(0.5)` を二重に重ねるだけにしようと思います：

```swift:ProgressSlider.swift
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)
    }
}
```

これでプレビューしてみると、こんなものが出ると思います：

```swift:ProgressSlider.swift
#Preview {
    ProgressSlider(progress: 0.5)
        .background(Color.red)
}
```

![スクリーンショット 2024-11-30 16.21.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/c1abfe32-7e71-c280-7e46-2438baa4f177.png)

お、それっぽくなってきましたね！

## 動き

ところが、これではまだプログレスを動かせない、このままでは「スライダー」とは呼べない。と言うわけで動かす仕組みを入れましょう。

まずは動かすことを可能にするには、`.gesture` Modifierを使って `Gesture` を受け付けさせる必要があります。`Gesture` は `protocol` で、普段の開発では `TapGesture` や `DragGesture` などがよく利用されるかと思いますが、今回のユースケースではまさに `DragGesture` が一番適してますね。ただ実際にいざ使ってみたらわかると思いますが、`DragGesture` はそのまま使いにくいです、なぜならこのジェスチャーがそのまま取れるのは、ジェスチャー開始時の座標、現在の座標、そして上記の2つの間の移動量ですが、これらの値は全部絶対値です；ところが我々が制御したい `progress` はスライダーの幅に応じた相対値です。またそれ以外にも、ジェスチャー開始時のプログレスを自分で取っておかないと、移動量をそのままプログレスに足してあげては今までの積み重ねが全部入ってしまいます。と言うわけで、このジェスチャーをもうちょっと使いやすいように、カスタマイズしたいと思います：

```swift:ProgressDragGesture.swift
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` がもしその範囲を超えた場合、その範囲内に収めるように直した数値を返す処理です。この処理はこのように書いています：

```swift:ProgressDragGesture.swift
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
        }
    }
}
```

さて、ここまでできたら、このジェスチャーを実際プレビューで試してみましょう、これで赤い部分をマウスでドラッグしてみると、下の数字がそれっぽく変更されてるのがわかるかと思います：

```swift:ProgressDragGesture.swift
#Preview {
    @Previewable @State var progress: Double = 0.5
    VStack {
        GeometryReader { proxy in
            Color.red
                .gesture(ProgressDragGesture(progress: $progress, canvasWidth: proxy.size.width))
        }
        Text("\(progress)")
    }
}
```

![スクリーンショット 2024-11-30 17.31.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/1eda14ba-61b7-8734-4c0d-5ba954ff8505.png)

## 合体

さて、これでジェスチャーができたので、早速 `ProgressSlider` に入れてみましょう。先ほどの `ProgressSlider` は最低限のプレビューだけ作ったので、`progress` は可変ではないから、まずそれを `@Binding` に変更します；次にスライダーの幅をとりたいので、一番簡単の方法として `GeometryReader` 使ってキャンバスをサイズをとりましょう。と言うわけでこれがこうなります：

```diff_swift:ProgressSlider.swift
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` も楽に使えますね。これでプレビューしてみると、ドラッグでスライダーがいい感じに動いてくれるかと思います：

```diff_swift:ProgressSlider.swift
#Preview {
-    ProgressSlider(progress: 0.5)
+    @Previewable @State var progress: Double = 0.5
+    ProgressSlider(progress: $progress)
        .background(Color.red)
}
```

![スクリーンショット 2024-11-30 17.41.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/11d2b6e6-0110-08aa-866b-caccd7c99756.png)

# 更に改善

## スライディングの微調整

ここまでできたら、すでにスライダーとして気持ちよく使えるものかと思いますが、ただこれだとまだ足りない部分があります。そう、iOS標準のビデオプレイヤーのプログレススライダーは、指を上下に動かしてスライダー本体からちょっと話すと、左右のスライドでプログレスの微調整が可能です。これは特に長い動画などの場合、細かい再生位置の調整ができてとても使いやすいです。と言うわけで次はこの機能をつけてあげましょう。

この機能の理屈は非常に単純です：ドラッグ時の移動量が取れるから、それのY方向の移動量を取って、数値が大きければ大きいほど、`progress` の調整を細かくしてあげればいいです。ただしそのためには、ドラッグジェスチャー全体の移動量ではなく、ドラッグ途中途中の毎回のX方向の移動量を取る必要があります。そうでないと、`progress` の変更量がドラッグ開始時の値からの変更にしかできませんが、実際iOS標準のビデオプレイヤーのプログレススライダー触ってみるとわかりますが、あの変更量は直前の値からの変更になります。そうしないと目的の場面の近くまでドラッグしたら、一回指を離してもう一回ドラッグしないと微調整できないから不便ですからね。ただし結局 `progressOnGestureStart` からの移動量を取る必要があるから、それらの差分を更に積分した移動量を取る必要もあります。と言うわけで、まずはジェスチャー実行中の直前のX座標を保持するプロパティーと、毎回の差分を積分したX座標の移動量を保持するプロパティーを追加しておきましょう：

```diff_swift:ProgressSlider.swift
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` の調整量にしたいと思います。

```diff_swift:ProgressSlider.swift
-    @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` に追加すれば、最終的にこうなります：

```diff_swift:ProgressSlider.swift
    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
            }
    }
```

:::note
実は細かい調整のためだけでしたら、`_progressOnGestureStart` も `scaledTranslationX` もわざわざ保持する必要が全くなくて、そのまま現在の `progress` に `scaledTranslationFromLastDragging` の結果を足してあげれば十分です。ただしこの場合、次の章で紹介する断続的な調整を可能にする `step` を導入したとき、前回との差分しか取れないので、細かい調整するとき、差分が `step` 以下の数字になるとそもそも調整不可能になってしまうから、この章でも敢えてこれらの状態を保持しておくやり方を取ります。
:::

さて、これでもう一回スライダーをプレビューしてみると、上下にドラッグしてから左右ドラッグすると、調整量が減るのがわかると思います。何より、例えば一回そのまま左右ドラッグして0.8の近くまでドラッグしたあと、上下ドラッグして更に左右ドラッグするとプログレスがずっと0.8付近で変わるのがわかると思います。

![スクリーンショット 2024-11-30 18.57.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/91bce9f3-c500-dd59-2376-01c072817a0c.png)

## 断続的な調整

このスライダーのままでは、`progress` を連続的な調整しかできません。もちろん動画再生のプログレスなどの場合はその方が都合がいいですが、逆に例えばパーセント調整みたいな場合ですとむしろ1%刻みのような断続的な調整が好ましい時もあります。と言うわけで続いては `step` を入れて、必要に応じて断続調整をできるようにしてみましょう。

まずは断続的な数字を表現する方法ですが、一般的にはそれらの数字の配列を思い浮かぶ人が多いかと思いますが、それだと例えば0.1%刻みみたいな非常に細かい場合だと配列がバカデカくなります。ところが実はSwiftには `StrideThrough` と言う数列を表す型を標準で提供しており、`ClosedRange` と似たような感じで範囲を指定しますが、間のギャップも設定できて、まさにこの用途に最適なものです。`StrideThrough` はこのように作れます：

```swift
let range = stride(from: 0, through: 1, by: 0.1)
```

これで0から1までの0.1刻みの数列を作れます。そしてこれの使い方は普通の配列ととても似ていて、`map` で要素を加工したり、`min` や `max` で最小値や最大値を探し出したりできます。

```swift
// この数列に0.18に一番近い数字を探し出す
let closedTo018 = range.min(by: { abs($0 - 0.18) < abs($1 - 0.18) })! // 0.2
```

さて、`StrideThrough` がわかったところで、これをどうやってこのスライダーに組み込むのでしょうか。そう、与えられた数列に、ジェスチャーが算出した数値と一番近い数字を `progress` として上書きすればいいです。と言うわけでここはこうできますね：

```diff_swift:ProgressDragGesture.swift
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刻みで数値が変わるのがわかると思います：

```diff_swift:ProgressDragGesture.swift
#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)")
    }
}
```

![スクリーンショット 2024-11-30 19.32.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/f4eb504b-9743-ddbd-8424-33a3c45acd4e.png)

更に `ProgressSlider` にも入れてあげたら、本当にそれっぽいですね

```diff_swift:ProgressSlider.swift
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)
}
```

![スクリーンショット 2024-11-30 19.39.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/ecce2346-1f84-7df3-91b1-058bc5e237bb.png)

# 最後に

アップル、公式ビデオプレイヤーのプログレススライダーを標準コンポーネントにしてくれないかな…