사용자 지정 SwiftUI 드래그 컨트롤을 이산 값에 맞춰 스냅하려면 포인터의 로컬 위치를 범위가 제한된 비율로 변환하고, 이를 반올림해 정수 인덱스를 얻은 다음 그 인덱스로부터 썸을 그리면 됩니다. 이 macOS 예제에서는 다섯 가지 원 크기 중 하나를 선택하고, 놓을 때 선택을 적용합니다.

제스처를 작성하기 전에 상호작용 방식 정하기

독자가 수행할 작업은 간단합니다. Mac 앱에 사용자 지정 가로 크기 선택기를 추가합니다. 선택 가능한 크기는 20, 40, 60, 80, 100포인트입니다. 드래그하는 동안 스냅된 선택을 미리 보고, 놓으면 해당 크기를 가상의 원에 적용합니다. 모든 상태는 메모리에만 유지됩니다.

먼저 사용자 지정 드로잉이 필요한지 판단하세요. Apple의 기본 Slider는 step 매개변수로 이산 간격을 지원하며, 썸이 움직일 때 바인딩된 값을 업데이트합니다. 일반적인 크기 선택기라면 범위 20...100과 간격 20으로 이 선택지를 바로 표현할 수 있습니다. 모양과 상호작용이 요구 사항에 맞으면 이 지원되는 컨트롤을 사용하세요. Apple의 Slider 문서

아래의 사용자 지정 구현은 트랙, 눈금 표시, 썸, 놓을 때의 동작을 직접 제어해야 할 때 유용합니다. 이 구현은 의도적으로 정수 선택 인덱스를 사용합니다. 표시할 크기는 배열에서 가져오므로 모델에 57.3과 같은 중간 크기가 실수로 들어갈 수 없습니다.

이 예제의 동작은 세 가지 결정으로 정해집니다.

  • 눈금은 일정한 간격으로 배치하고, 오른쪽으로 갈수록 크기가 커집니다.
  • 드래그 중에는 썸과 컨트롤의 값 레이블이 모두 스냅됩니다.
  • 부모 뷰의 선택 크기는 놓을 때 변경되며, 미리 보는 동안 원은 이전에 확정된 크기를 유지합니다.

포인터 위치를 이산 인덱스로 변환하기

입력에 사용하는 기하와 드로잉에 사용하는 기하를 일치시키세요. 여기서 컨트롤의 너비는 336포인트이고 썸의 반지름은 12포인트입니다. 따라서 썸의 중심은 12에서 324까지 움직이며, 사용 가능한 트랙 길이는 312포인트입니다. 입력 계산에 전체 너비를 사용하면 포인터 위치와 끝점 눈금 위치가 어긋납니다.

위치 매핑은 네 단계로 이루어집니다. 왼쪽 여백을 빼고, 사용 가능한 트랙 길이로 나누고, 0...1 범위로 제한한 다음, 반올림하기 전에 구간 수를 곱합니다. 선택지가 다섯 개라면 구간은 네 개입니다. 역매핑을 사용하면 선택된 썸을 해당 눈금 위치에 놓을 수 있습니다.

다음 도우미는 다음 절에서 완성할 앱의 일부입니다. 정규화된 인덱스는 음수가 아니므로, 반올림 규칙은 정확한 중간점에서 더 높은 인덱스를 선택합니다. Swift 문서에서 .toNearestOrAwayFromZero는 가장 가까운 값으로 반올림하되 동률이면 0에서 먼 쪽으로 반올림한다고 설명합니다. 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에서는 포인터가 첫 번째와 두 번째 눈금의 정확한 중간에 있으므로, 규칙에 따라 인덱스 1가 선택됩니다. 이 결정론적 계산은 Python으로 확인했습니다. 조사 환경에는 Swift 컴파일러가 없어 아래 SwiftUI 앱을 검토했지만 여기서 컴파일하거나 실행하지는 못했습니다.

macOS 컨트롤과 이를 소유하는 뷰 만들기

Xcode에서 SwiftUI를 사용하는 macOS App 프로젝트를 만들고 배포 대상을 macOS 13 이상으로 설정하세요. 생성된 앱 선언과 콘텐츠 뷰를 아래 코드로 바꾸고, 앞 절의 도우미도 같은 Swift 파일에 넣으세요. 도우미와 다음 두 코드 블록을 합치면 완전한 프로그램이 됩니다. @main 앱 선언은 하나만 두세요.

부모 뷰는 @State에서 확정된 인덱스를 소유합니다. 컨트롤은 @Binding을 받아 별도의 영구 선택 상태를 만들지 않고 해당 단일 데이터 원본에 연결됩니다. 이는 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
                    )
            }
        }
    }
}

이 예제에는 명시적인 전제 조건이 있습니다. 크기 배열은 비어 있지 않고, 확정된 인덱스는 유효하며, 상호작용 중 배열이 바뀌지 않아야 합니다. 제공된 부모 뷰는 이 조건을 모두 충족합니다. 기하 도우미에 방어적 반환값이 있어도 배열이 비어 있는 상태로 컨트롤을 사용할 수 있는 것은 아닙니다. 레이블이 여전히 배열을 인덱싱하기 때문입니다. 선택지가 바뀌는 모델을 사용한다면 렌더링 전에 컬렉션과 선택값을 검증하고, 드래그 도중 선택지가 바뀌면 어떻게 할지 정하세요.

안정적인 컨트롤 기하에 제스처 연결하기

드래그 제스처는 고정된 336포인트 트랙 컨테이너에 연결합니다. 썸은 컨테이너 안에서 움직입니다. Apple은 location를 현재 이벤트 위치로 정의하며, DragGesture에서 위치에 사용할 좌표계를 지정할 수 있도록 합니다. 고정된 컨테이너에서 .local을 사용하면 보고된 x 좌표가 드로잉 계산과 일치합니다. Apple의 DragGesture 문서, Apple의 location 문서

직사각형 콘텐츠 셰이프는 그려진 모양 사이의 빈 공간을 포함해 컨트롤의 트랙 영역을 히트 테스트 대상으로 만듭니다. 최소 거리를 0으로 설정하면 초기 이동 임계값 없이 제스처를 인식할 수 있습니다. 이 설계에서는 트랙의 다른 곳을 누르면 가장 가까운 눈금을 즉시 미리 봅니다. 이는 의도한 위치 이동 동작입니다. Apple의 contentShape(_:eoFill:) 문서, Apple의 DragGesture 문서

처음 잡은 오프셋을 유지하면서 썸만 조작하려면 다른 매핑을 사용하세요. 썸의 시작 중심을 한 번 저장한 다음, 스냅하기 전에 제스처의 전체 가로 이동량을 더합니다. 이미 업데이트된 중심에 전체 이동량을 반복해서 더하지 마세요. SwiftUI 문서에 따르면 이동량은 직전 콜백 이후의 움직임이 아니라 제스처 시작 시점부터의 변위입니다. Apple의 translation 문서

레이블과 버튼을 포함한 바깥쪽 스택에는 제스처를 연결하지 마세요. 제스처를 연결한 뷰와 그 하위 뷰의 인식 관계는 제스처 연결의 영향을 받으므로 범위를 넓히면 다른 컨트롤과 인식이 경합할 수 있습니다. 이 예제를 스크롤 뷰에 넣는다면 그 안에서 제스처 인식을 명시적으로 테스트하세요. 거리가 0인 드래그는 즉시 시작되는 상호작용이므로, 독립된 패널에서 시작하는 것이 가장 명확합니다. Apple의 gesture(_:including:) 문서

미리 보기를 확정 상태로 바꿀 시점 정하기

드래그하는 동안 바뀌는 것은 컨트롤의 썸과 선택값 레이블뿐입니다. 큰 원과 적용된 값 레이블은 부모 뷰의 확정된 인덱스를 따릅니다. 놓을 때 핸들러는 가장 최근의 미리 보기 콜백에 의존하지 않고 마지막 위치로부터 최종 인덱스를 계산합니다.

놓을 때 확정하는 동작 없이 제스처가 비활성화되면 임시 미리 보기가 재설정되고 이전에 확정된 선택이 유지됩니다. 미리 보기 상태를 @GestureState에 두고 지속 상태 쓰기는 종료 핸들러에서 처리하기 때문에 이런 동작이 나타납니다. Apple의 GestureState 문서

값을 실시간으로 적용하려면 드래그가 바뀔 때 스냅된 인덱스를 바인딩에 기록하세요. 그러면 상호작용 계약이 바뀌어 중간 선택도 모델에 확정된 업데이트로 반영됩니다. 선택기를 더 많은 렌더링 작업에 연결하기 전에 이것이 바람직한지 결정하세요. 두 설계 모두 선택된 값에 사용한 동일한 양자화 인덱스로 썸을 그리세요.

Smaller와 Larger 버튼은 한 단계씩 즉시 적용하며 양 끝점에서 멈춥니다. 트랙에는 접근성 조절 동작도 제공되며, 증가와 감소 핸들러는 동일한 단계 이동 함수를 사용합니다. Apple은 이 수정자가 VoiceOver 같은 보조 기술에서 조절 동작을 호출하는 방식이라고 설명합니다. Apple의 accessibilityAdjustableAction(_:) 문서

AppKit 뷰가 상호작용을 소유하는 경우

순수 SwiftUI 뷰라면 이 작업에 AppKit 브리지가 필요하지 않습니다. 트랙이 이미 자체 NSView에 있다면 해당 뷰에서 인식을 처리하세요. Apple의 addGestureRecognizer(_:)은 인식기를 뷰에 연결하고, 인식 대상 제스처의 범위를 해당 뷰와 하위 뷰로 한정하며, 인식기에 대한 강한 참조를 설정합니다. Apple의 addGestureRecognizer(_:) 문서

NSPanGestureRecognizer를 사용하는 AppKit 구현에서는 고정된 트랙 뷰의 좌표계로 이동량을 측정하세요. 썸의 시작 위치를 저장하고, 보고된 전체 이동량을 더한 다음 결과 x 좌표를 동일한 스냅 계산에 전달합니다. Apple은 translation(in:)이 제스처 시작 이후의 전체 거리를 반환하며 선택한 뷰의 변환을 적용한다고 명시합니다. Apple의 translation(in:) 문서

각 상호작용 표면에는 제스처 구현을 하나만 사용하세요. AppKit 트랙을 SwiftUI에 포함한다면 트랙이 팬 제스처 처리를 소유하고 앱의 모델 연결을 통해 선택 변경을 전달하게 하세요. 그러면 이벤트 좌표에서 미리 보기, 확정된 선택으로 이어지는 경로가 명확해집니다.

끝점, 임계값, 중단된 드래그 확인하기

Mac 앱에서 다음의 구체적인 순서로 상호작용을 확인하세요.

  1. 가운데 눈금에서 시작합니다. 모든 구간을 드래그하며 선택 레이블에 허용된 다섯 가지 크기만 표시되는지 확인합니다.
  2. 양쪽 가로 끝점을 지나 이동합니다. 썸은 첫 번째 또는 마지막 눈금에 머물러야 합니다.
  3. 눈금 사이에서 놓습니다. 원이 가장 가까운 크기를 적용하고, 그 크기가 썸의 최종 위치와 일치해야 합니다.
  4. 트랙에서 도형이 없는 부분을 클릭합니다. 위치 이동 동작이 작업에 적합한지 확인합니다.
  5. 활성 상호작용을 중단하고 임시 미리 보기가 남아 고정되지 않는지 확인합니다.
  6. 단계 버튼과 VoiceOver 조절 기능을 모두 사용합니다. 두 방법이 동일한 끝점에서 멈추는지 확인합니다.

20, 30, 60, 100처럼 간격이 균일하지 않은 선택지를 사용해도 이 인덱스 기반 설계에서는 각 선택지가 같은 간격으로 배치됩니다. 이는 의도적인 순서 기반 배치입니다. 간격이 숫자상의 거리까지 반영해야 한다면 각 숫자값을 자체 트랙 위치에 매핑하고 가장 가까운 위치를 선택하세요. 균일 인덱스를 반올림하면 다른 상호작용을 표현하게 됩니다.

안정적인 스냅 컨트롤은 입력과 드로잉에 하나의 좌표계와 매핑을 사용합니다. 두 가지를 일치시키면 정수 인덱스로 선택값을 이산 상태로 유지할 수 있고, 미리 보기 상태와 확정 상태를 분리하면 놓을 때의 동작도 분명해집니다.

출처