활성 드래그 미리보기를 확정된 캔버스 도형과 분리하여 빠른 사각형 그리기 반응성을 유지하세요. Mac 드로잉 앱에서 하나의 제스처 표면이 입력을 담당하게 하고, 임시 지오메트리를 로컬에 유지하며, 드래그가 성공적으로 완료되었을 때만 도형 컬렉션에 추가하세요.

이 가이드는 직접 macOS 앱에서 간단한 클릭-드래그 사각형 도구를 만드는 개발자를 대상으로 합니다. 모든 예제 도형은 메모리에 상주합니다. 목표는 포인터 이동이 전체 드로잉 모델을 반복적으로 변경하는 것을 방지한 다음, 프로파일링을 사용하여 남은 렌더링 비용을 식별하는 것입니다.

확정된 도형이 누적되면 그리기가 왜 느려지는가?

입력 수신, 상태 업데이트, 지오메트리 렌더링이라는 세 가지 작업을 구분하는 것부터 시작하세요. 드래그는 임시 지오메트리를 자주 변경할 수 있습니다. 이러한 변경이 확정된 컬렉션을 대체하거나, 모든 경로를 재구성하거나, 광범위한 뷰 계층을 무효화하는 경우, 각 이동과 관련된 작업량이 드로잉과 함께 증가할 수 있습니다.

Apple은 SwiftUI가 종속성을 추적하고 해당 종속성이 변경될 때 뷰를 업데이트한다고 설명합니다. 느린 개별 뷰 업데이트와 불필요한 다수의 업데이트 모두 프레임 마감 기한을 놓치게 할 수 있습니다. 자주 변경되는 미리보기 상태를 확정된 콘텐츠와 분리하는 것은 문서화된 모델의 아키텍처적 적용입니다. Optimize SwiftUI performance with Instruments

Canvas는 그래픽 컨텍스트를 통해 즉시 모드 그리기를 제공합니다. Apple은 이를 복잡한 동적 드로잉의 잠재적 성능 개선으로 식별하지만, 그려진 각 요소에 대한 상호작용이나 접근성을 제공하지는 않습니다. 입력 처리를 의도적인 드로잉 표면에 연결하세요; 앱이 나중에 개별적으로 조작 가능한 도형을 필요로 한다면 별도의 선택 및 접근성 지원을 구현하세요. Canvas

따라서 유용한 경계는 명시적입니다: 입력 표면이 제스처를 소유하고, 임시 상태가 미리보기를 소유하며, 부모가 확정된 사각형을 소유합니다. 이러한 책임을 분할하면 피할 수 있는 종속성이 줄어듭니다. 이는 시스템이 확정된 캔버스를 결코 다시 그리지 않는다는 것을 보장하지는 않습니다.

미리보기와 확정된 드로잉 분리

렌더링 최적화를 선택하기 전에 이 소유권 계획을 사용하세요:

책임 소유자 변경 시점
현재 드래그 사각형 제스처 표면 활성 드래그 중
확정된 사각형 컬렉션 부모 드로잉 뷰 성공적인 드래그당 한 번
확정된 사각형 그리기 배경 캔버스 입력이 업데이트를 요구할 때
포인터 인식 제스처 표면 제스처 시퀀스 전반

임시 지오메트리의 경우, 제스처가 비활성화되면 SwiftUI가 이를 재설정하므로 @GestureState가 유용합니다. onEnded을 통해 확정하세요. 이 콜백은 최종 제스처 값을 수신합니다. Apple은 이 콜백이 제스처가 성공적으로 끝날 때만 실행된다고 명시합니다; 제스처 상태는 제스처가 어떻게 끝나든 정리 작업을 처리합니다. GestureState, onEnded(_:)

사각형의 경우 최종 지오메트리에는 시작 위치와 현재 위치만 필요합니다. 모든 중간 포인터 위치를 유지할 필요가 없습니다. Apple은 해당 위치들을 예측된 엔드포인트와 별도로 문서화합니다; 사각형을 확정할 때 실제 최종 위치를 사용하세요. DragGesture.Value

완전한 인메모리 사각형 도구 빌드

다음 두 코드 블록은 하나의 완전한 Swift 소스 파일을 형성합니다. macOS 12 이상을 대상으로 하는 macOS SwiftUI 앱 프로젝트에서 생성된 앱 진입 파일을 두 블록으로 모두 대체하고 다른 앱 진입 선언을 제거하세요. Canvas는 macOS 12부터 사용 가능합니다. 이 예제는 검토되었으나 Swift 툴체인이 없는 환경에서는 컴파일할 수 없었습니다. Canvas

첫 번째 블록은 앱, 확정된 컬렉션, 그리고 그 렌더러를 정의합니다. 부모는 완성된 사각형만 수신하며, 활성 드래그 상태를 읽지 않습니다.

import SwiftUI

@main
struct RectangleDrawingApp: App {
    var body: some Scene {
        WindowGroup {
            DrawingBoard()
        }
    }
}

struct DrawingBoard: View {
    @State private var rectangles: [CGRect] = []

    var body: some View {
        VStack(spacing: 8) {
            Text("Drag to draw a rectangle")
            Text("Committed rectangles: \(rectangles.count)")

            ZStack {
                Color.white

                CommittedRectangles(rectangles: rectangles)
                    .allowsHitTesting(false)

                RectangleGestureSurface { rectangle in
                    rectangles.append(rectangle)
                }
            }
            .clipped()
            .frame(minWidth: 480, minHeight: 320)
        }
        .padding()
    }
}

struct CommittedRectangles: View {
    let rectangles: [CGRect]

    var body: some View {
        Canvas { context, _ in
            for rectangle in rectangles {
                context.stroke(
                    Path(rectangle),
                    with: .color(.blue),
                    lineWidth: 2
                )
            }
        }
    }
}

다음 블록을 같은 파일에 추가하세요. 투명한 표면은 드로잉 영역을 덮고, 히트 테스트에 명시적인 사각형 모양을 제공하며, 유일한 드로잉 제스처를 소유합니다. Apple은 contentShape를 히트 테스트 모양 정의로, allowsHitTesting(false)를 뷰를 히트 테스트에서 제외하는 것으로 문서화합니다. contentShape(_:eoFill:), allowsHitTesting(_:)

struct RectangleGestureSurface: View {
    let commit: (CGRect) -> Void
    @GestureState private var preview: CGRect? = nil

    var body: some View {
        ZStack {
            Color.clear

            Canvas { context, _ in
                if let rectangle = preview {
                    context.stroke(
                        Path(rectangle),
                        with: .color(.orange),
                        lineWidth: 2
                    )
                }
            }
            .allowsHitTesting(false)
        }
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0, coordinateSpace: .local)
                .updating($preview) { value, state, _ in
                    state = Self.rectangle(
                        from: value.startLocation,
                        to: value.location
                    )
                }
                .onEnded { value in
                    let rectangle = Self.rectangle(
                        from: value.startLocation,
                        to: value.location
                    )
                    commit(rectangle)
                }
        )
    }

    private static func rectangle(
        from start: CGPoint,
        to end: CGPoint
    ) -> CGRect {
        CGRect(
            x: min(start.x, end.x),
            y: min(start.y, end.y),
            width: abs(end.x - start.x),
            height: abs(end.y - start.y)
        )
    }
}

minimumDistance: 0은 인식을 위한 최소 이동 요구 사항을 제거합니다. 로컬 좌표 공간은 제스처 위치를 동일한 크기의 미리보기 캔버스와 정렬되게 유지합니다. 이들은 문서화된 제스처 구성 옵션이며, 이벤트 빈도나 디스플레이 지연 시간에 대한 보장이 아닙니다. DragGesture

사각형 도우미는 네 가지 드래그 방향을 모두 정규화합니다. 예시 검증으로, (90, 80)에서 (30, 20)로 드래그하면 원점 (30, 20)과 크기 (60, 60)이 생성됩니다. 미리보기와 확정이 동일한 계산을 사용하므로 포인터를 놓아도 지오메트리 공식은 변경되지 않습니다.

이 최소 도구는 너비 0, 높이 0인 사각형도 확정합니다. 따라서 클릭만으로도 보이는 사각형 없이 개수가 증가할 수 있습니다. 제품에 최소 크기가 필요한 경우, 확정 시점에 해당 규칙을 명시적으로 추가하고 사용자에게 적절한 피드백을 제공하세요. 이 예제는 작은 결과를 자동으로 필터링하는 대신 성공적으로 완료된 모든 제스처를 유지합니다.

표시 영역은 클리핑되지만 저장된 지오메트리는 변경되지 않습니다. 따라서 보드 밖으로 확장된 사각형은 보드 밖 좌표를 포함할 수 있습니다. 좌표 고정을 추가하기 전에 향후 리사이즈 시 해당 지오메트리를 드러낼지 여부를 결정하세요.

캐시 추가 전 남은 비용 진단

이 예제는 드래그 업데이트 경로에서 확정된 모델 변경을 제거합니다. 그러나 확정된 렌더러는 해당 렌더러가 실행될 때마다 컬렉션을 순회합니다. 감소된 상태 전파와 감소된 렌더링 작업을 구별하세요: 별도의 뷰는 문서화된 지속적인 이미지 캐시가 아닙니다.

어떤 비용이 남는지 찾기 위해 통제된 비교를 사용하세요. 0개, 500개, 5,000개 사각형과 같은 설명용 인메모리 개체군을 선택하세요. 사각형 크기, 윈도우 크기, 디스플레이 스케일, 빌드 구성을 일관되게 유지하세요. 이 개수는 테스트 입력일 뿐, 용량 한계나 측정된 결과가 아닙니다.

세 가지 간격을 별도로 기록하세요: 컬렉션이 변경되지 않은 상태에서의 드래그, 확정을 위한 릴리즈, 윈도우 리사이즈. 지속적인 드래그 느려짐은 확정 시에만 발생하는 일시 정지와 다른 작업을 시사합니다. 리사이즈 동작은 일반적인 그리기에서 나타나지 않는 지오메트리 또는 캐시 재구축을 드러낼 수 있습니다.

Apple의 SwiftUI 프로파일링 세션은 Long View Body Updates, Long Representable Updates, Cause & Effect Graph 도구와 Time Profiler를 설명합니다. 의존성 그래프를 사용하여 확정된 뷰가 드래그 중에 업데이트되는 이유를 조사하고, CPU 샘플을 사용하여 비용이 많이 드는 지오메트리 구성 또는 렌더링 작업을 찾으세요. Apple은 Instruments 26에서 해당 계측 워크플로를 도입했습니다. Optimize SwiftUI performance with Instruments

더 광범위한 멈춤의 경우, Apple의 WWDC26 반응성 세션은 Time Profiler로 시작하여 바쁜 실행과 대기 시간을 구별합니다. 작업 간 이동이나 렌더링 스택 교체 전에 이 구별을 적용하세요. Profile, fix, and verify: Improve app responsiveness with Instruments

다음 결정 지원 도구는 위 프로파일링 도구와 소유권 설계로부터 도출된 엔지니어링 추론입니다:

관찰된 동작 우선 조사 사항 후보 변경 사항
포인터 이동 중 부모 드로잉 상태 변경 제스처 업데이트 콜백 및 공유 상태 미리보기 상태를 제스처 표면 내부에 유지
이동 중 확정 드로잉이 샘플 지배 반복 순회 및 경로 생성 지오메트리 사전 계산 또는 확정 콘텐츠 캐시 도입
컬렉션 증가에 따라 릴리즈 일시 정지 길어짐 배열 변형 및 전체 드로잉 재구성 유지된 스냅샷 줄이고 영향을 받는 파생 콘텐츠만 재구성
사각형 그리기는 반응형이나 긴 자유 곡선 그리기는 느림 활성 경로 크기 및 재구성 증가하는 활성 스트로크 별도 프로파일링
일시 정지가 주로 리사이즈 중 발생 크기 의존적 렌더링 및 캐시 무효화 파생 콘텐츠에 대한 명시적 리사이즈 동작 정의

캐시는 또 다른 소유권 경계를 추가합니다: 사각형 지오메트리는 권위 있는 소스로 남고, 캐시는 교체 가능한 파생 콘텐츠입니다. 무효화 설계에 드로잉 크기, 디스플레이 스케일, 관련 스타일링을 포함하세요. 추가만 있는 드로잉의 경우 증분 캐시 업데이트가 최적화 후보가 됩니다; 편집과 삭제에는 명확한 재구축 또는 영역 무효화 전략이 필요합니다. 동일한 입력 개체군으로 결과를 검증한 후 추가된 복잡성을 유지할지 결정하세요.

확정 경계에서 배열 복사 확인

Swift 배열은 값 의미론을 가지며 copy-on-write 저장소를 사용합니다. 복사본은 변형이 독립적인 저장소를 요구할 때까지 저장소를 공유할 수 있습니다; 클래스 인스턴스를 포함하는 배열은 여전히 해당 인스턴스에 대한 참조를 공유합니다. 배열을 뷰에 전달하거나 배열에 추가하는 것을 항상 모든 요소를 복사하는 것이라고 설명해서는 안 됩니다. Array

이 도구의 경우, 포인터 업데이트마다 새로운 전체 드로잉 스냅샷을 저장하지 마세요. 프로파일링에서 확정 중 복사가 식별되면 어떤 스냅샷이 살아 있는지와 드로잉 모델이 어떻게 공유되는지 검사하세요. 복사를 피하기 위해 값 타입을 공유 가변 객체로 대체하는 것은 소유권 의미론을 변경하며 별도의 설계 결정이 필요합니다.

비동기 프레젠테이션을 측정된 옵션으로 취급

Apple은 rendersAsynchronously를 캔버스가 콘텐츠를 부모에게 비동기적으로 프레젠테이션할 수 있게 하는 것으로 정의합니다. 이 설명은 더 빠른 제스처 전달을 약속하지 않으며, 제스처 핸들러를 백그라운드 실행기로 이동시키거나 컬렉션 순회를 제거하지도 않습니다. 입력과 상태 경계를 설정한 후 별도로 평가하세요. rendersAsynchronously

언제 AppKit이 드로잉 표면을 소유해야 하는가?

작업이 직접 리스폰더 메서드 처리나 드로잉 영역의 명시적 무효화를 요구할 때 커스텀 AppKit 표면을 사용하세요. Apple은 NSView를 그리기 및 이벤트 처리를 위한 인프라로 문서화하며, 커스텀 동작을 위한 서브클래싱을 지원합니다. 이는 자체 렌더링 책임을 가진 지원되는 대안입니다. NSView

AppKit 구현의 경우 동일한 순서를 유지하세요: mouseDown(with:)에서 임시 지오메트리 초기화, mouseDragged(with:)에서 업데이트, mouseUp(with:)에서 확정. 지오메트리 계산 전에 event.locationInWindow를 수신 뷰의 좌표로 변환하세요. Apple의 아카이브된 마우스 이벤트 가이드가 이 순서와 좌표 변환을 문서화하며, 현재 NSView 참조가 지원되는 리스폰더 메서드를 문서화합니다. Handling Mouse Events, NSView

needsDisplay = true를 설정하여 디스플레이를 요청하세요; Apple은 그러면 뷰가 다음 이벤트 루프 패스에서 다시 표시된다고 명시합니다. 영역 기반 설계의 경우, 스트로크를 포함하도록 확장된 이전 및 새 미리보기 경계의 합집합을 무효화하세요. 드로잉 구현도 영향을 받지 않는 콘텐츠 처리를 피하는 경우에만 영역 무효화가 순회를 줄입니다. needsDisplay, Handling Mouse Events

NSViewRepresentable로 표면을 임베드하세요: makeNSView에서 생성하고, updateNSView에서 외부 상태를 조정하며, 코디네이터를 사용해 변경 사항을 SwiftUI로 다시 전달하세요. 업데이트 중 콜백 라우팅을 갱신하고, SwiftUI가 다른 표현 가능한 값을 제공한다는 이유만으로 활성 초안을 재설정하지 마세요. Apple은 또한 SwiftUI가 표현된 뷰의 레이아웃을 제어한다고 명시합니다; 관리되는 프레임이나 경계를 직접 덮어쓰지 마세요. NSViewRepresentable

드로잉 입력에 대한 하나의 소유자를 선택하세요. AppKit이 드로잉 시퀀스를 처리하는 경우, 해당 시퀀스를 겹치는 SwiftUI 드래그 제스처로도 확정하지 마세요. 직접 NSView 서브클래스의 경우, Apple은 나열된 마우스 이벤트를 처리하는 구현이 해당 처리된 이벤트에 대해 슈퍼클래스 구현을 호출하지 않도록 지시합니다. NSView

첫 번째 개선을 작고 관찰 가능하게 유지하세요: 로컬 미리보기 상태, 단일 확정 경계, 이동 중 입력이 변경되지 않는 확정 렌더러. 그런 다음 Instruments에서 증가하는 합성 드로잉을 비교하세요. 결과 추적이 다음 유용한 변경이 의존성 관리, 컬렉션 소유권, 지오메트리 준비, 또는 렌더링 중 어디에 속하는지 알려줍니다.

출처