래핑된 NSTextView를 SwiftUI 프레임에 맞추려면 NSScrollView를 표현하고, sizeThatFits를 통해 유한한 제안 크기를 수용하며, 그 안에서 텍스트 문서가 세로로 확장되도록 하세요. 긴 줄이 줄바꿈되도록 텍스트 컨테이너가 편집기의 너비를 추적하게 설정하세요.

이 튜토리얼에서는 보이는 영역의 크기가 제한된, Mac 앱용 일반 텍스트 편집기를 만듭니다. 텍스트를 추가해도 SwiftUI 레이아웃은 커지지 않고 세로 스크롤이 생깁니다. 전체 예제는 Apple이 표현 가능한 뷰의 크기 계산 콜백을 제공하는 macOS 13 이상을 대상으로 합니다. Apple의 sizeThatFits 문서

NSTextView가 SwiftUI 프레임을 무시하는 것처럼 보이는 이유는 무엇인가요?

조정해야 할 크기는 세 가지입니다. SwiftUI는 표현된 AppKit 뷰의 크기를 정합니다. NSScrollView는 보이는 영역을 제공합니다. 그 문서 뷰에는 텍스트가 들어가며, NSTextContainer는 텍스트 레이아웃에 사용할 수 있는 영역을 결정합니다. Apple은 문서 뷰를 스크롤 뷰가 콘텐츠 뷰 안에서 스크롤하는 뷰로 설명합니다. Apple의 documentView 문서

SwiftUI의 .frame(width:height:)는 이를 둘러싸는 레이아웃 프레임을 만듭니다. 텍스트 컨테이너의 줄바꿈 동작을 별도로 설정하지는 않습니다. Apple은 자식 뷰의 경계가 이를 둘러싼 프레임을 벗어날 수 있다고도 설명합니다. 따라서 테두리는 진단에 유용합니다. 표현된 콘텐츠의 동작이 달라도 테두리는 요청한 크기로 표시될 수 있습니다. Apple의 프레임 문서

핵심은 소유권입니다. Apple은 SwiftUI가 표현된 AppKit 뷰의 frame와 bounds를 제어한다고 명시합니다. 이 속성을 직접 변경하면 SwiftUI와 충돌합니다. 대신 표현 가능한 뷰의 크기 계산 콜백을 통해 원하는 크기를 반환하세요. 이 구현에서 표현되는 뷰는 스크롤 뷰이고, AppKit은 내부 텍스트 문서를 관리합니다. Apple의 NSViewRepresentable 문서

레이아웃 플래그를 바꾸기 전에 원하는 동작을 정하세요.

원하는 동작 바깥쪽 크기 정책 텍스트 문서 정책
고정 크기 편집기 패널 전달된 너비와 높이를 수용 가로로 줄바꿈하고, 세로로 확장해 스크롤
크기가 제한된 영역을 채우는 편집기 영역에서 제안한 유한한 크기를 수용 가로로 줄바꿈하고, 세로로 확장해 스크롤
모든 텍스트가 보이도록 확장되는 편집기 제안된 너비에 맞춰 높이 계산 콘텐츠 측정과 레이아웃 업데이트 조정

앞의 두 항목이 실행 가능한 예제에서 다루는 작업입니다. 세 번째에는 별도의 콘텐츠 높이 정책이 필요합니다. 제안된 높이를 수용하는 것만으로는 이를 구현할 수 없습니다.

크기가 제한되고 줄바꿈되는 편집기 만들기

Xcode에서 macOS SwiftUI 앱을 만들고 배포 대상을 macOS 13 이상으로 설정하세요. 다음 파일을 FramedTextEditor.swift로 추가합니다. 두 번째 코드 블록에는 앱 진입점과 샘플 콘텐츠가 있습니다. 두 파일을 합치면 전체 예제가 됩니다.

이 구성은 Apple이 문서화한 세로 스크롤 설정을 따릅니다. 텍스트 뷰는 세로로 확장되고, 너비는 상위 뷰를 따르며, 텍스트 컨테이너는 텍스트 뷰의 너비를 따릅니다. Apple의 조립 안내서는 보관 자료이므로, 이 예제는 현재 Swift API를 사용하고 NSTextView(usingTextLayoutManager: true)를 통해 TextKit 2를 명시적으로 선택합니다. Apple은 TextKit 발표 자료에서 이 초기화 방법을 설명합니다. Apple의 스크롤 텍스트 뷰 안내서, TextKit과 텍스트 뷰의 새로운 기능

import AppKit
import SwiftUI

@MainActor
struct FramedTextEditor: NSViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator {
        Coordinator($text)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.borderType = .noBorder
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.autohidesScrollers = true

        let textView = NSTextView(usingTextLayoutManager: true)
        // This is the internal document, not the represented root view.
        textView.setFrameSize(scrollView.contentSize)
        textView.minSize = .zero
        textView.maxSize = NSSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        textView.isVerticallyResizable = true
        textView.isHorizontallyResizable = false
        textView.autoresizingMask = [.width]
        textView.isRichText = false
        textView.importsGraphics = false
        textView.font = .systemFont(ofSize: 14)
        textView.textContainerInset = NSSize(width: 8, height: 8)

        guard let container = textView.textContainer else {
            preconditionFailure("Expected a text container")
        }
        container.size = NSSize(
            width: max(1, scrollView.contentSize.width),
            height: CGFloat.greatestFiniteMagnitude
        )
        container.widthTracksTextView = true
        container.heightTracksTextView = false

        textView.string = text
        textView.delegate = context.coordinator
        scrollView.documentView = textView
        return scrollView
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        context.coordinator.text = $text
        guard let textView = nsView.documentView as? NSTextView else {
            preconditionFailure("Expected an NSTextView document")
        }
        if textView.string != text {
            textView.string = text
        }
    }

    func sizeThatFits(
        _ proposal: ProposedViewSize,
        nsView: NSScrollView,
        context: Context
    ) -> CGSize? {
        let ideal = CGSize(width: 320, height: 180)
        let proposed = proposal.replacingUnspecifiedDimensions(by: ideal)
        return CGSize(
            width: proposed.width.isFinite
                ? max(0, proposed.width) : ideal.width,
            height: proposed.height.isFinite
                ? max(0, proposed.height) : ideal.height
        )
    }

    static func dismantleNSView(
        _ nsView: NSScrollView,
        coordinator: Coordinator
    ) {
        (nsView.documentView as? NSTextView)?.delegate = nil
    }

    @MainActor
    final class Coordinator: NSObject, NSTextViewDelegate {
        var text: Binding<String>

        init(_ text: Binding<String>) {
            self.text = text
            super.init()
        }

        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else {
                return
            }
            if text.wrappedValue != textView.string {
                text.wrappedValue = textView.string
            }
        }
    }
}

생성된 앱 진입점과 콘텐츠 뷰를 다음 두 번째 파일로 바꾸세요. 대상에는 @main 선언을 하나만 남겨 두세요. 모든 레이블과 텍스트는 예시용이며, 예제는 앱 자체 창 안에서만 동작합니다.

import SwiftUI

@main
@MainActor
struct FrameSizingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            EditorDemo()
        }
    }
}

@MainActor
struct EditorDemo: View {
    @State private var compact = false
    @State private var text = Array(
        repeating: "Sample note: arrange the blue tiles beside the green tiles.",
        count: 16
    ).joined(separator: "\n\n")

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Toggle("Compact editor", isOn: $compact)

            FramedTextEditor(text: $text)
                .frame(width: compact ? 280 : 420, height: 180)
                .border(Color.secondary)

            Text("Character count: \(text.count)")
                .font(.caption)
        }
        .padding(20)
        .frame(minWidth: 460, minHeight: 280, alignment: .topLeading)
    }
}

이 파일들은 검토했지만 여기서 컴파일하거나 실행하지는 않았습니다. 사용 가능한 환경은 Linux이며 Swift 실행 파일이나 macOS 프레임워크가 없습니다. 아래 확인 절차에 따라 Xcode에서 앱을 빌드하고 점검하세요.

크기 계산과 줄바꿈 설정은 어떻게 동작하나요?

문서를 측정하지 않고 유한한 제안 크기 수용하기

이 편집기에서는 유한한 너비와 높이 제안을 뷰포트의 원하는 크기로 사용합니다. 320 × 180 대체값은 크기가 지정되지 않았거나 유한하지 않은 경우를 위한 예시용 설계 선택입니다. 텍스트 크기를 측정한 값이 아닙니다. 최소 크기 확인이 임의의 양수 최소 크기로 바뀌지 않도록 0은 그대로 유지합니다.

SwiftUI는 한 번의 레이아웃 처리 중에도 서로 다른 크기를 물어볼 수 있습니다. 표준 제안에는 최소, 최대, 이상적인 크기 확인이 포함됩니다. 이 샘플은 제한되지 않은 제안에 의도적으로 유한한 대체값을 제공합니다. 모든 컨테이너에 적용하는 범용 크기 계산 정책이 아니라, 크기가 제한된 편집기 패널을 대상으로 합니다. 대신 nil를 반환하면 SwiftUI의 기본 크기 계산 알고리즘이 선택됩니다. Apple의 ProposedViewSize 문서, Apple의 표현 가능한 뷰 크기 계산 콜백

이 콜백은 스크롤 뷰의 프레임을 바꾸거나, 텍스트를 교체하거나, SwiftUI 상태를 쓰지 않습니다. 크기 질문에 답하는 것이 역할입니다. 따라서 이 구현에서는 텍스트 길이가 바깥쪽 편집기 높이를 결정하지 않습니다.

너비는 뷰포트를 따르고 높이는 콘텐츠를 따르게 하기

두 너비 설정은 서로 다른 역할을 합니다. 내부 텍스트 뷰의 .width 자동 크기 조정 마스크는 상위 뷰가 바뀔 때 텍스트 뷰의 너비가 바뀌도록 합니다. 텍스트 컨테이너의 widthTracksTextView = true는 텍스트 뷰가 바뀔 때 텍스트 컨테이너의 레이아웃 너비가 바뀌도록 합니다. 편집기 너비가 좁아질 때 두 관계가 모두 중요합니다. Apple의 autoresizingMask 문서, Apple의 widthTracksTextView 문서

세로 방향에서는 isVerticallyResizable = true를 사용해 문서 뷰 높이가 텍스트에 맞춰 바뀌도록 합니다. 큰 텍스트 컨테이너 높이는 세로 레이아웃 공간을 제공하고, heightTracksTextView = false는 레이아웃 한계가 문서 뷰 크기가 바뀔 때마다 함께 바뀌는 것을 방지합니다. 스크롤 뷰는 크기가 제한된 보이는 영역으로 유지됩니다. Apple의 세로 크기 조정 문서, Apple의 heightTracksTextView 문서

테두리와 텍스트 사이에 공간이 생기는 것은 정상입니다. 이 예제는 텍스트 컨테이너에 8포인트의 여백을 명시적으로 추가합니다. 컨테이너의 줄 조각 여백으로 텍스트에 추가 공간이 생길 수도 있으므로, 뷰포트 너비나 텍스트 뷰 너비가 문자 배치에 실제로 사용되는 너비와 반드시 일치하지는 않습니다. Apple의 textContainerInset 문서

텍스트 업데이트와 크기 협상 분리하기

코디네이터는 textDidChange을 통해 편집 내용을 전달하고, 바인딩은 이를 상위 뷰의 상태에 연결합니다. updateNSView는 SwiftUI에서 돌아오는 변경을 처리합니다. 같은 값이 일반적인 편집 후 다시 전달되었을 때 동일성 검사를 통해 편집기의 문자열을 불필요하게 교체하지 않습니다. Apple의 텍스트 변경 대리자 문서, Apple의 Binding 문서

다른 문자열이 들어오면 전체 텍스트를 교체하는 것으로 처리합니다. 편집 중 앱에서 프로그래밍 방식으로도 텍스트를 교체한다면, 이 샘플을 확장하기 전에 선택 영역, 실행 취소 기록, 입력 중인 조합을 어떻게 보존할지 정하세요. 데모 자체는 편집기를 통해서만 문자열을 변경합니다.

이 예제는 UI 타입과 코디네이터에 @MainActor를 지정합니다. Swift 동시성 안내서는 메인 액터 격리를 UI 상태를 보호하고 주석이 붙은 코드가 해당 액터에서 실행되도록 요구하는 수단으로 설명합니다. The Swift Programming Language: 동시성

결과를 확인하고 남은 레이아웃 문제 찾기

샘플 앱에서 다음 항목을 확인하세요. 모든 증상을 SwiftUI 프레임 문제로 간주하지 말고, 뷰포트 크기, 줄바꿈, 스크롤, 상태 동기화를 각각 구분해 점검합니다.

  1. 편집기 테두리를 확인합니다. 처음 요청된 편집기 영역은 420 × 180포인트입니다.
  2. Compact editor를 켭니다. 너비는 280포인트로 바뀌고 높이는 180포인트로 유지되어야 합니다. 문장은 더 많은 줄로 줄바꿈되어야 합니다.
  3. 제공된 문단을 스크롤합니다. 같은 테두리 안에서 문서가 움직여야 합니다.
  4. 줄을 추가로 입력합니다. 편집기 요청 높이가 늘어나지 않고 글자 수가 바뀌어야 합니다.
  5. 텍스트를 삭제합니다. 문서에 콘텐츠가 거의 없어도 뷰포트는 요청된 크기를 유지해야 합니다.
증상 먼저 확인할 항목
테두리는 올바른 크기지만 긴 줄이 줄바꿈되지 않음 문서의 너비 자동 크기 조정 마스크와 widthTracksTextView를 모두 확인하세요
텍스트를 추가하면 주변 레이아웃이 커짐 표현 가능한 뷰가 스크롤 뷰를 반환하는지, sizeThatFits에서 콘텐츠 높이를 반환하지 않는지 확인하세요
뒤쪽 줄에 도달할 수 없음 세로 스크롤, 문서의 세로 크기 조정, 텍스트 컨테이너의 높이 제한을 확인하세요
다른 상위 레이아웃에서 편집기가 계속 좁게 표시됨 상위 뷰가 제안한 너비를 살펴보세요. 콜백은 유한한 제안을 수용하며 공간을 임의로 늘리지 않습니다
관련 없는 상태 업데이트 중 텍스트 위치가 바뀜 textView.string에 조건 없이 값을 할당하는 코드가 있는지 확인하세요

이 점검 절차는 위 크기 관계를 바탕으로 한 진단 순서이며, 실행 테스트 결과를 보고한 것이 아닙니다.

콘텐츠에 따른 높이나 이전 macOS 버전에서는 어떻게 달라지나요?

모든 줄이 보이도록 확장되는 편집기는 다른 작업이 필요합니다. 제안된 너비를 기준으로 콘텐츠 높이를 계산하고, 최대 높이 정책도 정해야 합니다. 내부 문서에 세로 크기 조정을 켜는 것만으로는 SwiftUI에 새로 원하는 높이를 전달할 수 없습니다. 마찬가지로 이 예제처럼 제안된 높이를 수용하는 방식으로는 텍스트에 따른 높이를 계산할 수 없습니다.

측정을 추가할 때는 선택한 텍스트 엔진을 유지하세요. Apple은 NSTextView 인스턴스의 layoutManager에 접근하면 TextKit 1 호환 모드가 활성화될 수 있다고 설명합니다. 문서화된 TextKit 2의 대응 항목으로 텍스트 컨테이너 사용 경계를 나타내는 NSTextLayoutManager.usageBoundsForTextContainer가 있습니다. 이 편집기에는 두 측정 API 모두 필요하지 않습니다. Apple의 TextKit 마이그레이션 안내

지원 버전 경계도 중요합니다. NSViewRepresentable은 macOS 10.15부터 사용할 수 있지만, sizeThatFits 콜백에는 macOS 13이 필요합니다. 이전 배포 대상을 지원하려면 스크롤 문서 구성을 유지하고, 이전 표현 방식의 기본 크기 계산 동작과 AppKit 크기 우선순위 또는 해당 대상에 맞게 설계된 컨테이너를 사용하세요. 해당 환경에서는 이 콜백으로 레이아웃을 제어할 수 없으므로, 그 구현을 별도로 검증하세요. Apple의 표현 가능한 뷰 문서, Apple의 크기 계산 콜백 사용 가능 버전

크기가 제한된 Mac 편집기에서는 역할을 명확히 유지하세요. SwiftUI는 보이는 사각형을 할당하고, 스크롤 뷰는 확장되는 문서를 담으며, 텍스트 컨테이너는 사용 가능한 너비를 따릅니다. 이 구성은 높이가 고정된 편집기에 텍스트 측정을 포함하지 않으면서 프레임 크기 문제를 구체적으로 진단할 수 있게 해 줍니다.

출처