Mac 앱 안에서 도형을 드래그할 때는 제스처가 진행되는 동안 오프셋을 직접 업데이트하고, 손을 놓을 때 같은 오프셋에 스프링 애니메이션을 적용하세요. macOS 14 이상에서는 SwiftUI가 추적된 드래그 속도를 스프링에 자동으로 전달할 수 있습니다.

이 튜토리얼에서는 작은 데스크톱 상호작용을 만듭니다. 파란색 타일을 어느 방향으로든 드래그한 다음 손을 놓으면, 타일이 스프링처럼 튕기며 시작 위치로 돌아옵니다. 모든 상태는 메모리에 유지되고, 제스처는 앱 자체 창의 타일에 연결됩니다.

스프링은 드래그의 최종 속도를 어떻게 이어받나요?

SwiftUI의 Transaction.tracksVelocity는 애니메이션 가능한 속성이 바뀔 때 속도를 기록합니다. Apple 문서에 따르면 제스처의 onChanged 및 updating 콜백은 이 추적을 자동으로 활성화합니다. 상호작용이 끝나면 애니메이션이 누적된 속도를 사용할 수 있습니다. 따라서 일반적인 SwiftUI 상호작용에서는 초기 속도를 계산해 전달할 필요가 없습니다. Apple의 속도 추적 문서에서 이 전달 과정을 명확히 설명합니다.

핵심은 애니메이션이 적용되는 속성입니다. 드래그하는 동안 제스처의 이동량을 타일의 오프셋에 기록합니다. 손을 놓을 때는 withAnimation 안에서 같은 오프셋을 목적지로 변경합니다. 뷰와 상태를 계속 유지하면 SwiftUI가 연속된 속성을 추적할 수 있습니다.

이 구현에서는 드래그 업데이트에 애니메이션을 적용하지 않습니다. Apple 설명에 따르면 트랜잭션의 속도 추적과 자체 속도를 관리하는 애니메이션은 동시에 사용할 수 없습니다. 드래그 중 대화형 스프링을 적용하는 방법도 지원되지만, 타일이 포인터를 따라가는 방식이 달라집니다. 여기서 구현하는 직접 추종 방식에는 필요하지 않습니다. 트랜잭션 속도 추적

스프링의 목적지와 초기 움직임은 서로 다른 역할을 합니다. 목적지는 타일이 어디에 자리 잡을지를 나타냅니다. 이어받은 속도는 손을 놓는 순간 타일이 어떤 방향과 속도로 움직이는지를 나타냅니다. 홈 위치에서 멀어지는 방향으로 움직일 때 손을 놓으면, 스프링이 타일을 되돌리기 전에 타일이 잠시 바깥쪽으로 계속 움직일 수 있습니다. Apple은 스프링을 사용한 애니메이션에서 제스처 속도로 움직임을 이어 가는 스프링을 보여 줍니다.

완전한 macOS 드래그 및 놓기 예제를 만들어 보세요

Xcode에서 SwiftUI를 사용하는 macOS 앱을 만들고, 배포 대상을 macOS 14.0 이상으로 설정한 다음, 생성된 앱 진입점 파일을 아래 코드로 바꾸세요. 대상에는 @main 앱 선언을 하나만 남겨 두세요. 이 예제에서는 macOS 14의 애니메이션 완료 API를 사용합니다. Apple의 애니메이션 완료 문서

이 코드는 에셋이나 패키지 종속성이 없는 완전한 단일 파일 애플리케이션입니다. API 시그니처는 문서와 대조해 확인했지만, 이 연구 환경에서는 데스크톱 애플리케이션을 컴파일하거나 실행하지 않았습니다.

import SwiftUI

@main
struct SpringReleaseDemoApp: App {
    var body: some Scene {
        WindowGroup {
            SpringReleaseView()
        }
    }
}

struct SpringReleaseView: View {
    @State private var offset: CGSize = .zero
    @State private var isReturning = false
    @GestureState private var isDragging = false

    var body: some View {
        VStack(spacing: 16) {
            Text("Drag the tile, then release")
                .font(.headline)

            ZStack {
                RoundedRectangle(cornerRadius: 20)
                    .stroke(.gray.opacity(0.4), lineWidth: 2)
                    .frame(width: 112, height: 112)

                RoundedRectangle(cornerRadius: 20)
                    .fill(.blue)
                    .frame(width: 112, height: 112)
                    .contentShape(Rectangle())
                    .offset(offset)
                    .gesture(tileDrag)
                    .allowsHitTesting(!isReturning)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .coordinateSpace(name: "canvas")
        }
        .padding(24)
        .frame(width: 640, height: 440)
        .onChange(of: isDragging) { _, active in
            guard !active, !isReturning else { return }

            // Cancellation cleanup has no successful release value.
            withTransaction(Transaction(animation: nil)) {
                offset = .zero
            }
        }
    }

    private var tileDrag: some Gesture {
        DragGesture(
            minimumDistance: 3,
            coordinateSpace: .named("canvas")
        )
        .updating($isDragging) { _, active, _ in
            active = true
        }
        .onChanged { value in
            // Gesture callbacks automatically track velocity.
            offset = value.translation
        }
        .onEnded { _ in
            returnHome()
        }
    }

    private func returnHome() {
        guard !isReturning else { return }
        isReturning = true

        withAnimation(
            .spring(duration: 0.45, bounce: 0.2),
            completionCriteria: .removed
        ) {
            offset = .zero
        } completion: {
            isReturning = false
        }
    }
}

제스처는 움직이는 타일에 연결하고, 이름이 지정된 좌표 공간은 움직이지 않는 캔버스에 둡니다. 이렇게 하면 상호작용에 사용할 좌표 기준이 분명해집니다. Apple의 DragGesture 문서에서는 제스처 수정자를 사용해 설정된 드래그 제스처를 연결하는 방법을 설명합니다.

value.translation은 드래그가 시작된 뒤의 총 이동량입니다. 이를 offset에 할당하고, 이전 오프셋에 계속 더하지 마세요. 계속 더하면 같은 움직임이 여러 번 누적됩니다. 이 예제에서는 허용된 드래그가 모두 홈 위치에서 시작하므로 별도의 시작 오프셋이 필요하지 않습니다. Apple의 이동량 정의

정상적으로 손을 놓으면 즉시 returnHome()을 호출합니다. 먼저 오프셋을 초기화하지도 않고, 스프링을 나중에 실행하도록 예약하지도 않습니다. 이렇게 하면 추적 중인 드래그 변경과 손을 놓을 때의 애니메이션이 동일한 상태 속성을 통해 연결됩니다.

드래그, 위치, 복귀 상태를 각각 유지하는 이유는 무엇인가요?

세 속성은 각각 다른 질문에 답합니다. offset은 요청된 이동량을 저장하고, isDragging은 제스처가 진행 중인지 추적하며, isReturning은 예제의 복귀 단계를 기록합니다. 타일을 소유한 뷰가 이 값들을 로컬로 보관합니다. Apple은 뷰가 소유하는 상태를 비공개로 선언하고, 다른 뷰에서 변경해야 할 때만 바인딩을 공유하도록 권장합니다. State 문서

프로퍼티 래퍼 구문에도 구체적인 목적이 있습니다. Swift 프로퍼티 래퍼는 래핑된 값에 대한 접근을 관리하고 달러 기호 접두사를 사용해 투영된 값을 제공할 수 있습니다. 여기서 $isDragging은 updating에 사용하는 제스처 상태 투영값입니다. 별도로 관리하는 두 번째 플래그가 아닙니다. Swift 프로그래밍 언어: 프로퍼티

제스처가 취소되는 경우에는 별도로 처리해야 합니다. onEnded은 제스처가 성공적으로 끝난 경우에만 실행되기 때문입니다. Apple은 제스처 종료 방식과 관계없이 초기화해야 하는 상태에 @GestureState를 권장합니다. 이 예제에서는 초기화를 관찰해 정상적인 복귀가 진행 중이지 않을 때 합성 타일을 원위치로 돌립니다. 이 대체 동작은 타일을 홈으로 순간 이동시키며, 취소된 제스처의 최종 놓기 속도를 얻었다고 간주하지 않습니다. 제스처 완료 동작, GestureState

또한 이 예제에서는 복귀 애니메이션이 끝난 뒤에만 새 드래그를 허용합니다. 모델 오프셋을 0으로 설정하면 애니메이션이 시작되므로, 타일이 화면에서 계속 움직이는 동안에도 0은 이미 요청된 목적지입니다. 이 제한만 없애려면 현재 화면에 표시 중인 위치에서 새 드래그를 어떻게 이어받을지 설계해야 합니다.

allowsHitTesting(!isReturning)은 타일이 히트 테스트에 참여할지 제어합니다. .removed 완료 기준을 사용하면 전체 애니메이션이 끝날 때 제한이 해제됩니다. 스프링의 지속 시간 매개변수를 바탕으로 지연 시간을 추측하지 않아도 됩니다. 히트 테스트 제어, 애니메이션 제거 완료

놓기 동작은 어떻게 조정하고 확인해야 하나요?

먼저 목적지와 스프링 설정을 고정하세요. 애니메이션 매개변수를 바꾸기 전에 타일을 놓는 방식을 바꿔 보세요. 이렇게 하면 속도 이어받기와 스프링 조정을 따로 확인할 수 있습니다.

창 안에서 다음 항목을 확인하세요. 이는 문서화된 전달 과정을 바탕으로 한 진단 순서이며, 이 예제에서 측정한 결과는 아닙니다.

확인 항목 동작 살펴볼 점
거의 정지한 상태에서 놓기 타일을 홈에서 멀리 드래그하고 잠시 멈춘 다음 놓습니다. 이어지는 움직임이 거의 없이 복귀를 시작해야 합니다.
바깥쪽으로 움직이며 놓기 타일을 홈에서 멀리 드래그한 뒤 계속 바깥쪽으로 움직일 때 놓습니다. 스프링이 타일을 되돌리기 전에 바깥쪽 움직임을 이어 가야 합니다.
안쪽으로 움직이며 놓기 타일을 홈에서 멀리 드래그한 뒤 방향을 바꿔 홈 쪽으로 움직이고 놓습니다. 복귀가 안쪽 움직임을 이어 가야 합니다.
대각선으로 움직이며 놓기 두 축을 따라 움직일 때 놓습니다. 수평으로만 복귀하는 듯한 동작이 아니라 방향이 이어지는지 확인합니다.
다음 상호작용 복귀가 끝날 때까지 기다린 뒤 다시 드래그합니다. 새 드래그가 홈 위치에서 시작해야 합니다.

속도를 조정하려면 duration을, 느낌을 조정하려면 bounce을 바꾸세요. 예제의 0.45 및 0.2은 설명을 위한 시작값이지, 필수 설정값이 아닙니다. Apple은 지속 시간을 지각되는 지속 시간으로 정의하고, 바운스 값이 0이면 임계 감쇠 상태이며 양수 값이 커질수록 스프링의 튕김이 강해진다고 설명합니다. 스프링 구성

지각되는 지속 시간이 화면에 보이는 모든 움직임이 끝나는 정확한 시점을 보장하지는 않습니다. Apple은 이를 스프링의 움직임에 따라 달라지는 안정화 지속 시간과 구분합니다. 0.45을 타이머처럼 취급하지 말고, 이 예제의 복귀 단계에는 완료 콜백을 사용하세요. 스프링을 사용한 애니메이션

놓을 때 움직임이 무시되는 것처럼 보이면 먼저 속도 전달 과정을 점검하세요. 제스처가 스프링이 애니메이션하는 것과 같은 오프셋을 변경하나요? 다른 코드가 놓기 전에 오프셋을 덮어쓰나요? 뷰가 교체되나요? 애니메이션 수정자가 드래그 업데이트에 애니메이션을 적용하나요? 이런 점검은 애니메이션을 적용하기 전에 바뀌는 애니메이션 가능 속성을 추적해야 한다는 문서상의 요구사항에서 바로 이어집니다.

속도를 직접 읽거나 AppKit을 사용해야 할 때는 언제인가요?

DragGesture.Value.velocity는 현재 드래그 속도를 제공하고, onEnded은 성공적으로 완료된 뒤 제스처의 최종 값을 제공합니다. 예를 들어 속도에 따라 두 개의 합성 스냅 위치 중 하나를 선택하는 등, 애플리케이션의 결정에 속도를 사용한다면 값을 읽는 것이 유용합니다. 이 예제처럼 목적지가 고정된 복귀 동작에서는 자동 추적만으로 애니메이션에 속도를 전달할 수 있습니다. 드래그 속도, 최종 제스처 값

초기 속도를 지정하는 스프링 API를 직접 사용하려면 제스처 측정값을 전달하기 전에 그 표현 방식을 확인하세요. Apple의 제스처 기반 애니메이션 데모에서는 이동 속도를 남은 이동 거리로 나누어 단위 속도로 변환합니다. 데모에 나온 계산을 따르면 초당 300포인트의 속도로 150포인트를 이동할 때 초당 2애니메이션 거리로 변환됩니다. 남은 거리가 0인 경우를 어떻게 처리할지도 정해야 합니다. 여기서는 자동 SwiftUI 경로를 사용하므로 이런 계산이 필요하지 않습니다. UI 애니메이션과 전환 개선하기

앱에서 소유하는 기존 AppKit 뷰나 인식기가 상호작용을 담당해야 한다면 AppKit을 사용하세요. NSPanGestureRecognizer.velocity(in:)는 지정된 뷰를 기준으로 초당 포인트 단위의 수평 및 수직 속도를 반환합니다. Swift 시그니처에서는 뷰가 선택 사항이지만, Apple은 해당 인수가 nil이어서는 안 된다고 명시합니다. AppKit 팬 제스처 속도

macOS 26 이상에서 AppKit 제스처 인식기를 SwiftUI 계층에 통합하는 공식 방법은 NSGestureRecognizerRepresentable입니다. SwiftUI가 인식기의 생성과 업데이트를 관리하고 좌표 변환 기능을 제공합니다. Apple은 인식기의 기본 뷰와 SwiftUI 뷰의 기하 구조가 다를 수 있다고 주의를 줍니다. 두 영역을 오갈 때는 표현 타입이 제공하는 변환기를 사용하세요. AppKit 제스처 통합

타일 상호작용에서는 드래그 업데이트를 담당하는 주체를 SwiftUI 제스처 하나로 유지하고, 놓을 때는 기존 오프셋에 복귀 스프링 애니메이션을 적용하세요. 이 전달 과정이 작동하면 동일한 상태와 좌표 경계를 유지하면서 목적지와 스프링 매개변수를 조정하세요.

출처