세로 목록 안에 가로 선반이 있는 Mac 앱에서는 각 제스처가 향할 곳을 명확히 정하세요. 먼저 SwiftUI의 축 설정을 사용하고, 명시적인 라우팅이 필요하면 SwiftUI 브리지를 통해 중첩된 AppKit 스크롤 뷰를 직접 관리하세요.

여기서 구체적으로 다룰 작업은 가상의 카드 둘러보기입니다. 어느 선반 위에서든 세로로 스크롤하면 목록 전체가 움직이고, 가로로 스크롤하면 해당 선반만 독립적으로 움직여야 합니다. 이를 위해 각 행에 별도의 가로 오프셋이 필요합니다. 이는 하나의 캔버스가 가로와 세로로 함께 움직이는 경우와 다릅니다.

작업에 맞는 스크롤 구조 선택하기

Apple 문서에 따르면 SwiftUI ScrollView는 가로, 세로 또는 양쪽 축으로 스크롤할 수 있습니다. 하나의 큰 문서라면 ScrollView([.horizontal, .vertical])을 사용하고 뷰포트보다 큰 콘텐츠 크기를 지정하세요. 모든 콘텐츠가 가로 위치 하나와 세로 위치 하나를 공유하는 경우 가장 간단한 구조입니다. ScrollView

각 선반이 독립적이어야 한다면 별도의 스크롤 컨테이너가 필요합니다. 자연스러운 SwiftUI 구조는 높이를 제한한 가로 ScrollView들을 세로 ScrollView 안에 넣는 것입니다. 하지만 축을 설정하는 것만으로 내부 컨테이너에서 받은 이벤트가 외부 컨테이너로 전달되는 방식까지 정해지지는 않습니다. Apple의 ScrollView 문서는 지원되는 축을 설명하지만, 이 중첩 사례에 대한 일반적인 이벤트 라우팅 규칙을 제시하지는 않습니다. ScrollView

필요한 동작 적절한 시작점
하나의 문서가 양방향으로 움직임 양축을 사용하는 SwiftUI ScrollView 하나
각 선반이 자체 가로 위치를 유지함 세로 컨테이너 안에 중첩된 가로 선반
선반 위에서 시작한 세로 제스처가 향할 곳을 명시해야 함 NSViewRepresentable로 제공하는 앱 소유 NSScrollView 하위 클래스
대각선 제스처로 문서 전체를 자유롭게 움직여야 함 양축 컨테이너 하나. AppKit은 usesPredominantAxisScrolling도 제공합니다

AppKit은 한 번에 주로 한 축을 따라 스크롤하는 콘텐츠와 캔버스처럼 양축으로 자유롭게 움직여야 하는 콘텐츠를 구분합니다. usesPredominantAxisScrolling 속성은 이 차이를 표현하며, 문서화된 기본값은 true입니다. 이 속성은 스크롤 뷰의 동작에 관한 것으로, 다른 중첩 스크롤 뷰를 이벤트 대상으로 선택하는 기능은 아닙니다. usesPredominantAxisScrolling

문서화된 이벤트 전달의 범위 이해하기

wantsForwardedScrollEvents(for:)라는 이름은 중첩 스크롤 문제를 보편적으로 해결하는 기능처럼 보일 수 있습니다. 하지만 Apple 문서의 조건은 더 제한적입니다. 탄성 스크롤을 수행하는 뷰는 제스처가 시작될 때 콘텐츠가 해당 가장자리에 이미 도달한 경우 제스처 이벤트를 전달할 수 있습니다. 이 문서는 일반 휠 이벤트나 임의의 축 분할을 항상 전달한다고 규정하지 않습니다. wantsForwardedScrollEvents(for:)

마찬가지로 기본 NSResponder.scrollWheel(with:) 구현은 메시지를 다음 응답자에게 전달하고, NSScrollView는 이 메서드를 구현해 스크롤을 수행합니다. 따라서 응답자가 처리하지 않은 이벤트를 다음으로 전달하는 것과 스크롤 컨테이너가 이벤트를 소비하는 것을 구분해야 합니다. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)

명시적인 제어가 필요한 인터페이스라면 지원되는 구성 요소로 NSViewRepresentable을 사용해 AppKit 뷰를 SwiftUI에 포함하고, NSHostingView를 사용해 SwiftUI 콘텐츠를 AppKit에 포함할 수 있습니다. 아래 구현은 이 브리지들을 사용해 두 스크롤 컨테이너를 관리하고 카드 콘텐츠는 SwiftUI로 유지합니다. 여기에 적용한 라우팅 정책은 문서화된 API에서 도출한 앱 코드이며, Apple이 제공하는 중첩 스크롤 구현 지침은 아닙니다. NSViewRepresentable, NSHostingView

앱이 직접 관리하는 중첩 스크롤 예제 만들기

Xcode에서 SwiftUI와 Swift를 사용하는 macOS 앱 프로젝트를 만드세요. 배포 대상을 macOS 11 이상으로 설정하세요. 생성된 앱 진입점 파일을 아래 첫 번째 파일로 바꾸고, 두 번째 파일을 같은 타깃에 추가하세요. 두 파일을 합치면 가상 레이블을 사용하는 완전한 데모 프로그램이 되며 추가 에셋은 필요하지 않습니다.

이 샘플은 macOS 11부터 사용할 수 있는 SwiftUI App 라이프사이클을 사용합니다. 타깃에는 @main 선언을 정확히 하나만 두세요. Apple의 라이프사이클 문서와 Swift 언어 레퍼런스 모두 프로그램 진입점을 하나 요구합니다. App, Attributes

외부 목록과 SwiftUI 카드 만들기

첫 번째 파일은 선반 여덟 개를 만듭니다. 각 선반에는 카드 열여섯 개와 자체 가로 문서가 있습니다. 외부 문서는 뷰포트 너비를 추적하고 데모용 고정 높이를 사용하므로 세로 스크롤은 외부 컨테이너만 처리하면 됩니다.

SwiftUI가 표현된 외부 뷰의 프레임을 제어합니다. 사용자 지정 레이아웃은 그 안의 AppKit 문서와 선반 프레임만 변경하여, NSViewRepresentable이 직접 관리하는 뷰의 레이아웃 속성을 변경하지 말라는 Apple의 경고를 따릅니다. NSViewRepresentable

이 파일을 NestedScrollDemoApp.swift로 저장하세요:

import SwiftUI
import AppKit

@main
struct NestedScrollDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ShelfBoard()
                .frame(minWidth: 420, minHeight: 320)
        }
    }
}

struct ShelfBoard: NSViewRepresentable {
    func makeNSView(context: Context) -> BoardScrollView {
        BoardScrollView(frame: .zero)
    }

    func updateNSView(_ nsView: BoardScrollView, context: Context) {
        // The demonstration content is static.
    }
}

struct ShelfCards: View {
    let row: Int

    var body: some View {
        HStack(spacing: 12) {
            ForEach(1...16, id: \.self) { column in
                Text("Shelf \(row) · Card \(column)")
                    .frame(width: 140, height: 96)
                    .background(Color.gray.opacity(0.15))
                    .cornerRadius(8)
            }
        }
        .padding(12)
        .frame(width: 2444, height: 120)
    }
}

final class TopDocument: NSView {
    override var isFlipped: Bool { true }
}

final class BoardScrollView: NSScrollView {
    private let board = TopDocument(frame: .zero)
    private var shelves: [ShelfScrollView] = []

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        hasVerticalScroller = true
        hasHorizontalScroller = false
        autohidesScrollers = true
        horizontalScrollElasticity = .none
        documentView = board

        for row in 1...8 {
            let shelf = ShelfScrollView(frame: .zero)
            shelf.verticalOwner = self
            shelf.hasHorizontalScroller = true
            shelf.hasVerticalScroller = false
            shelf.autohidesScrollers = true
            shelf.verticalScrollElasticity = .none

            let cards = NSHostingView(rootView: ShelfCards(row: row))
            cards.frame = NSRect(x: 0, y: 0, width: 2444, height: 120)
            shelf.documentView = cards
            board.addSubview(shelf)
            shelves.append(shelf)
        }
    }

    required init?(coder: NSCoder) {
        fatalError("Use init(frame:)")
    }

    override func layout() {
        super.layout()
        let width = contentSize.width
        board.frame = NSRect(x: 0, y: 0, width: width, height: 1184)
        for (index, shelf) in shelves.enumerated() {
            shelf.frame = NSRect(
                x: 0, y: 8 + CGFloat(index) * 146,
                width: width, height: 140
            )
        }
    }
}

NSScrollView는 문서 뷰, 클리핑 뷰, 스크롤 막대를 조정합니다. 여기서는 외부 문서 안에 선반 스크롤 뷰가 있고, 각 선반의 문서에는 SwiftUI 카드가 들어 있는 NSHostingView가 있습니다. 2444 포인트 너비는 레이아웃 산술을 설명하기 위한 예시입니다. 140 너비인 카드 열여섯 개, 12 간격 열다섯 개, 가로 여백 24포인트를 합한 값입니다. NSScrollView, NSHostingView

제스처를 한 번 라우팅하고 대상을 유지하기

두 번째 파일은 라우팅 정책을 제공합니다. 제스처가 0이 아닌 움직임을 처음 보일 때 세로 이동량이 더 크면 외부 목록으로 전달합니다. 그렇지 않으면 선반에 유지합니다. 두 이동량이 같으면 선반을 선택합니다. 이는 이 데모를 위해 명시적으로 정한 정책입니다.

Apple은 phase과 momentumPhase 속성을 별도로 제공합니다. 레거시 휠 이벤트에는 둘 다 없고, 관성 이벤트에는 일반 제스처 단계가 없습니다. 따라서 이 샘플은 단계가 지정되지 않은 휠 이벤트를 별도로 처리하고, 단계가 있는 제스처의 대상은 관성 시퀀스가 끝날 때까지 유지합니다. phase, momentumPhase

이 파일을 ShelfScrollView.swift로 저장하세요:

import AppKit

final class ShelfScrollView: NSScrollView {
    weak var verticalOwner: NSScrollView?
    private var routesVertically: Bool?
    private var pending: [NSEvent] = []

    override func scrollWheel(with event: NSEvent) {
        let phase = event.phase
        let momentum = event.momentumPhase
        let x = event.scrollingDeltaX
        let y = event.scrollingDeltaY

        // A legacy wheel event has no gesture or momentum phase.
        if phase.isEmpty && momentum.isEmpty {
            routesVertically = nil
            pending.removeAll()
            deliver(event, vertically: abs(y) > abs(x))
            return
        }

        if phase.contains(.mayBegin) {
            routesVertically = nil
            pending = [event]
            return
        }

        if phase.contains(.began) {
            routesVertically = nil
            if pending.last?.phase.contains(.mayBegin) != true {
                pending.removeAll()
            }
        }

        let finishes = phase.contains(.cancelled)
            || momentum.contains(.ended)
            || momentum.contains(.cancelled)

        if let vertical = routesVertically {
            deliver(event, vertically: vertical)
        } else {
            // Keep zero-delta opening events until direction is known.
            pending.append(event)
            if x != 0 || y != 0 {
                let vertical = abs(y) > abs(x)
                routesVertically = vertical
                let openingEvents = pending
                pending.removeAll()
                for openingEvent in openingEvents {
                    deliver(openingEvent, vertically: vertical)
                }
            } else if phase.contains(.ended) || finishes {
                pending.removeAll()
            }
        }

        if finishes {
            routesVertically = nil
            pending.removeAll()
        }
        // Retain direction after phase.ended for possible momentum.
    }

    private func deliver(_ event: NSEvent, vertically: Bool) {
        if vertically, let owner = verticalOwner {
            owner.scrollWheel(with: event)
        } else {
            super.scrollWheel(with: event)
        }
    }
}

코드는 대상을 선택할 때만 scrollingDeltaX과 scrollingDeltaY를 읽습니다. 원래 이벤트를 하나의 스크롤 뷰에 전달하므로 스크롤은 AppKit이 처리합니다. 델타를 포인트 오프셋으로 다시 해석하지 않습니다. Apple 문서에 따르면 정밀 델타와 비정밀 델타는 서로 다르게 처리해야 합니다. 예를 들어 앱에서 직접 움직임을 구현할 때 비정밀 세로 델타에는 줄 높이에 따른 스케일링이 필요합니다. scrollingDeltaX, scrollingDeltaY

약한 verticalOwner 참조는 이 예제에서 만든 외부 스크롤 뷰를 직접 가리킵니다. 따라서 SwiftUI의 내부 뷰 계층 구조에 의존하지 않습니다. 이 샘플은 이벤트 모니터를 추가하지 않습니다. 재정의한 메서드는 앱의 선반 뷰에 전달된 이벤트를 처리합니다.

양방향 스크롤, 관성, 크기 조정 확인하기

이 예제는 가져온 API 문서를 기준으로 검토했지만, 조사 환경에 Swift 컴파일러나 macOS 런타임이 없어 컴파일하거나 실행하지는 않았습니다. 기존 인터페이스에 이 라우팅 정책을 적용하기 전에 Xcode에서 빌드하고 다음 항목을 확인하세요.

확인 항목 의도한 결과
포인터를 카드 위에 둔 채 세로로 스크롤 외부 목록이 움직임
첫 번째 선반 위에서 가로로 스크롤 해당 선반만 가로로 움직임
다른 선반을 가로로 움직임 해당 선반의 오프셋만 독립적으로 바뀜
세로로 시작한 뒤 대각선 움직임 추가 제스처가 계속 외부 목록에 지정됨
가로로 시작한 뒤 대각선 움직임 추가 제스처가 계속 선반에 지정됨
트랙패드 제스처를 놓아 관성 스크롤 발생 관성 스크롤도 선택된 대상을 유지함
일반 세로 휠 사용 각 휠 이벤트가 외부 목록으로 라우팅됨
창 크기 조정 선반이 외부 뷰포트 너비에 맞춰짐

델타가 0인 제스처 시작, 취소, 빠른 방향 전환, 오버레이 스크롤 막대와 항상 표시되는 스크롤 막대도 테스트하세요. ShelfScrollView의 버퍼링은 델타가 0인 시작 이벤트 때문에 가로 스크롤이 성급하게 선택되지 않도록 합니다. 지원하는 기기와 macOS 버전에서의 동작은 런타임으로 검증해야 합니다.

대각선 제스처 정책은 단계가 있는 제스처의 대상을 하나 선택합니다. 외부 목록과 선반이 동시에 움직인다고 보장하지 않습니다. 이 샘플은 가로 선반의 끝에 도달해도 해당 선반을 대상으로 유지하며 세로 목록에 제스처를 넘기지 않습니다. 대각선으로 끊김 없이 패닝해야 한다면 양축 문서 하나를 사용하세요.

이 예제는 모든 카드를 즉시 만들고 크기도 고정합니다. 이 크기들은 데모를 위한 입력값으로 취급하세요. 콘텐츠가 바뀐다면 updateNSView을 구현해 기존 문서와 호스팅 뷰를 업데이트하면서 스크롤 위치를 유지하세요. Apple은 이 메서드를 표현된 AppKit 뷰에 새로운 SwiftUI 상태를 적용하는 지점으로 문서화합니다. NSViewRepresentable

스크롤 비활성화와 텍스트 레이아웃 문제 구분하기

어느 방향으로도 스크롤되지 않는다면 라우팅을 변경하기 전에 수정자를 확인하세요. macOS 13 이상에서 scrollDisabled(_:)는 설정을 SwiftUI 환경으로 전달하며 외부 SwiftUI 스크롤 뷰와 중첩 스크롤 뷰 모두에 영향을 줄 수 있습니다. 이는 스크롤 활성화 여부를 제어하는 기능이며 축 전달 방식은 아닙니다. scrollDisabled(_:)

나중에 선반에 AppKit 텍스트 뷰를 넣는다면 텍스트 기하를 별도로 확인하세요. TextKit의 NSTextContainer.widthTracksTextView는 텍스트 컨테이너의 너비가 텍스트 뷰의 너비를 따라갈지 제어합니다. 어떤 스크롤 뷰가 휠 제스처를 받을지는 정하지 않습니다. 텍스트 레이아웃 변경으로 콘텐츠가 가로로 넘치는지 여부는 달라질 수 있지만, 이를 이벤트 라우팅 문제의 해결책으로 제시해서는 안 됩니다. 카드 데모에서는 이 변수를 제외했습니다. widthTracksTextView

인터페이스가 하나의 문서라면 양축 SwiftUI 스크롤 뷰 하나로 시작하세요. 독립적인 선반마다 명시적인 세로 라우팅이 필요하다면 앱이 직접 관리하는 브리지를 통해 제스처가 향할 대상을 명확히 지정하면서 카드 콘텐츠는 SwiftUI로 유지할 수 있습니다. 콘텐츠를 동적으로 바꾸도록 예제를 확장하기 전에 앱이 지원하는 Mac과 입력 기기에서 제스처 정책을 검증하세요.

출처