カスタム SwiftUI ドラッグコントロールを離散値にスナップさせるには、ポインタのローカル位置を範囲内の割合に変換し、整数インデックスに丸め、そのインデックスからつまみを描画します。この macOS の例では、5 種類の円のサイズから選択し、ドラッグを離したときに選択を確定します。

ジェスチャを書く前に操作方法を決める

この例の目的はシンプルです。Mac アプリにカスタムの横方向サイズセレクターを追加します。選べるサイズは 20、40、60、80、100 ポイントです。ドラッグ中はスナップした選択をプレビューし、ドラッグを離すとそのサイズを仮想の円に適用します。すべてメモリ内で処理します。

まず、カスタム描画が必要かどうかを判断します。Apple の標準 Slider は、step パラメーターで離散的な増分を設定でき、つまみの移動に合わせてバインドされた値を更新します。一般的なサイズセレクターなら、範囲を 20...100、刻みを 20 に設定すれば、これらの選択肢をそのまま表せます。外観と操作性が要件に合うなら、この標準コントロールを使ってください。Apple の Slider ドキュメント

以下のカスタム実装は、トラック、目盛り、つまみ、リリース時の動作を制御する必要がある場合に役立ちます。ここでは選択を整数インデックスで管理します。表示するサイズは配列から取得するため、モデルに 57.3 のような中間サイズが誤って入ることはありません。

この例を決める要点は 3 つです。

  • 目盛りは等間隔で、大きいサイズほど右側に配置します。
  • ドラッグ中は、つまみとコントロールの値ラベルの両方がスナップします。
  • 選択したサイズはドラッグを離したときに親ビューで更新されます。プレビュー中、円には直前に確定したサイズが適用されたままです。

ポインタ位置を離散インデックスに変換する

入力に使う座標と描画に使う座標を一致させます。ここではコントロールの幅を 336 ポイント、つまみの半径を 12 ポイントとします。そのため、つまみの中心は 12 から 324 まで移動し、使用可能なトラックの長さは 312 ポイントです。入力計算に全幅を使うと、ポインタ位置と端の目盛りが一致しなくなります。

変換は 4 段階です。左端の余白を差し引き、使用可能なトラック長で割り、0...1 の範囲に収めてから、丸める前に区間数を掛けます。選択肢が 5 つなら区間は 4 つです。逆変換では、選択したつまみを対応する目盛りに配置します。

次のヘルパーは、次のセクションで組み立てる完全なアプリの一部です。この丸め規則では、正規化されたインデックスが非負なので、ちょうど中間の値は高い方のインデックスを選びます。Swift のドキュメントでは、.toNearestOrAwayFromZero は、ちょうど中間の値をゼロから遠ざける最近接丸めとして説明されています。Apple の rounded(_:) ドキュメント

import SwiftUI

enum SnapGeometry {
    static func index(
        at x: CGFloat,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> Int {
        guard count > 1, x.isFinite, width > 2 * inset else {
            return 0
        }
        let fraction = min(max((x - inset) / (width - 2 * inset), 0), 1)
        return Int(
            (fraction * CGFloat(count - 1))
                .rounded(.toNearestOrAwayFromZero)
        )
    }

    static func position(
        for index: Int,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> CGFloat {
        guard count > 1 else { return width / 2 }
        return inset + CGFloat(index) / CGFloat(count - 1)
            * (width - 2 * inset)
    }
}

次の例は、固定されたジオメトリでの境界付近の動作を示しています。範囲外へのドラッグも有効な選択肢に収まり、操作は無視されません。

ポインタの x 座標 選択インデックス サイズ(ポイント)
-10 0 20
12 0 20
51 1 40
90 1 40
168 2 60
324 4 100
350 4 100

51 では、ポインタは最初の 2 つの目盛りのちょうど中間にあるため、この規則ではインデックス 1 が選ばれます。これらの決定的な計算は Python で確認しました。調査環境に Swift コンパイラがなかったため、以下の SwiftUI アプリは確認済みですが、ここではコンパイルも実行もしていません。

macOS コントロールとそれを所有するビューを作る

Xcode で SwiftUI を使った macOS App プロジェクトを作成し、デプロイメントターゲットを macOS 13 以降に設定します。生成されたアプリ宣言とコンテンツビューを以下のコードに置き換え、先ほどのヘルパーも同じ Swift ファイルに含めます。ヘルパーと次の 2 つのコードブロックを合わせると、完全なプログラムになります。@main のアプリ宣言は 1 つだけにしてください。

親ビューは @State で確定済みのインデックスを保持します。コントロールには @Binding を渡し、2 つ目の永続的な選択状態を作らずに、親ビューの情報源とつなげます。これは Apple がドキュメントで示しているバインディングの所有パターンに沿っています。Apple の Binding ドキュメント

@main
struct SnapSizeApp: App {
    var body: some Scene {
        WindowGroup {
            SizeDemo()
        }
    }
}

struct SizeDemo: View {
    private let sizes = [20, 40, 60, 80, 100]
    @State private var selectedIndex = 2

    var body: some View {
        VStack(spacing: 24) {
            Text("Circle size")
                .font(.headline)

            Circle()
                .fill(Color.accentColor)
                .frame(
                    width: CGFloat(sizes[selectedIndex]),
                    height: CGFloat(sizes[selectedIndex])
                )
                .frame(width: 120, height: 120)

            DiscreteSizeControl(
                sizes: sizes,
                selection: $selectedIndex
            )

            Text("Applied size: \(sizes[selectedIndex]) pt")
        }
        .padding(32)
        .frame(minWidth: 420, minHeight: 340)
    }
}

コントロールは一時的なプレビューに @GestureState を使います。Apple のドキュメントによると、ジェスチャーが非アクティブになると、ジェスチャー状態は初期値に戻ります。ここでは nil に戻ることで、コントロールに確定済みのバインディング値が再び表示されます。そのため、別の永続変数を用意して一時的な選択を手動で消す必要はありません。Apple の GestureState ドキュメント

struct DiscreteSizeControl: View {
    let sizes: [Int]
    @Binding var selection: Int
    @GestureState private var previewIndex: Int? = nil

    private let trackWidth: CGFloat = 336
    private let radius: CGFloat = 12

    private var shownIndex: Int {
        previewIndex ?? selection
    }

    private func snappedIndex(at x: CGFloat) -> Int {
        SnapGeometry.index(
            at: x, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func xPosition(for index: Int) -> CGFloat {
        SnapGeometry.position(
            for: index, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func moveSelection(by amount: Int) {
        guard previewIndex == nil else { return }
        selection = min(max(selection + amount, 0), sizes.count - 1)
    }

    var body: some View {
        VStack(spacing: 10) {
            Text("Selected size: \(sizes[shownIndex]) pt")
                .monospacedDigit()

            ZStack(alignment: .topLeading) {
                Capsule()
                    .fill(Color.secondary.opacity(0.25))
                    .frame(width: trackWidth - 2 * radius, height: 4)
                    .position(x: trackWidth / 2, y: 20)

                ForEach(sizes.indices, id: \.self) { index in
                    Circle()
                        .fill(Color.secondary)
                        .frame(width: 5, height: 5)
                        .position(x: xPosition(for: index), y: 20)
                }

                Circle()
                    .fill(Color.accentColor)
                    .frame(width: 2 * radius, height: 2 * radius)
                    .position(x: xPosition(for: shownIndex), y: 20)
            }
            .frame(width: trackWidth, height: 40)
            .contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .updating($previewIndex) { value, state, _ in
                        state = snappedIndex(at: value.location.x)
                    }
                    .onEnded { value in
                        selection = snappedIndex(at: value.location.x)
                    }
            )
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Circle size")
            .accessibilityValue("\(sizes[shownIndex]) points")
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment:
                    moveSelection(by: 1)
                case .decrement:
                    moveSelection(by: -1)
                @unknown default:
                    break
                }
            }

            HStack {
                Button("Smaller") { moveSelection(by: -1) }
                    .disabled(selection == 0 || previewIndex != nil)
                Button("Larger") { moveSelection(by: 1) }
                    .disabled(
                        selection == sizes.count - 1 || previewIndex != nil
                    )
            }
        }
    }
}

この例には明示的な前提条件があります。サイズ配列が空でないこと、確定済みのインデックスが有効であること、操作中に配列が変更されないことです。ここで示す親ビューは、3 つの条件をすべて満たします。ジオメトリヘルパーに防御的な戻り値があっても、コントロールで空の配列が有効になるわけではありません。ラベルがその配列の要素を参照するためです。選択肢が変化するモデルを使う場合は、描画前にコレクションと選択状態を検証し、ドラッグ中に選択肢が変わったときの動作を定義してください。

安定したコントロールのジオメトリにジェスチャーを付ける

ドラッグは、固定された 336 ポイントのトラックコンテナーに付けます。つまみはそのコンテナー内を移動します。Apple の定義では、location は現在のイベント位置を表し、DragGesture では位置に使う座標空間を指定できます。安定したコンテナーで .local を使うと、報告される x 座標が描画計算と一致します。Apple の DragGesture ドキュメント、Apple の location ドキュメント

長方形のコンテンツシェイプを指定すると、描画された図形の間も含めて、トラック領域全体がヒットテストの対象になります。最小距離をゼロにすると、最初の移動しきい値なしでジェスチャーを認識できます。この設計では、トラック上の別の場所を押すと、すぐに最も近い目盛りをプレビューします。この位置にジャンプする動作は意図したものです。Apple の contentShape(_:eoFill:) ドキュメント、Apple の DragGesture ドキュメント

つまみだけで操作し、押し始めた位置からのオフセットを維持するには、別の変換方法を使います。最初につまみの中心位置を取得し、スナップする前にジェスチャーの合計水平移動量を加えます。すでに更新された中心位置に合計移動量を繰り返し加算しないでください。SwiftUI のドキュメントでは、translation は直前のコールバックからの移動量ではなく、ジェスチャー開始時からの変位として説明されています。Apple の translation ドキュメント

ラベルやボタンを含む周囲のスタックにはジェスチャーを付けないでください。ジェスチャーを付けるビューとそのサブビューとの認識関係は、どこに付けるかによって変わります。対象範囲を広げると、ほかのコントロールとの競合が起きる場合があります。この例をスクロールビューに埋め込む場合は、そこでのジェスチャー認識を明示的に確認してください。距離ゼロのドラッグはすぐに反応する設計なので、まずは独立したパネルで試すのが最も分かりやすい方法です。Apple の gesture(_:including:) ドキュメント

プレビューを確定状態に反映するタイミングを決める

ドラッグ中に変わるのは、コントロールのつまみと選択値のラベルだけです。大きい円と適用済み値のラベルは、親ビューで確定済みのインデックスに従います。ドラッグを離したとき、ハンドラーは最後の位置から最終インデックスを計算します。直近のプレビューコールバックには依存しません。

リリース時の確定処理が行われる前にジェスチャーが非アクティブになった場合、一時的なプレビューはリセットされ、直前の確定済み選択が維持されます。プレビュー状態を @GestureState に保持し、永続的な書き込みを終了ハンドラーで行うため、この動作になります。Apple の GestureState ドキュメント

ドラッグ中に選択を即時反映する場合は、ドラッグ変更時にスナップ後のインデックスをバインディングへ書き込みます。これにより操作の仕様が変わり、中間の選択肢が確定済みのモデル更新になります。セレクターを大規模な描画処理につなぐ前に、それが適切かどうかを決めてください。どちらの設計でも、選択値の計算に使う量子化済みインデックスと同じものを使って、つまみを描画します。

Smaller ボタンと Larger ボタンは 1 段階の変更をすぐに適用し、端点で停止します。トラックには調整可能なアクセシビリティアクションもあり、増加と減少のハンドラーは同じステップ関数を使います。Apple のドキュメントでは、VoiceOver などの支援技術が調整可能なアクションを実行する仕組みとして、このモディファイアが説明されています。Apple の accessibilityAdjustableAction(_:) ドキュメント

AppKit ビューが操作を担う場合

純粋な SwiftUI ビューであれば、このタスクに AppKit とのブリッジは必要ありません。トラックが独自の NSView にすでに存在する場合は、そのビュー内でジェスチャーの認識を行ってください。Apple の addGestureRecognizer(_:) は、認識器をビューに追加し、関連付けられたジェスチャーをそのビューとサブビューに適用して、認識器への強参照を確立します。Apple の addGestureRecognizer(_:) ドキュメント

NSPanGestureRecognizer を使う AppKit 実装では、固定されたトラックビューの座標系で移動量を測定します。ドラッグ開始時のつまみの位置を取得し、報告された合計移動量を加えたうえで、算出した x 座標を同じスナップ計算に渡します。Apple のドキュメントでは、translation(in:) はジェスチャー開始時からの合計距離を返し、選択したビューの変換が適用されると明記されています。Apple の translation(in:) ドキュメント

1 つの操作領域につき、ジェスチャー実装は 1 つにしてください。AppKit のトラックを SwiftUI に埋め込む場合は、そのトラック自身にパン操作を任せ、アプリのモデル接続を通じて選択の変更を伝えます。こうすることで、イベント座標からプレビューを経て確定選択に至る流れが明確になります。

端点、しきい値、中断されたドラッグを確認する

Mac アプリで、次の短い手順を実行して操作を確認します。

  1. 中央の目盛りから始めます。すべての区間を横切るようにドラッグし、選択ラベルに許可された 5 種類のサイズだけが表示されることを確認します。
  2. 水平方向の両端を越えて移動します。つまみは最初または最後の目盛りにとどまるはずです。
  3. 目盛りの間でドラッグを離します。円に最も近いサイズが適用され、つまみの最終位置と一致することを確認します。
  4. トラック上の図形がない部分をクリックします。位置にジャンプする動作がこのタスクに適しているか確認します。
  5. 操作中にジェスチャーを中断し、一時的なプレビューが残ったままにならないことを確認します。
  6. 両方のステップボタンと VoiceOver の調整機能を使います。どちらも同じ端点で停止することを確認します。

20、30、60、100 のように値の間隔が均等でない選択肢でも、このインデックス方式では各選択肢を等間隔に配置します。これは順序に基づいたレイアウトです。数値の差に応じて間隔を変える場合は、各数値をトラック上の位置に個別に割り当てて、最も近い位置を選びます。均一なインデックスを丸める方式では、異なる操作を表すことになります。

信頼できるスナップコントロールでは、入力と描画で同じ座標系と変換を使います。両者を一致させれば、整数インデックスで値を離散化でき、プレビュー状態と確定状態を分けることで、ドラッグを離したときの動作が明確になります。

参考資料