Mac 앱의 작은 레이블 행에서는 ViewThatFits를 사용해 숨겨진 개수를 포함했을 때 들어가는 가장 긴 완전한 앞부분을 선택합니다. 이 튜토리얼에서는 레이블을 줄임표로 자르거나 레이아웃 중에 상태를 업데이트하지 않고 해당 행을 구성합니다.

구체적인 작업은 합성 레이블 6개가 있는 크기 조절 가능한 macOS 윈도우를 만드는 것입니다. 순서를 유지하고, 완전한 레이블만 표시하며, 생략된 뒤쪽 항목 수를 3 more 같은 배지로 보여 줍니다. 이 솔루션은 개수만 표시하는 배지가 들어갈 만큼 가로 공간이 충분하고 한 행을 표시할 세로 공간도 충분하다고 가정합니다.

완전한 행 대안 선택하기

Apple은 ViewThatFits를 제약된 축에서 제안된 크기에 이상적인 크기가 맞는 첫 번째 자식을 선택하는 뷰라고 설명합니다. macOS 13 이상에서 사용할 수 있습니다. 여기서는 행 너비가 문제이므로 .horizontal을 사용합니다. 주변 인터페이스에서 행의 높이를 제공해야 합니다. ViewThatFits

레이블이 6개라면 다음 순서로 대안 7개를 제공합니다. 배지 없이 레이블 6개, 1 more와 레이블 5개, 2 more와 레이블 4개, 이런 식으로 마지막에는 6 more만 남습니다. 각 대안은 완전한 행입니다. 배지도 맞는지 판단하는 과정에 포함되므로 개수를 추가해도 마지막으로 보이는 레이블이 예기치 않게 밀려나지 않습니다.

이 방식은 앞에서부터 이어지는 항목을 표시하는 정책입니다. 첫 번째 레이블이 길면 뒤에 짧은 레이블이 들어갈 수 있더라도 모든 레이블이 생략될 수 있습니다. 이는 의도된 동작입니다. 개별 레이블을 건너뛰어도 되는 작업이라면 구현 전에 다른 선택 정책을 정하세요.

각 후보 행에 fixedSize()를 적용해 레이블과 배지가 이상적인 크기를 유지하도록 합니다. Apple은 이 수정자가 뷰를 부모의 경계보다 크게 만들 수 있다고 설명합니다. 이 수정자만으로 뷰가 맞는다고 보장되지는 않습니다. 여기서는 바깥쪽 선택 컨테이너가 맞는지 판단합니다. fixedSize()

완전한 macOS 예제 구성하기

Xcode에서 macOS SwiftUI 앱을 만들고 배포 대상을 macOS 13 이상으로 설정합니다. 생성된 앱 선언과 콘텐츠 뷰를 아래 단일 파일 프로그램으로 바꿉니다. 타깃에는 @main으로 표시된 앱 선언을 정확히 하나만 둡니다.

이 예제는 레이블 텍스트를 식별자로 사용하지 않고 명시적인 식별자를 사용합니다. Apple의 ForEach 문서는 식별된 요소나 명시적인 식별자 키 경로를 요구합니다. 서로 다른 식별자를 사용하면 표시되는 제목이 같은 두 항목도 처리할 수 있습니다. ForEach

import SwiftUI

struct DemoItem: Identifiable {
    let id: Int
    let title: String
}

struct ItemChip: View {
    let title: String

    var body: some View {
        Text(title)
            .lineLimit(1)
            .padding(.horizontal, 10)
            .padding(.vertical, 6)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(Color.blue.opacity(0.12))
            )
    }
}

struct PrefixCandidate: View {
    let items: [DemoItem]
    let visibleCount: Int

    var body: some View {
        HStack(spacing: 8) {
            ForEach(items.prefix(visibleCount)) { item in
                ItemChip(title: item.title)
            }

            if visibleCount < items.count {
                Text("\(items.count - visibleCount) more")
                    .lineLimit(1)
                    .padding(.horizontal, 10)
                    .padding(.vertical, 6)
                    .background(
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.gray.opacity(0.15))
                    )
            }
        }
        .fixedSize()
    }
}

struct WholeItemRow: View {
    let items: [DemoItem]

    var body: some View {
        if items.isEmpty {
            Text("No items")
                .fixedSize()
        } else {
            ViewThatFits(in: .horizontal) {
                ForEach(
                    Array((0...items.count).reversed()),
                    id: \.self
                ) { visibleCount in
                    PrefixCandidate(
                        items: items,
                        visibleCount: visibleCount
                    )
                }
            }
        }
    }
}

struct ContentView: View {
    let items: [DemoItem] = [
        DemoItem(id: 1, title: "Sketch"),
        DemoItem(id: 2, title: "Color samples"),
        DemoItem(id: 3, title: "Shapes"),
        DemoItem(id: 4, title: "Paper textures"),
        DemoItem(id: 5, title: "Layout notes"),
        DemoItem(id: 6, title: "Preview")
    ]

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Sample labels")
                .font(.headline)

            WholeItemRow(items: items)
                .frame(maxWidth: .infinity, alignment: .leading)

            Text("Resize the window to change how many labels fit.")
                .foregroundStyle(.secondary)
        }
        .font(.system(size: 14))
        .padding(24)
        .frame(minWidth: 320, idealWidth: 680, minHeight: 160)
    }
}

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

후보 식별자는 표시할 수 있는 개수이며 항목 개수부터 0까지 내림차순으로 이어집니다. 각 후보 안에서는 항목 식별자가 레이블을 식별합니다. 각 반복은 가로 행 하나를 포함하는 PrefixCandidate 하나를 만듭니다.

바깥쪽 유연한 프레임은 선택된 콘텐츠를 앞쪽에 정렬하면서 행이 사용 가능한 가로 공간을 받도록 합니다. fixedSize()는 ViewThatFits 안의 후보에 적용한 상태로 유지합니다. 전체 선택 컨테이너에 적용하면 해당 컨테이너가 부모의 너비 제안에 반응하는 방식이 달라집니다.

이 예제는 완전한 소스 코드이지만, 여기서는 Swift 툴체인을 사용할 수 없어 컴파일하거나 실행하지 않았습니다. 다음 섹션의 예시 산술은 별도로 확인했으며, 실제 텍스트 너비는 Mac 앱에서 검증해야 합니다.

맞는지 계산할 때 배지를 포함하기

유용한 판단 규칙은 후보 전체가 맞는 후보 중 표시 항목 수가 가장 많은 것을 선택하는 것입니다. 숨겨진 항목 수는 항상 전체 항목 수에서 후보의 표시 항목 수를 뺀 값입니다. 따로 추정한 값을 동기화할 필요가 없습니다.

너비가 각각 72, 96, 64, 88포인트인 항목 4개가 있는 행을 예로 들어 보겠습니다. 너비에는 항목 여백이 포함됩니다. 간격은 8포인트이며, 숨겨진 항목 수가 0이 아닐 때 배지 너비는 60포인트라고 가정합니다. 이는 예제 앱에서 측정한 값이 아니라 계산을 위해 임의로 정한 입력값입니다.

항목 4개가 모두 표시될 때 필요한 너비는 72 + 96 + 64 + 88 + 3 × 8 = 344포인트입니다. 항목 3개를 표시하면 생략된 뒤쪽 항목 대신 배지가 들어가므로 너비는 72 + 96 + 64 + 3 × 8 + 60 = 316포인트가 됩니다. 후보에 항목 3개와 배지 1개가 있으므로 간격은 3개가 필요합니다.

사용 가능한 너비 표시 항목 숨겨진 항목 선택된 후보 너비
344포인트 4 0 344포인트
343포인트 3 1 316포인트
300포인트 2 2 244포인트
220포인트 1 3 140포인트
60포인트 0 4 60포인트

프로그램의 각 후보에는 실제 배지 텍스트가 들어갑니다. 따라서 예시의 배지 너비가 일정하다는 가정에 의존하지 않고 배지 문자열 간 차이를 반영합니다. 후보 너비가 일정한 간격으로 달라질 필요는 없습니다. 표시 항목 수를 내림차순으로 두는 이유는 선택 우선순위를 표현하기 위해서입니다.

개수만 표시하는 후보가 사용 가능한 최소 너비를 결정합니다. 이 배지조차 들어갈 수 없다면, 이 규칙으로는 사용 가능한 너비 안에 전체 개수를 표시할 수 없습니다. 충분한 최소 너비를 제공하거나, 다른 배치를 허용하거나, 개수를 다른 곳에 표시하세요. 잘림을 온전한 항목 표시의 성공으로 간주하지 마세요.

이 예제에서는 최소 콘텐츠 너비와 짧은 합성 배지에 적당한 명시적 글꼴을 지정합니다. 문구, 글꼴, 여백 또는 주변 레이아웃을 바꾸면 개수만 표시하는 경우가 다시 맞는지 확인하세요. 이 예제를 위해 정한 최소 너비가 모든 배지에 적용되는 값은 아닙니다.

레이아웃 선택과 뷰 생명주리 분리하기

숨겨진 개수는 각 후보의 변경 불가능한 입력에 포함됩니다. 프로그램은 측정 메서드에서 바인딩에 값을 쓰거나, 비동기 개수 업데이트를 예약하거나, 표시 콜백으로 가시성을 추론하지 않습니다. 행을 선택하면 그 행에 맞는 배지도 함께 선택됩니다.

반복되는 후보 콘텐츠는 단순하게 유지하세요. 여러 대안에서 레이블을 반복한다고 해서 그 레이블이 모든 대안에서 하나의 공유된 식별자를 갖는 것은 아닙니다. Apple은 상태 저장소가 뷰 식별성에 연결되며 식별성이 바뀌면 상태가 대체될 수 있다고 설명합니다. Demystify SwiftUI

나중에 레이블을 편집 가능한 컨트롤로 바꾼다면 공유 값은 공통 상위 뷰나 모델에 두고, 각 표현에 적절한 입력값이나 바인딩을 전달하세요. Apple은 공유 상태를 이를 필요로 하는 뷰들의 가장 가까운 공통 상위 뷰에 두도록 권장합니다. 표현이 바뀌어도 후보 내부 상태가 유지된다고 기대하지 마세요. 사용자 인터페이스 상태 관리하기

이 솔루션은 선택된 행 안에 숨겨진 개수를 표시합니다. 선택된 개수를 형제 뷰나 앱 모델에 전달하지는 않습니다. Apple이 문서화한 ViewThatFits 이니셜라이저는 제약된 축과 후보 콘텐츠를 받으며, 선택된 개수를 바인딩하는 기능은 제공하지 않습니다. 이 값을 내보내는 것은 추가 설계 요구 사항으로 다루세요. init(in:content:)

사용자 지정 레이아웃에 다른 설계가 필요한 경우

특수한 기하 구조가 필요하다면 사용자 지정 Layout이 적합합니다. Apple은 핵심 책임을 복합 크기 보고와 레이아웃 프록시를 통한 하위 뷰 위치 지정으로 정의합니다. 프록시는 측정 및 배치 작업을 제공하며 내부 뷰에 직접 접근하는 기능은 제공하지 않습니다. Layout

맞는 마지막 항목 뒤에서 배치만 멈추는 방식으로 생략을 구현하지 마세요. Apple의 LayoutSubview 문서는 각 하위 뷰를 한 번씩 배치하라고 안내합니다. 배치를 건너뛰는 것은 문서화된 숨김 방법이 아니며, 레이아웃 프록시는 다른 하위 뷰를 새 개수 레이블로 바꾸는 일반적인 콘텐츠 재작성 기능을 제공하지 않습니다. LayoutSubview

사용자 지정 레이아웃을 만든다면 간격과 사용된 크기 제안을 포함해 측정과 배치가 일치하도록 하세요. Apple은 이 계산들이 일관되어야 한다고 명시적으로 요구합니다. 레이아웃이 여러 배치를 검토하더라도 이 계약은 적용됩니다. placeSubviews(in:proposal:subviews:cache:)

후보 방식은 의도적으로 범위를 제한합니다. 항목 6개라면 후보 행 7개와 그 행 전체에서 항목이 나타나는 횟수 21회를 다룹니다. 일반적으로 항목 표시 횟수는 n × (n + 1) / 2에 따라 증가합니다. 이는 구조를 센 값이지, 성능 벤치마크나 모든 업데이트에서 모든 후보가 렌더링된다는 주장이 아닙니다. 레이블이 적은 컬렉션에 사용하고, 컬렉션이 크거나 콘텐츠 처리 비용이 높다면 확장하기 전에 프로파일링하세요.

경계 조건에서 행 확인하기

앱을 실행하고 윈도우 크기를 천천히 조절합니다. 컴포넌트를 적용하기 전에 다음 조건을 확인하세요.

  • 너비가 넉넉하면 모든 레이블이 표시되고 0 more 배지는 없습니다.
  • 윈도우가 좁아지면 레이블이 끝에서부터 사라지며, 일부만 표시되거나 줄임표가 붙은 레이블은 없습니다.
  • 표시 항목 수에 배지의 숨겨진 항목 수를 더한 값은 항상 6입니다.
  • 첫 번째 레이블이 유난히 길어도 개수만 표시하는 후보를 사용할 수 있습니다.
  • 배열이 비어 있으면 행에 No items가 표시됩니다.
  • 제목이 중복되고 식별자가 다르면 두 항목 모두 개수에 반영됩니다.
  • 글꼴이나 배지 문구를 바꾼 뒤에도 개수만 표시하는 후보가 지원되는 최소 너비에 들어갑니다.

순서가 있는 작은 행에서는 완전한 대안을 사용하면 맞는지 판단하는 규칙을 쉽게 확인할 수 있습니다. 각 후보에는 레이블, 간격, 정확한 숨겨진 항목 수가 포함됩니다. 최소 너비 조건을 명시하고 반복되는 콘텐츠를 가볍게 유지하면 별도의 개수 업데이트 주기나 잘린 항목 없이 윈도우 크기에 맞춰 행이 조정됩니다.

출처