캔버스 드래그가 끝나면 대상의 좌표 공간에서 항목의 최종 기하 정보를 계산합니다. 먼저 완전히 포함되는지 확인한 다음, 면적이 양수인 겹침이 있는지 확인합니다. 이 튜토리얼에서는 Mac 앱에서 직사각형 항목에 이 규칙을 적용하고 이에 해당하는 AppKit 구현도 설명합니다.

겹침과 포함의 기준 정의하기

움직일 수 있는 파란 직사각형과 고정된 회색 대상이 있는 단순한 데스크톱 캔버스를 생각해 봅시다. 사용자는 놓은 항목이 대상 안에 완전히 들어가는지, 일부 겹치는지, 또는 대상과 아무 면적도 공유하지 않는지 확인하면 됩니다. 예시의 모든 요소는 합성 데이터이며 메모리에 저장됩니다.

포함은 방향성이 있습니다. 대상이 항목을 포함해야 합니다. Apple 문서에 따르면 CGRectContainsRect(target, item)는 직사각형들의 합집합이 대상과 같을 때 true를 반환합니다. 여기에는 대상과 정확히 일치하는 항목도 포함됩니다. 포함 여부를 먼저 확인하면 완전히 포함된 항목이 부분 겹침으로 판정되는 일을 방지할 수 있습니다. CGRectContainsRect 문서

이 예시에서는 폭과 높이가 모두 양수인 공통 영역이 있어야 겹친 것으로 봅니다. 가장자리나 모서리만 맞닿은 경우는 겹치는 면적 없음으로 처리합니다. 접촉 시 동작을 암묵적으로 두지 말고 교차 영역을 계산해 그 크기를 확인하세요. Apple 문서에 따르면 CGRectIntersection은 공통 직사각형을 반환하며, 교차 영역이 없으면 null 직사각형을 반환합니다. CGRectIntersection 문서

결과 이 튜토리얼에서 사용하는 규칙 예시
포함됨 경계가 일치하는 경우를 포함해 대상이 항목 전체를 포함함 항목이 대상 안에 들어가거나 대상과 정확히 일치함
겹침 포함되지는 않지만 공통 영역의 폭과 높이가 모두 양수임 항목이 대상의 가장자리를 가로지름
겹치는 면적 없음 면적이 양수인 교차 영역이 없음 항목들이 떨어져 있거나 가장자리만 맞닿음
유효하지 않은 기하 정보 어느 한 직사각형의 좌표가 유한수가 아니거나 크기가 양수가 아님 폭이 0인 항목

이는 기하 연산에서 도출한 애플리케이션 규칙입니다. 인터페이스에서 대상 안쪽에 여백을 두어야 한다면 여백을 별도로 정의하고 더 작은 허용 직사각형을 기준으로 확인하세요. 항목을 드래그하는 사람이 보이는 대상과 허용 규칙을 이해할 수 있도록 하세요.

하나의 좌표 공간과 드래그의 최종 이동량 사용하기

그리는 직사각형과 분류하는 직사각형은 같은 위치를 나타내야 합니다. 이 예시에서는 항목과 대상 모두 캔버스 좌표를 사용합니다. 캔버스에 이름이 지정된 좌표 공간을 설정하고 항목의 DragGesture에서도 같은 이름을 사용합니다. Apple의 최신 coordinateSpace(_:) 수정자는 macOS 14 이상에서 이 패턴을 지원합니다. 이름이 지정된 좌표 공간 문서

항목의 확정된 직사각형과 임시 드래그 이동량은 따로 저장합니다. 제스처 중에는 확정된 직사각형을 이동량만큼 옮겨 그립니다. 제스처가 성공적으로 끝나면 콜백 값으로 최종 직사각형을 다시 계산하고 분류한 뒤 한 번만 확정합니다.

Apple은 translation를 드래그 시작 시점부터 현재 이벤트까지의 전체 변위로 정의합니다. 따라서 이미 갱신된 위치에 전체 이동량을 계속 더하면 이동이 과도하게 누적됩니다. 대신 변경되지 않은 확정 위치에 이동량을 적용하세요. 드래그 이동량 문서

놓은 지점만으로는 항목 전체가 대상 안에 들어가는지 알 수 없습니다. 사용자는 직사각형의 한쪽 모서리 근처를 잡을 수 있으므로 포인터가 대상 안에 있어도 항목 대부분은 바깥에 남을 수 있습니다. 이동량을 적용하면 원래 잡은 위치도 유지되며 항목 중심이 포인터로 이동하지 않습니다.

완전한 SwiftUI 직사각형 예제 만들기

Xcode에서 배포 대상이 macOS 14 이상인 macOS SwiftUI 앱을 만드세요. 생성된 앱 진입점을 아래 두 블록으로 바꾸고 하나의 Swift 파일에 차례대로 배치하세요. 원래 앱 진입점은 삭제해 대상에 @main 선언이 하나만 있도록 하세요. 두 블록을 합치면 완전한 프로그램이 됩니다.

첫 번째 블록은 기하 분류기를 정의합니다. 잘못된 크기를 다른 직사각형으로 조용히 바꾸지 않고 명시적으로 거부합니다. Swift 열거형은 네 가지 결과를 서로 구분하며 문자열 원시값을 표시 메시지로 사용합니다. Swift 열거형 문서

import SwiftUI
import CoreGraphics

enum Placement: String {
    case contained = "Contained"
    case overlap = "Overlap"
    case separate = "No area overlap"
    case invalid = "Invalid geometry"
}

func isUsable(_ rect: CGRect) -> Bool {
    let values = [rect.origin.x, rect.origin.y,
                  rect.width, rect.height,
                  rect.maxX, rect.maxY]
    return values.allSatisfy { $0.isFinite }
        && rect.width > 0 && rect.height > 0
}

func classify(item: CGRect, target: CGRect) -> Placement {
    guard isUsable(item), isUsable(target) else {
        return .invalid
    }
    if CGRectContainsRect(target, item) {
        return .contained
    }
    let shared = CGRectIntersection(item, target)
    if !shared.isNull && shared.width > 0 && shared.height > 0 {
        return .overlap
    }
    return .separate
}

두 번째 블록은 앱과 캔버스를 제공합니다. Apple의 DragGesture 문서에 나온 것처럼 제스처를 움직이는 직사각형에 연결합니다. 고정된 캔버스 크기를 사용하면 이 예시의 기하 정보를 명확하게 유지할 수 있습니다. DragGesture 문서

@main
struct RectanglePlacementApp: App {
    var body: some Scene {
        WindowGroup {
            PlacementCanvas()
        }
    }
}

struct PlacementCanvas: View {
    private let target = CGRect(x: 200, y: 100,
                                width: 200, height: 160)
    @State private var item = CGRect(x: 40, y: 120,
                                     width: 80, height: 60)
    @State private var message = "Drag the blue item, then release."
    @GestureState private var translation: CGSize = .zero

    private var preview: CGRect {
        item.offsetBy(dx: translation.width,
                      dy: translation.height)
    }

    private var move: some Gesture {
        DragGesture(minimumDistance: 1,
                    coordinateSpace: .named("placementCanvas"))
            .updating($translation) { value, state, _ in
                state = value.translation
            }
            .onEnded { value in
                let finalItem = item.offsetBy(
                    dx: value.translation.width,
                    dy: value.translation.height
                )
                let result = classify(item: finalItem, target: target)
                message = result.rawValue
                if result != .invalid {
                    item = finalItem
                }
            }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(message)
            ZStack(alignment: .topLeading) {
                Color.white
                Rectangle()
                    .fill(Color.gray.opacity(0.25))
                    .frame(width: target.width, height: target.height)
                    .position(x: target.midX, y: target.midY)
                Rectangle()
                    .fill(Color.blue)
                    .frame(width: item.width, height: item.height)
                    .position(x: preview.midX, y: preview.midY)
                    .gesture(move)
            }
            .frame(width: 640, height: 380)
            .coordinateSpace(.named("placementCanvas"))
            Button("Reset") {
                item = CGRect(x: 40, y: 120, width: 80, height: 60)
                message = "Drag the blue item, then release."
            }
        }
        .padding()
    }
}

파란 항목을 회색 대상으로 드래그한 뒤 놓으세요. 메시지에는 마지막으로 완료된 배치 결과가 표시됩니다. 일부 겹치거나 떨어져 있는 항목은 놓은 위치에 그대로 남습니다. 포함되었다고 해서 부모 항목이 바뀌거나 항목이 다른 컨테이너로 자동 이동하지는 않습니다. 초기화 버튼을 누르면 시작 직사각형으로 돌아갑니다.

제스처의 업데이트 콜백에서는 위치를 지속적으로 갱신하지 않습니다. @GestureState에는 미리 보기 이동량만 저장되며 제스처가 비활성화되면 초기화됩니다. Apple은 onEnded가 성공적으로 완료된 뒤에만 실행된다고 명시하며, 제스처 종료 방식과 상관없이 초기화되어야 하는 값에는 제스처 상태를 권장합니다. 따라서 제스처가 중단되면 확정된 직사각형과 이전 결과는 유지되고 미리 보기만 버려질 수 있습니다. GestureState 문서, onEnded 문서

이 구현에서는 채워진 직사각형을 기하 정보로 취급합니다. 장식용 그림자나 윤곽선을 추가해도 허용 직사각형이 커지지는 않습니다. 크기 조정, 확대·축소 또는 회전을 도입한다면 동일한 분류기를 사용하기 전에 기하 모델과 좌표 변환을 갱신하세요.

항목을 늘리기 전에 경계 사례 확인하기

예시의 대상에는 다음 합성 사례를 사용하세요. 원점은 (200, 100), 폭은 200, 높이는 160입니다. 좌표는 같은 캔버스 공간에서 항목 직사각형을 나타냅니다.

항목 원점 항목 크기 예상 결과 이유
(220, 120) 80 × 60 포함됨 모든 가장자리가 대상 안에 있음
(360, 120) 80 × 60 겹침 항목이 대상의 오른쪽 가장자리를 넘어감
(400, 120) 80 × 60 겹치는 면적 없음 세로 가장자리만 맞닿음
(401, 120) 80 × 60 겹치는 면적 없음 직사각형 사이에 간격이 있음
(200, 100) 200 × 160 포함됨 같은 직사각형은 포함 조건을 만족함
(180, 80) 240 × 200 겹침 항목이 대상을 둘러싸므로 포함 방향이 반대임

표의 계산은 별도의 결정적 계산으로 확인했습니다. 조사 환경에 Swift 컴파일러가 없어 Swift 프로그램은 검토만 했으며 컴파일하지 않았습니다. macOS 앱 대상에서 빌드하고 각 경계 근처에 놓는 동작을 시험하세요.

메모리 내 항목이 여러 개라면 드래그가 시작될 때 선택된 항목의 식별 정보를 유지하세요. 완료 시 해당 항목의 최종 기하 정보를 적격 대상과 비교하고 항목 자신은 제외합니다. 여러 대상이 조건을 만족할 경우 명시적인 대상 순서처럼 일관된 선택 규칙을 정하세요. 기하 분류만으로는 어느 대상이 항목을 소유할지 결정되지 않습니다.

AppKit 캔버스에 같은 기하 규칙 적용하기

기존 사용자 지정 AppKit 뷰에서는 이벤트 전달과 분류기를 분리하세요. mouseUp(with:)는 마우스 왼쪽 버튼을 놓을 때 호출됩니다. 이를 사용해 캔버스가 이미 소유하고 있는 드래그만 마무리하세요. 관련 없는 마우스 버튼 놓기가 배치를 실행해서는 안 됩니다. AppKit 마우스 버튼 놓기 문서

실제 이벤트 순서는 다음과 같습니다.

  1. mouseDown(with:)에서 캔버스의 기존 적중 테스트 규칙을 사용해 합성 항목을 선택합니다. 해당 항목의 식별 정보, 시작 직사각형, 시작 시 포인터 위치를 저장합니다.
  2. 관련 마우스 이벤트 위치를 캔버스 좌표로 변환합니다. 윈도우에 연결된 뷰의 변환 식은 convert(event.locationInWindow, from: nil)입니다.
  3. mouseDragged(with:)에서 저장한 직사각형과 포인터 변위를 사용해 미리 보기 위치를 계산합니다.
  4. mouseUp(with:)에서 놓기 이벤트를 사용해 변위를 다시 계산하고 최종 직사각형을 분류한 다음 한 번만 확정하고 활성 드래그를 지웁니다.

Apple 문서에 따르면 locationInWindow은 마우스 이벤트 위치를 윈도우 좌표로 보고합니다. NSView.convert(_:from:)의 점 변환 오버로드는 뷰 인수가 nil일 때 윈도우 좌표에서 변환합니다. 이를 통해 윈도우 좌표의 점과 캔버스의 로컬 좌표로 표현한 직사각형을 비교하는 일을 방지할 수 있습니다. 이벤트 위치 문서, 점 변환 문서

이벤트 처리는 이미 상호작용을 소유한 컴포넌트에서 하세요. Apple은 이벤트를 직접 처리하는 NSView 하위 클래스와 소비되지 않은 이벤트를 부모가 처리하도록 해야 하는 하위 클래스를 구분합니다. 문서에 명시된 응답자 동작을 따르세요. 배치 로직을 추가한다고 해서 다른 뷰의 이벤트 흐름을 가로채거나 동일한 놓기 이벤트를 독립된 두 처리기에서 처리해서는 안 됩니다. NSView 이벤트 처리 지침

직사각형만으로 도형을 판단할 수 없는 경우

이 예시에서는 축에 정렬된 채워진 직사각형을 정확하게 판정합니다. 원, 오목한 윤곽선, 구멍 또는 회전된 도형에서는 경계 직사각형으로 기하를 예비 검사할 수 있을 뿐입니다. 직사각형의 공통 영역에 어느 쪽의 가시 도형도 채우지 않는 공간이 포함될 수 있습니다.

경로 안에 포인터가 있는지 검사하는 것만으로 다른 도형이 완전히 포함되는지 판단할 수도 없습니다. AppKit의 NSBezierPath.contains(_:)는 경로의 좌표 공간에 있는 점 하나를 입력으로 받으며, Apple은 선 두께를 고려하지 않는다고 설명합니다. 따라서 항목의 중심을 검사하면 그 중심이 경로 안에 있는지만 알 수 있습니다. NSBezierPath 점 포함 문서

macOS 14 이상에서는 SwiftUI가 채워진 경로에 지원되는 불리언 연산을 문서화하고 있습니다. Path.intersection(_:eoFill:)은 공통으로 채워진 영역을 만들고, Path.subtracting(_:eoFill:)는 첫 번째 채워진 영역에서 두 번째 영역을 제외한 부분을 만듭니다. 이러한 연산을 통해 경로를 기준으로 겹침과 대상 바깥에 있는 항목의 일부를 조사할 수 있습니다. 두 경로를 같은 좌표 공간에 맞추고 렌더링에 맞는 채우기 규칙을 사용하세요. Path 교집합 문서, Path 빼기 문서

이 연산은 채워진 영역을 대상으로 하며 닫히지 않은 하위 경로를 닫힌 것으로 간주합니다. 획이 있는 선이나 경계 접촉에 관한 애플리케이션 규칙에는 별도의 기하 정의가 필요합니다. 직사각형 항목에는 직사각형 분류기를 유지하고 캔버스에 다른 도형을 그리기 시작하면 기하 계층을 신중하게 확장하세요.

신뢰할 수 있는 놓기 결과를 얻으려면 세 가지를 구체적으로 정하면 됩니다. 드래그 시작 시의 기하 정보를 유지하고, 대상 좌표 공간에서 최종 항목을 계산하며, 포함과 겹침에 대한 명시적 정책을 적용하세요. 이 조건을 정해 두면 기하 정보와 이벤트 전달 또는 항목 소유권을 결합하지 않고도 인터페이스가 결과를 일관되게 표시할 수 있습니다.

출처