측정된 크기가 이를 만들어 낸 프레임으로 되돌아가는 경로를 제거해 SwiftUI 기하 루프를 해결하세요. 너비를 공유하는 레이블이 있는 Mac 윈도우에서는 측정값을 상태에 저장하는 대신 Layout 컨테이너 안에서 크기를 계산하세요.

이 안내서에서는 직접 만든 앱 윈도우 안에 작은 레이블 패널을 만듭니다. 레이블은 로컬에서 만든 예시 텍스트를 사용하고, 너비를 공유하며, 윈도우가 좁아지면 줄바꿈됩니다. 전체 예제의 대상은 macOS 13 이상입니다. 뒤의 섹션에서는 인터페이스에 AppKit 텍스트 뷰가 포함된 경우에도 같은 크기 조정 원칙을 적용하는 방법을 설명합니다.

기하 측정은 어떻게 레이아웃 루프가 되나요?

SwiftUI 레이아웃은 제안과 응답으로 이루어집니다. 부모가 크기를 제안하면 자식은 자신이 선택한 크기를 보고합니다. 사용자 지정 컨테이너는 ProposedViewSize를 통해 이 협상에 참여합니다. 따라서 뷰를 반복해서 측정하는 것은 정상입니다. 측정을 반복한다는 사실만으로 문제가 있다고 볼 수는 없습니다. Apple의 ProposedViewSize 문서를 참고하세요.

문제가 되는 의존성은 측정값이 측정 대상 레이아웃의 입력을 바꿀 때 발생합니다. 예를 들어 레이블이 패딩을 포함한 너비를 보고하고, 어떤 동작이 그 너비를 저장한 다음, 이를 둘러싼 프레임이 저장된 값에 패딩을 더해 사용한다고 가정해 보세요. 그 결과 프레임이 다음 측정값을 바꿉니다. Apple은 이 기하 리더에서 프레임으로 되돌아가는 피드백 루프를 명시적으로 설명하고, 이런 배치에는 레이아웃 시스템을 사용할 것을 권장합니다. SwiftUI로 사용자 지정 레이아웃 구성하기를 참고하세요.

간단한 계산만으로도 값이 같은지 확인하는 방식이 도움이 되지 않을 수 있는 이유를 알 수 있습니다. 설명을 위해 이전 프레임 너비에 패딩 16포인트를 더한 값을 측정한 뒤, 그 결과를 다음 프레임 너비로 설정하는 구현을 가정해 보겠습니다. 120포인트에서 시작하면 136, 152, 168포인트가 됩니다. 각 결과가 이전 값과 다르므로 값이 바뀌지 않았을 때 할당을 막는 검사로는 증가를 멈출 수 없습니다. 이 숫자는 의존성 관계를 설명하기 위한 예시이며, 실행 중인 앱에서 측정한 값이 아닙니다.

해결 방법을 선택하기 전에 자신의 코드에서 의존성 경로를 추적하세요.

  1. 기하 리더, 프리퍼런스 처리기 또는 기하 변경 동작을 찾습니다.
  2. 측정값이 저장되는 모든 상태 값을 확인합니다.
  3. 해당 값에 의존하는 프레임, 패딩, 글꼴 선택 또는 조건부 뷰를 찾습니다.
  4. 그 선택이 측정 대상 뷰나 조상 뷰에 영향을 주는지 확인합니다.
  5. 측정값을 상수로 임시 대체합니다. 반복적인 변경이 멈추면 먼저 해당 의존성 경로를 살펴보세요.

측정값을 전달하는 방식만 바꾼다고 순환이 반드시 사라지는 것은 아닙니다. 프리퍼런스도 같은 크기를 같은 조상 뷰로 되돌릴 수 있습니다. 상태 할당을 나중으로 미루면 피드백이 다음 업데이트에서 발생할 수 있습니다. 다음 측정값이 여전히 이전 결과에 의존하는지 확인하는 것이 중요합니다.

어떤 크기 조정 방식을 사용해야 하나요?

필요한 결정에 따라 방식을 선택하세요. 먼저 내장 레이아웃 컨테이너를 사용해 보세요. Apple은 더 많은 제어가 필요한 동작을 구현하는 방법으로 사용자 지정 Layout을 설명합니다. Layout 문서를 참고하세요.

필요한 결과 지원되는 방법 핵심 조건
일반 레이블을 세로로 배치 내장 스택 자식이 일반적인 크기 조정에 참여하도록 합니다.
넓은 표현과 간결한 표현 중 선택 ViewThatFits 선호도 순서대로 대안을 제공합니다.
콘텐츠에 맞는 공통 너비를 여러 레이블에 적용 사용자 지정 Layout 레이아웃 메서드 안에서 자식을 측정하고 배치합니다.
기존 뷰의 기하 변화에 반응 onGeometryChange 동작에 필요한 값만 관찰합니다.
임베드된 AppKit 뷰 크기 조정 NSViewRepresentable.sizeThatFits 제안된 크기에 맞는 선호 크기를 반환합니다.

ViewThatFits는 제약된 축에서 이상적인 크기가 들어맞는 첫 번째 자식을 선택합니다. 실제 요구 사항이 가로 배치와 세로 대안처럼 여러 표현 중 하나를 선택하는 것이라면 유용합니다. 별도의 측정 너비 상태 변수를 없앨 수 있습니다. Apple의 ViewThatFits 문서를 참고하세요.

아래의 공통 너비 레이블 패널에서는 크기 조정 규칙을 명시적으로 정합니다. 가장 넓은 레이블의 이상적인 너비를 사용하되, 유한한 제안 너비를 초과하지 않도록 제한합니다. 그런 다음 선택한 너비에서 각 레이블의 높이를 측정합니다. 이렇게 하면 윈도우가 좁아질 때 줄바꿈으로 패널 높이는 늘어나지만, 기하 측정값을 앱 상태를 거쳐 전달하지 않습니다.

기하 상태 없이 공통 너비 레이블 패널 만들기

Xcode에서 SwiftUI를 사용하는 macOS 앱 프로젝트를 만들고, 배포 대상 버전을 macOS 13 이상으로 설정한 다음, 생성된 앱 및 콘텐츠 뷰 선언을 이 완전한 단일 파일 예제로 바꾸세요. 타깃에는 @main 앱 선언이 하나만 있어야 합니다. 문서화된 API를 기준으로 프로그램을 검토했지만, 사용할 수 있는 환경이 Linux이므로 여기서는 컴파일하거나 실행하지 않았습니다.

import SwiftUI

private struct EqualWidthColumn: Layout {
    private let gap: CGFloat = 8

    private func columnWidth(
        proposal: ProposedViewSize,
        subviews: Subviews
    ) -> CGFloat {
        let idealWidth = subviews.reduce(CGFloat.zero) { result, view in
            max(result, view.sizeThatFits(.unspecified).width)
        }

        guard let offeredWidth = proposal.width,
              offeredWidth.isFinite else {
            return idealWidth
        }

        return min(idealWidth, max(0, offeredWidth))
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        guard !subviews.isEmpty else { return .zero }

        let width = columnWidth(proposal: proposal, subviews: subviews)
        let childProposal = ProposedViewSize(width: width, height: nil)
        let contentHeight = subviews.reduce(CGFloat.zero) { result, view in
            result + view.sizeThatFits(childProposal).height
        }
        let totalGap = gap * CGFloat(subviews.count - 1)

        return CGSize(width: width, height: contentHeight + totalGap)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let childProposal = ProposedViewSize(
            width: bounds.width,
            height: nil
        )
        var y = bounds.minY

        for view in subviews {
            let height = view.sizeThatFits(childProposal).height
            view.place(
                at: CGPoint(x: bounds.minX, y: y),
                anchor: .topLeading,
                proposal: childProposal
            )
            y += height + gap
        }
    }
}

private struct ContentView: View {
    private let labels = [
        "Amber tile",
        "Blue tile with a longer local label",
        "Green tile"
    ]

    var body: some View {
        ScrollView(.vertical) {
            EqualWidthColumn {
                ForEach(labels, id: \.self) { label in
                    Text(label)
                        .padding(10)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(
                            RoundedRectangle(cornerRadius: 8)
                                .fill(Color.blue.opacity(0.12))
                        )
                }
            }
            .padding(20)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(minWidth: 220, minHeight: 180)
    }
}

@main
struct GeometryLoopDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

필수 레이아웃 메서드 두 개는 역할을 나눕니다. sizeThatFits은 컨테이너의 크기를 보고하고, placeSubviews는 자식을 배치합니다. 자식 프록시를 사용하면 내부 뷰에 직접 접근하지 않고도 측정과 배치를 할 수 있습니다. 두 메서드는 모두 문서화된 역할을 따릅니다. Apple의 Layout 문서를 참고하세요.

구현에서 중요한 점은 너비 제안과 이를 다시 사용하는 방식입니다. 각 자식은 높이를 지정하지 않고 너비를 지정해 측정하므로 텍스트가 필요한 높이를 결정할 수 있습니다. 배치에는 컨테이너의 실제 경계 너비와 동일한 높이 정책을 사용합니다. bounds.minX 및 bounds.minY에서 시작하면 배치가 좌표 원점에서 시작한다고 가정하지 않아도 됩니다.

컨테이너는 빈 컬렉션에 대해 0을 반환합니다. 너비가 지정되지 않았으면 이상적인 너비를 선택하고, 유한한 너비가 지정되었으면 이상적인 너비를 그 값 이하로 제한합니다. 너비가 무한대로 제안된 경우에도 이 레이블에 대해서는 유한한 이상적 너비를 반환합니다. 이는 콘텐츠 크기에 맞춘 이 패널을 위해 의도적으로 정한 규칙입니다. Apple은 부모가 단일 레이아웃 패스 중에 0, 무한대, 미지정 등 다양한 제안으로 레이아웃을 탐색할 수 있다고 설명합니다. sizeThatFits(proposal:subviews:cache:)를 참고하세요.

예제에는 측정 크기 상태도, 기하 콜백도, 렌더링된 너비를 다시 프레임에 할당하는 코드도 없습니다. 세로 스크롤 뷰를 사용하므로 줄바꿈으로 늘어난 콘텐츠를 표시할 공간이 생깁니다. 이 레이아웃은 제공된 레이블처럼 유한한 너비에 적절히 반응하는 자식을 가정합니다. 사용 가능한 공간을 초과하는 고정 너비 자식에는 가로 스크롤이나 대체 뷰 같은 별도의 표현 방식이 필요합니다.

기하 관찰은 언제 여전히 적절한가요?

측정값이 제한적인 반응을 일으키는 경우에는 기하 관찰이 여전히 유용합니다. Apple의 onGeometryChange API는 변환과 동작을 분리합니다. 변환은 동등성 비교가 가능한 값을 추출하고, 동작은 변환된 값이 바뀔 때 실행됩니다. 기하는 자주 바뀔 수 있으므로 Apple은 광범위한 앱 업데이트를 피하라고 권장합니다. onGeometryChange(for:of:action:)를 참고하세요.

예를 들어 오버레이에 안정적인 포함 영역이 선택한 너비를 초과하는지 표시할 수 있습니다. 해당 포함 영역을 관찰하고 변환에서 불리언 값을 반환하세요. 오버레이가 영역의 크기에 영향을 주지 않도록 합니다. 이 설계는 문서화된 변환 및 동작 모델을 따르면서 순환적인 크기 의존성을 제거합니다.

사각형을 불리언으로 축약하면 알림 횟수를 줄일 수 있지만, 그 자체로 안정성이 보장되지는 않습니다. 간결한 표현으로 전환했을 때 측정 대상 영역이 크게 변해 다시 넓은 표현으로 전환된다면 불리언 값이 무한히 오갈 수 있습니다. 너비가 주변 윈도우 레이아웃에 따라 결정되는 영역을 측정하거나, ViewThatFits를 사용해 레이아웃 내부에서 표현 방식을 선택하세요.

가용성은 오버로드에 따라서도 달라집니다. 새 값 하나를 받는 동작은 해당 API가 포함된 SDK로 빌드할 때 macOS 13 이상에서 사용할 수 있습니다. 이전 값과 새 값을 모두 받는 오버로드에는 macOS 15 이상이 필요합니다. 전체 예제는 Layout만 사용하므로 이 차이를 고려할 필요가 없습니다. 콜백 시그니처를 이전 배포 대상에 복사하기 전에 Apple의 기하 변경 문서와 이전 값 및 새 값을 받는 오버로드를 확인하세요.

AppKit 및 TextKit 측정값은 SwiftUI 레이아웃에 어떻게 반영해야 하나요?

콘텐츠가 AppKit 뷰인 경우, representable의 크기 조정 경계를 사용하세요. NSViewRepresentable.sizeThatFits(_:nsView:context:)은 macOS 13 이상에서 사용할 수 있고, 제안된 크기에 맞는 선호 크기를 반환하며, 한 패스에서 서로 다른 제안으로 여러 번 호출될 수 있습니다. nil을 반환하면 기본 크기 조정 알고리즘을 요청합니다. 이 메서드는 측정된 치수를 SwiftUI 상태에 게시하는 곳이 아니라 크기를 조회하는 방법으로 다루세요. representable 크기 조정 메서드를 참고하세요.

SwiftUI가 representable에 포함된 AppKit 뷰의 frame 및 bounds을 제어합니다. Apple은 이러한 레이아웃 속성을 직접 설정하지 말라고 명시적으로 경고합니다. SwiftUI와 충돌해 정의되지 않은 동작이 발생하기 때문입니다. representable의 업데이트 메서드에서 콘텐츠를 설정하고, 지원되는 크기 조정 방식을 통해 크기를 보고하세요. NSViewRepresentable을 참고하세요.

텍스트를 다룰 때는 먼저 뷰에서 어떤 레이아웃 엔진을 사용하는지 확인하세요. TextKit 2는 macOS 12 이상에서 사용할 수 있는 NSTextLayoutManager.usageBoundsForTextContainer를 텍스트 컨테이너 사용 영역 측정값으로 제공합니다. Apple은 이 값을 KVO로 관찰할 수 있다고 설명합니다. 이 관찰은 크기 변경 요청에 활용할 수 있지만, 너비와 높이 사이의 의존성을 일관되게 유지해야 합니다. usageBoundsForTextContainer를 참고하세요.

기존 TextKit 1 구현에서는 NSLayoutManager.usedRect(for:)이 이미 레이아웃된 글리프가 차지하는 영역을 보고합니다. 글리프를 생성하거나 레이아웃을 수행하지는 않습니다. 측정에 레이아웃 완료가 필요하면, 사용된 사각형을 읽기 전에 관련 컨테이너에 ensureLayout(for:)을 호출하세요. 두 API 모두 macOS 10.7 이상에서 사용할 수 있습니다. usedRect(for:) 및 ensureLayout(for:)을 참고하세요.

측정값을 얻기 위해 TextKit 2 텍스트 뷰의 레거시 layoutManager에 접근하지 마세요. Apple은 레거시 레이아웃 관리자에 접근하면 텍스트 뷰가 TextKit 1 호환성 모드로 전환될 수 있다고 설명합니다. 먼저 textLayoutManager을 확인하고, 적절한 대체 처리 분기에서 레거시 측정 코드를 사용하세요. TextKit 및 텍스트 뷰의 새로운 기능을 참고하세요.

줄바꿈되는 텍스트 뷰에서는 제안된 너비를 텍스트 레이아웃의 입력으로 사용하고, 해당 구성에서 선호 높이를 계산하세요. 텍스트 뷰의 인셋과 컨테이너 패딩을 명시적으로 반영하세요. 컨테이너 좌표계의 사각형이 자동으로 바깥 뷰의 크기가 되는 것은 아닙니다. AppKit UI 작업은 해당 액터의 격리 규칙 안에서 처리하세요. Swift의 동시성 장에서는 메인 액터 실행을 설명합니다. 작업을 태스크로 옮겨도 크기 조정 공식의 순환 의존성은 바뀌지 않습니다.

레이아웃이 안정적으로 정착하는지 어떻게 확인하나요?

Xcode에서 예제를 실행하고 윈도우를 양방향으로 천천히 크기 조정하세요. 기대되는 동작은 레이블이 너비를 공유하고, 좁아지면 줄바꿈되며, 콘텐츠가 표시 가능한 높이를 넘으면 세로로 스크롤되는 것입니다. 그런 다음 측정 상태 의존성을 제거한 후 실제 뷰 구조에서도 확인하세요.

정상적인 레이아웃 작업과 지속적인 피드백을 구분하려면 다음을 확인하세요.

  • 크기 조정 후 윈도우를 그대로 둡니다. 콘텐츠가 바뀌지 않았는데도 측정값과 상태 할당이 계속 변하는지 확인합니다.
  • 더 긴 예시 레이블과 더 큰 글꼴을 사용해 봅니다. 텍스트가 잘리지 않고 패널 높이가 늘어나는지 확인합니다.
  • 레이블이 없는 컬렉션을 테스트합니다. 주변 패딩을 적용하기 전에 레이아웃이 0을 반환해야 합니다.
  • 측정 제안과 배치 제안을 모두 살펴봅니다. 줄바꿈되는 자식에는 일관된 너비 조건이 제공되어야 합니다.
  • 나중에 캐싱을 추가한다면, 유효성 검사 규칙에 크기 조정 입력을 포함합니다. 다른 너비에서 계산된 높이를 재사용하지 마세요.

한 패스 중 크기 조정이 여러 번 호출되는 것은 문서화된 동작입니다. 실질적인 목표는 콘텐츠와 제약 조건이 그대로일 때 결과가 안정적으로 유지되는 것입니다. 배치를 결정하는 측정값은 레이아웃 협상 안에서 처리하고, 관찰한 치수를 다시 만들어 내지 않는 반응에만 기하 관찰을 사용하세요.

출처