5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iOSAdvent Calendar 2024

Day 2

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

Last updated at Posted at 2024-12-01

この記事はiOS Advent Calendar 2024への寄稿です。

背景

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

Simulator Screen Recording - iPhone 16 Pro - 2024-11-30 at 04.54.05.gif

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

基本実装

表示

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

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

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()
+        }
    }

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

今回の本題ではないのでここで割愛しますが、Path の使い方についてはこちらの記事がとても詳しく書かれているので、ぜひご参考ください。

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

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

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

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)
    }
}

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

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

スクリーンショット 2024-11-30 16.21.23.png

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

動き

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

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

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 の範囲をちゃんと 01 の間に保証したいから、計算した newProgress がもしその範囲を超えた場合、その範囲内に収めるように直した数値を返す処理です。この処理はこのように書いています:

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
        }
    }
}

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

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

合体

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

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

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

更に改善

スライディングの微調整

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

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

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 の調整量にしたいと思います。

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.xlastDraggingLocationX に代入して、次の呼び出しの時のために使えるようにします。ただしもし現在値が無ければ、その前にそのまま translation.width を返します。逆にもし値が取れたら、それと現在の location.x と比較して、生の差分を取ります。次に translation.height を見て、どれくらいスケールを減らすべきかを計算します。今回は二次関数を使っていましたので、translation.height を自乗して適当に 10000 で割ります。それで得られた商を 1 で減らして、その結果を 0.011 の間に収めるように clamped します。最後に先ほど取った生の差分とこのスケールを掛けて、調整した移動量を返します。

そして最後はこの処理で取れた差分を scaledTranslationX に追加すれば、最終的にこうなります:

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
            }
    }

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

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

スクリーンショット 2024-11-30 18.57.21.png

断続的な調整

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

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

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

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

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

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

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

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

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

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

最後に

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

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?