DragGesture, RotateGesture, MagnifyGesture를 simultaneously(with:)로 결합한 다음, 하나의 변환 파이프라인을 통해 각 값을 렌더링합니다. Mac 캔버스에서는 입력 표면을 고정해 두고 각 제스처의 임시 변경값을 확정된 결과와 분리해 저장합니다.

이 튜토리얼에서는 자체 macOS 앱에 작은 도형 뷰어를 만듭니다. 클릭한 채 드래그해 패닝하고, 핀치해 확대/축소하고, 트랙패드 회전 제스처로 도형을 돌립니다. 메모리 내 사각형과 원만 사용합니다. 이 조합은 인식된 제스처가 겹치는 것을 허용하지만, 실제 입력이 겹치는지는 기기가 전달하는 이벤트에 따라 달라집니다.

Mac에서 동시 실행이란 무엇인가요?

Apple의 simultaneously(with:)는 두 제스처를 하나의 다른 제스처로 결합하므로, 그 결과를 세 번째 제스처와 결합할 수 있습니다. 이렇게 하면 길게 누르기나 다른 사전 동작 없이 패닝, 회전, 확대/축소를 조합할 수 있습니다. Apple의 동시 제스처 문서에서 이 조합을 설명합니다.

코드를 작성하기 전에 패닝의 의미를 정하세요. 여기서는 기본 마우스 버튼을 누른 채 드래그하는 동작을 뜻하며, 트랙패드를 클릭한 채 드래그하는 동작도 포함합니다. 트랙패드에서 손가락 두 개를 밀어 움직이면 보통 스크롤 입력이 발생합니다. Apple의 보관된 이벤트 가이드는 이 스크롤 이벤트 흐름을 확대 및 회전 제스처와 명확히 구분합니다. 현재 AppKit 문서에서도 scrollWheel(with:)을 통해 스크롤을 처리합니다. 드래그 제스처를 추가해도 스크롤이 드래그로 바뀌지는 않습니다. 트랙패드 이벤트 처리하기, scrollWheel(with:)

캔버스의 입력 계층을 선택할 때 다음 표를 참고하세요.

원하는 상호작용 시작점 유지해야 할 경계
클릭한 채 드래그해 패닝, 핀치 확대/축소, 트랙패드 회전 아래의 SwiftUI 예제 인식된 제스처 이벤트 흐름 간 중첩을 조합으로 허용합니다.
두 손가락 스크롤로 패닝 SwiftUI에 포함된 AppKit 캔버스 스크롤은 계속 스크롤 이벤트 흐름으로 처리됩니다.
한 가지 손가락 동작으로 세 구성 요소가 모두 포함된 변환을 보장 별도로 정의한 사용자 지정 상호작용 동시 조합만으로 하드웨어나 macOS가 모든 구성 요소를 내보낸다고 보장할 수 없습니다.

이 예제는 MagnifyGesture와 RotateGesture를 모두 사용할 수 있는 macOS 14 이상이 필요합니다. 최신 입력 종류 설정 API 대신 기본 이니셜라이저를 사용합니다. MagnifyGesture, RotateGesture

변환 상태는 어떻게 구성해야 하나요?

각 구성 요소에 확정된 값과 제스처 중의 실시간 값을 둡니다. 이동과 회전은 더해서 결합하고, 배율은 곱해서 결합합니다. 다음 식은 예제의 동작을 정의하며, SwiftUI가 요구하는 사항은 아닙니다.

표시할 이동 = 확정된 이동 + 실시간 드래그 이동
표시할 회전 = 확정된 회전 + 실시간 회전
표시할 배율 = clamp(확정된 배율 × 실시간 확대 배율)

실시간 값에는 @GestureState를 사용합니다. Apple 문서에 따르면 제스처가 비활성 상태가 되면 이 값은 초기화됩니다. 각 구성 요소는 성공적으로 완료된 뒤에만 실행되는 자체 onEnded 콜백에서 확정합니다. 이렇게 하면 취소의 의미가 명확해집니다. 해당 구성 요소의 미완성 미리 보기는 버리고 이전에 확정한 값은 유지합니다. GestureState, onEnded(_:)

이 콜백은 제스처를 조합하기 전에 각 개별 제스처에 연결하세요. 그러면 각 콜백이 고유한 타입의 값을 받고 자신이 담당하는 속성만 변경합니다. 나중에 시작된 제스처가 덮어쓸 수 있는 공용 시작 변환 스냅샷은 없습니다.

드래그 이동값은 특히 주의해야 합니다. 이 값은 제스처가 시작된 이후의 전체 이동량입니다. 업데이트할 때마다 이 누적 이동량을 확정된 오프셋에 더하면 이전 이동량을 계속해서 중복 계산하게 됩니다. 누적 이동량을 실시간 미리 보기 값에 할당하고, 완료 시 최종 이동량을 한 번만 더하세요. DragGesture.Value.translation

완전한 SwiftUI 예제 만들기

배포 대상이 macOS 14 이상인 macOS SwiftUI 앱을 만드세요. 생성된 앱 진입점과 콘텐츠 뷰를 아래 단일 파일로 바꾸고, 타깃에 @main 선언은 하나만 두세요. 에셋, 패키지, 외부 데이터는 필요하지 않습니다.

회색 캔버스가 제스처를 담당합니다. 자식 도형에 시각적 변환을 적용하므로 도형을 패닝하거나 회전해도 제스처 입력 표면은 변환되지 않습니다. 이 예제에서는 의도적으로 도형 중심을 기준으로 확대하고 회전합니다.

import SwiftUI

@main
struct TransformCanvasApp: App {
    var body: some Scene {
        WindowGroup {
            TransformCanvas()
                .frame(minWidth: 400, minHeight: 300)
        }
        .defaultSize(width: 720, height: 500)
    }
}

struct TransformCanvas: View {
    @State private var offset = CGSize.zero
    @State private var angle = Angle.zero
    @State private var scale: CGFloat = 1

    @GestureState private var liveOffset = CGSize.zero
    @GestureState private var liveAngle = Angle.zero
    @GestureState private var liveScale: CGFloat = 1

    private func boundedScale(_ value: CGFloat) -> CGFloat {
        min(max(value, 0.25), 4)
    }

    private var pan: some Gesture {
        DragGesture(minimumDistance: 3, coordinateSpace: .local)
            .updating($liveOffset) { value, state, _ in
                state = value.translation
            }
            .onEnded { value in
                offset = CGSize(
                    width: offset.width + value.translation.width,
                    height: offset.height + value.translation.height
                )
            }
    }

    private var rotation: some Gesture {
        RotateGesture()
            .updating($liveAngle) { value, state, _ in
                state = value.rotation
            }
            .onEnded { value in
                angle = angle + value.rotation
            }
    }

    private var zoom: some Gesture {
        MagnifyGesture()
            .updating($liveScale) { value, state, _ in
                state = value.magnification
            }
            .onEnded { value in
                scale = boundedScale(scale * value.magnification)
            }
    }

    private var manipulation: some Gesture {
        pan.simultaneously(with: rotation)
            .simultaneously(with: zoom)
    }

    var body: some View {
        ZStack {
            Color.gray.opacity(0.12)

            RoundedRectangle(cornerRadius: 18)
                .fill(Color.blue)
                .overlay(alignment: .topLeading) {
                    Circle()
                        .fill(Color.white)
                        .frame(width: 24, height: 24)
                        .padding(16)
                }
                .frame(width: 180, height: 120)
                .scaleEffect(
                    boundedScale(scale * liveScale),
                    anchor: .center
                )
                .rotationEffect(angle + liveAngle, anchor: .center)
                .offset(CGSize(
                    width: offset.width + liveOffset.width,
                    height: offset.height + liveOffset.height
                ))
                .allowsHitTesting(false)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Rectangle())
        .clipped()
        .gesture(manipulation)
    }
}

Apple 문서에서는 기본 드래그, 확대, 회전 제스처 타입과 뷰에 제스처를 연결하는 방법을 설명합니다. 예제는 이러한 API에 명시적인 상태 및 좌표 정책을 결합합니다. 연구 환경에서 SwiftUI를 사용할 수 없어 문서를 기준으로 코드를 살펴보았지만 여기서 컴파일하거나 실행하지는 않았습니다. DragGesture, MagnifyGesture, RotateGesture

배율을 적용한 다음 회전하고 오프셋을 적용하는 이유는 무엇인가요?

의도한 변환 순서는 도형을 중심을 기준으로 확대하고, 같은 중심을 기준으로 회전한 다음, 마지막으로 캔버스 좌표에서 이동하는 것입니다. 오프셋을 마지막에 적용하면 패닝이 도형의 확대와 회전 바깥에서 처리됩니다. 도형이 회전한 뒤에도 포인터를 가로로 움직이면 도형이 가로로 움직여야 합니다.

이 순서는 예제의 변환 파이프라인에서 선택한 설계 방식입니다. Apple 문서에 따르면 scaleEffect은 앵커를 기준으로 렌더링된 결과를 확대하고, rotationEffect은 렌더링된 결과를 회전하며, offset은 표시된 콘텐츠를 이동합니다. 이러한 시각 효과가 고정된 캔버스를 변환된 레이아웃 컨테이너로 바꾸지는 않습니다. scaleEffect(_:anchor:), rotationEffect(_:anchor:), offset(_:)

0.25와 4라는 한계값은 앱 설정의 예시입니다. 미리 보기와 확정에 같은 클램프를 적용하므로 제스처를 놓아도 표시된 배율이 유지됩니다. Swift의 min과 max는 이 범위를 표현하는 데 필요한 최솟값과 최댓값을 선택합니다. min(::), max(::)

구체적인 기대 동작으로 상호작용 확인하기

먼저 각 구성 요소를 따로 사용한 다음, 사용 가능한 입력 장치로 동시 입력을 시도하세요. 흰색 원은 사각형이 작아도 회전을 알아보기 쉽게 해줍니다.

  1. 캔버스를 클릭한 채 드래그하고 놓은 다음 다시 드래그합니다. 두 번째 드래그는 첫 번째 드래그에서 확정된 위치에서 시작해야 합니다.
  2. 핀치로 도형을 확대하고 놓은 다음 다시 핀치합니다. 각 제스처는 유지된 배율에 곱해져야 합니다.
  3. 회전하고 놓은 다음 다시 회전합니다. 각도가 누적되어야 합니다.
  4. 확대하고 회전한 뒤 가로로 드래그합니다. 도형은 계속 캔버스의 가로축을 따라 이동해야 합니다.
  5. 기기가 겹치는 입력을 전달하는 경우, 한 구성 요소를 종료하면서 다른 구성 요소를 계속 사용합니다. 계속되는 구성 요소는 자신이 담당하는 속성을 계속 업데이트해야 합니다.
  6. 예를 들어 앱을 전환해 활성 제스처를 중단합니다. 성공적으로 완료되지 않은 채 비활성화된 제스처는 실시간 미리 보기 값을 버리고, 임시 상태가 고착되지 않게 해야 합니다.

계산 예시로, 유지된 배율이 1.5이고 핀치에서 1.2가 보고된다고 가정해 보겠습니다. 표시되는 결과는 1.8입니다. 유지된 각도가 30도이고 실시간 회전이 15도라면 표시되는 각도는 45도입니다. 드래그 이동량 40포인트는 배율과 각도에 관계없이 캔버스 오프셋 40포인트로 유지됩니다.

이 예제에서는 구성 요소를 각각 확정합니다. 회전이 성공적으로 끝난 뒤 아직 활성 상태인 드래그가 취소되면 완료된 회전은 유지되고 미완성 드래그는 되돌아갑니다. 모든 구성 요소를 하나의 원자적 작업으로 처리해야 하는 편집기에는 명시적인 확정 및 롤백 정책을 가진 추가 상호작용 세션 모델이 필요합니다.

이동에는 의도적으로 범위 제한을 두지 않았으므로 도형을 보이는 캔버스 밖으로 드래그할 수도 있습니다. 캔버스는 계속 드래그할 수 있으므로 도형을 반대 방향으로 다시 이동할 수 있습니다. 완성된 뷰어에서는 별도의 초기화 명령을 추가하거나 콘텐츠 경계에 따라 이동 범위를 제한할 수 있습니다.

대신 AppKit 캔버스를 사용해야 하는 경우는 언제인가요?

작업에 두 손가락 스크롤 패닝이나 스크롤 관성의 직접 제어가 필요한 경우 AppKit 입력 계층을 선택하세요. Apple이 SwiftUI 안에서 AppKit 뷰를 생성, 업데이트, 해제하도록 제공하는 NSViewRepresentable을 통해 직접 관리하는 AppKit 뷰를 포함합니다. NSViewRepresentable

각 입력 이벤트 흐름을 하나의 계층에서만 처리하세요. AppKit 캔버스에서는 scrollWheel(with:)을 통해 스크롤을 처리하고 확대와 회전에는 인식기를 사용합니다. 그 결과로 나온 변경 사항을 같은 이동, 각도, 배율 모델에 전달합니다. 같은 입력을 그 모델에 적용하는 SwiftUI 핸들러를 추가로 연결하지 마세요.

함께 동작해야 하는 AppKit 인식기에는 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)을 구현하고 캔버스에서 의도한 인식기 쌍에만 true를 반환하세요. Apple 문서에 따르면 이 대리자 메서드는 인식기의 동시 인식을 허용하지만, 누락된 입력 이벤트 흐름을 만들어내지는 않습니다. 동시 인식 대리자 메서드

브리지에서 값의 규약을 유지하세요. AppKit 확대 인식기는 배율을 얻으려면 1에 더해야 하는 값을 보고하고, 회전 인식기는 라디안 값을 제공합니다. SwiftUI 값 할당을 수정 없이 그대로 가져오지 마세요. NSMagnificationGestureRecognizer.magnification, NSRotationGestureRecognizer.rotation

스크롤의 경우 Apple은 scrollingDeltaX을 기본 수평 델타 속성으로 지정하며, 정밀하지 않은 델타는 조정이 필요할 수 있다고 설명합니다. 관성 문서에서는 물리적인 제스처가 멈춘 뒤에도 스크롤 이벤트가 계속될 수 있다고 설명합니다. 캔버스에서 이 연속 입력을 받아들일지 결정하고, 허용된 각 이벤트는 한 번만 전달하세요. scrollingDeltaX, momentumPhase

브리지는 캔버스의 콘텐츠 변환을 변경하고, SwiftUI는 표현된 뷰의 레이아웃을 계속 담당해야 합니다. Apple은 표현된 뷰의 프레임이나 바운드를 직접 설정하지 말라고 명시적으로 경고합니다. representable의 코디네이터를 사용해 대리자 및 target-action의 변경 사항을 SwiftUI로 전달하세요. NSViewRepresentable

클릭한 채 드래그하는 뷰어라면 SwiftUI 구현이 고정된 입력 표면 하나, 개별 관리되는 제스처 구성 요소 세 개, 명확한 변환 순서를 갖춘 완전한 시작점을 제공합니다. 이를 확장할 때도 이러한 경계를 유지하고, 사용자 관점의 작업에 스크롤 패닝이 필요하면 AppKit 방식을 선택하세요.

출처