본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

글자, 분할, 창: 아직 AppKit이 앞서는 곳

~17 min · appkit-swiftui, nstextview, nssplitview, window-chrome, drag-and-drop

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"클릭은 전부 먹혔어. 드래그만 죽었고, 포인터 밑에서 창이 움직였어."

큰 Mac 앱이 AppKit에 남는 이유

가족의 편집기, 터미널, 파일 작업대가 AppKit을 주인으로 두는 건 향수 때문이 아니라 구체적인 이유가 있어서야. 텍스트 시스템(NSTextView랑 TextKit)엔 입력기, 조합 중인 글자, 맞춤법 검사, 서비스, 손쉬운 사용 지원이 다 들어 있어. 처음부터 새로 만든 글자 화면엔 그런 게 없어. 분할 뷰, 직접 만든 탭 줄, 제목 막대 보조 뷰에는 SwiftUI로는 손댈 수 없는 배치와 마우스 추적 제어가 필요해. 그리고 앱 사이 끌어다 놓기는 AppKit 객체에 붙은 플래그가 정해. 대신 AppKit의 힘에는 날카로운 모서리가 따라와. 아래는 가족이 실제로 세션을 통째로 날린 사례들이야.

마우스 추적

  • 제목 막대의 뷰는 mouseDragged를 한 번도 못 받아. 제목 막대 보조 영역에 둔 탭 줄이 클릭, 더블클릭, 오른쪽 클릭은 다 처리하는데, 끌어서 순서 바꾸기는 한 번도 안 불렸어. 창 전체가 대신 미끄러졌지. NSViewmouseDownCanMoveWindow 기본값은 !isOpaque야. 그래서 투명한 평범한 뷰는 "응"이라고 대답하고, 창 이동이 이벤트 흐름을 가로채. false를 돌려주게 오버라이드해. 제목 막대를 끌 때의 느낌을 살리려면, 탭 줄의 빈 곳에서는 직접 window.performDrag(with:)를 부르면 돼. (버튼은 이 문제가 없어. NSControl은 이미 false를 돌려줘.)
  • 드래그 중에 뷰를 다시 만들면 드래그가 끝나. 탭을 맞바꿀 때마다 모델에 알렸더니, 부모가 탭 줄의 하위 뷰를 다시 만들었어. 추적 중이던 뷰가 빠지면서 mouseDraggedmouseUp 흐름이 조용히 끊겨 버렸어. 드래그 중에는 탭 줄 안의 순서만 바꾸고, 모델 변경은 마우스를 뗄 때 한 번만 해.
  • 다른 앱으로 파일을 끌어가려면 비로컬 마스크가 필요해. 작업대 안에선 행을 놓을 수 있었는데, Finder나 다른 앱 위에선 먹통이었어. 테이블 뷰랑 컬렉션 뷰는 비로컬 드래그의 원본 작업 마스크가 기본으로 비어 있어. 뷰마다 호출 한 줄이면 고쳐져.

분할 뷰

배치 버그가 가장 많이 나온 곳은 중첩 NSSplitView였어. 부모가 프레임을 손으로 정하는 분할 뷰는 보통 분할 뷰가 받는 오토레이아웃 크기 조정을 못 받아. 높이가 처음으로 0이 아니게 됐을 때 setPosition을 딱 한 번 불렀더니, 한쪽이 패널을 다 차지하고 다른 쪽은 안 보였어. 고치려면 layout()에서 추적 중인 높이가 바뀔 때마다 구분선 위치를 다시 잡아. 사용자가 구분선을 끌면 비율을 기록하고, 코드에서 부른 호출이 되먹임되지 않게 막아. 제약을 켜기 전에는 하위 뷰에 0이 아닌 프레임을 줘. 확인은 실행 직후가 아니라 창 크기를 실제로 바꿔 가면서 하고, 통합 로그도 봐. 회복된 제약 충돌은 화면엔 안 드러나거든.

창과 탭

네이티브 창 탭은 탭 하나하나가 NSWindow야. 탭마다 색을 입힐 수도 없어. 탭 보조 뷰는 자기 크기 제약을 걸어두지 않으면 탭 너비 전체로 늘어나. 터미널은 네이티브 탭을 꺼버리고 탭 줄을 직접 그렸어. 탭 그룹 단위로 창을 스냅숏하는 코드가 돌기 전에, 새 탭을 먼저 그룹에 넣어야 해(addTabbedWindow(_:ordered:)). 안 그러면 따로 떨어진 창으로 저장돼.

Code

제목 막대 드래그, 손으로 배치하는 분할, 다른 앱으로 끌어내기·swift
import AppKit

final class TitleBarTabStrip: NSView {
    override var mouseDownCanMoveWindow: Bool { false }        // or the window steals the drag
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }

    override func mouseDown(with event: NSEvent) {
        if tab(at: convert(event.locationInWindow, from: nil)) == nil {
            window?.performDrag(with: event)                   // empty strip still drags the window
        } else {
            beginTabDrag(event)
        }
    }
    private func tab(at point: NSPoint) -> Int? { nil }
    private func beginTabDrag(_ event: NSEvent) {}
}

final class SidePanelView: NSView, NSSplitViewDelegate {
    private let split = NSSplitView()
    private var upperFraction: CGFloat = 0.5
    private var lastHeight: CGFloat = 0
    private var applying = false

    override init(frame: NSRect) {
        super.init(frame: frame)
        split.isVertical = false
        split.delegate = self
        split.addArrangedSubview(NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)))
        split.addArrangedSubview(NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)))
        addSubview(split)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }

    override func layout() {
        super.layout()
        split.frame = bounds
        guard abs(bounds.height - lastHeight) > 0.5, bounds.height > 0 else { return }
        lastHeight = bounds.height
        applying = true
        split.setPosition(bounds.height * upperFraction, ofDividerAt: 0)   // on EVERY height change
        applying = false
    }

    func splitViewDidResizeSubviews(_ notification: Notification) {
        guard !applying, bounds.height > 0, let upper = split.arrangedSubviews.first else { return }
        upperFraction = upper.frame.height / bounds.height                // a user drag moves the fraction
    }
}

@MainActor
func enableDragOut(from table: NSTableView) {
    table.setDraggingSourceOperationMask([.copy, .generic], forLocal: false)
}

External links

Exercise

사각형으로 그린 가짜 탭 두 개가 있는 TitleBarTabStrip을 Spark 창의 제목 막대 보조 영역에 넣어. 먼저 mouseDownCanMoveWindow 오버라이드 없이 탭을 끌어봐. 그러고 나서 오버라이드를 넣고, 마우스를 뗄 때 모델을 한 번만 바꾸는 방식으로 순서 바꾸기를 구현해. 이와 별도로 SidePanelView를 창에 넣고, 창을 아주 작은 크기부터 큰 크기까지 천천히 키워봐. 두 쪽이 계속 다 보이는지, 끌어서 옮긴 구분선이 비율을 유지하는지 확인해.
Hint
탭 줄은 layoutAttribute = .bottomNSTitlebarAccessoryViewController로 넣어. 크기를 바꾸는 동안 콘솔 앱(아니면 log stream --predicate 'subsystem == "com.apple.AppKit"')에서 제약 경고를 봐. 제대로 보이는 배치도 사실은 충돌에서 회복한 결과일 수 있어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.