Mac 앱에서 중첩된 확장에 애니메이션을 적용하려면 DisclosureGroup과 애니메이션이 적용된 확장 바인딩으로 시작하세요. 확장 높이를 정밀하게 제어해야 한다면 스크롤 뷰에서 사용자 지정 레이아웃을 사용하세요. 콘텐츠 전환만으로는 해당 기하 구조가 정의되지 않습니다.
이 튜토리얼에서는 항목의 작은 계층 구조를 확장해 콘텐츠를 표시하고, 아래 항목이 높이 변화에 따라 이동하도록 하는 구체적인 작업을 다룹니다. 예제는 합성 레이블을 사용하며 앱 자체 윈도우 안에서만 동작합니다.
콘텐츠는 희미해지는데 눈에 띄게 확장되지 않는 이유는 무엇인가요?
결과를 진단할 때는 확장 상태, 삽입된 콘텐츠의 모양, 컨테이너가 할당하는 공간을 구분하세요. Apple은 withAnimation, 값 기반 애니메이션 수정자, 애니메이션 바인딩을 상태 변경에 애니메이션을 적용하는 방법으로 문서화합니다. 전환은 뷰가 계층 구조에 들어오거나 나갈 때 적용되는 효과로 별도로 설명합니다. 애니메이션
불투명도 전환은 투명도를 바꿉니다. 이동 전환은 들어오거나 나가는 뷰의 위치를 가장자리 기준으로 바꿉니다. 어느 API도 그 자체로 주변 목록이 행 높이를 어떻게 보간해야 하는지는 지정하지 않습니다. 따라서 이동 전환을 추가하면 화면에 나타나는 방식은 달라질 수 있지만, 원하는 특정 레이아웃 동작이 해결되는 것은 아닙니다. move(edge:)
이를 특정 내부 구현의 증거가 아니라 진단을 위한 구분으로 보세요. 세부 항목이 희미해지는 동안 다음 행이 갑자기 이동한다면, 세부 항목의 전환과 별개로 컨테이너의 기하 구조를 살펴보세요. 문서에 따르면 List API는 선택을 지원할 수 있는 행 컨테이너입니다. 모든 중첩 구성과 목록 스타일에서 특정 확장 곡선을 보장하지는 않습니다. 목록
기본 중첩 DisclosureGroup으로 시작하기
일반적인 Mac 계층 구조에는 직접 확장 컨트롤을 구현하기 전에 DisclosureGroup을 사용하세요. 이 컨트롤은 불리언 바인딩으로 확장을 표현하며, Apple 문서에는 하나의 디스클로저 그룹 안에 다른 디스클로저 그룹을 중첩하는 예제가 명시되어 있습니다. 이 API는 macOS 11 이상에서 사용할 수 있습니다. DisclosureGroup
Xcode에서 SwiftUI를 사용하는 macOS 앱 프로젝트를 만드세요. 생성된 앱 선언을 다음 전체 프로그램으로 바꾸되, 타깃에 @main 선언은 정확히 하나만 두세요. 이 코드에는 모든 샘플 콘텐츠와 두 확장 상태가 포함되어 있습니다.
import SwiftUI
@main
struct NestedDisclosureDemoApp: App {
var body: some Scene {
WindowGroup {
NativeHierarchyView()
.frame(minWidth: 360, minHeight: 280)
}
}
}
struct NativeHierarchyView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var collectionExpanded = false
@State private var shapesExpanded = false
private var expansionAnimation: Animation? {
reduceMotion ? nil : .easeInOut(duration: 0.25)
}
var body: some View {
List {
DisclosureGroup(
"Collection",
isExpanded: $collectionExpanded.animation(expansionAnimation)
) {
DisclosureGroup(
"Shapes",
isExpanded: $shapesExpanded.animation(expansionAnimation)
) {
Text("Circle")
Text("Square")
}
Text("Palette")
}
Text("Following item")
}
}
}
바인딩의 animation 메서드는 값이 바뀔 때 애니메이션을 요청합니다. Collection을 클릭하고 Shapes를 확장한 다음 Collection을 접었다가 다시 여세요. 상태를 각각 저장하므로 부모가 닫혀 있는 동안에도 Shapes가 확장되어 있었는지 샘플이 기억할 수 있습니다. 이 상태는 현재 뷰의 메모리에만 저장되며 앱을 다시 실행해도 유지되지는 않습니다. 애니메이션 바인딩은 Apple이 문서화한 애니메이션 진입점 중 하나입니다. 애니메이션
이 예제의 0.25초 이징 곡선은 디자인 선택을 보여 주기 위한 것입니다. 시스템의 Reduce Motion 환경설정이 활성화되어 있으면 nil을 전달합니다. Apple은 accessibilityReduceMotion를 통해 이 환경설정을 제공하며, 값이 참일 때 큰 애니메이션을 피하도록 안내합니다. accessibilityReduceMotion
앱이 지원하는 각 macOS 릴리스와 목록 스타일에서 기본 버전을 평가하세요. 동작이 요구 사항을 충족하면 그대로 사용하세요. 디스클로저 모양만 조정해야 한다면 macOS 13 이상에서 DisclosureGroupStyle을 지원되는 사용자 지정 지점으로 사용할 수 있습니다. 사용자 지정 스타일도 뷰를 조합하는 방식이며, 주변 목록의 행 애니메이션을 임의로 제어할 수 있다고 문서에 보장되어 있지는 않습니다. DisclosureGroupStyle
필요한 결과에 따라 컨테이너 선택하기
수정자를 더 추가하기 전에 다음 판단 기준을 사용하세요. 기본 행 상호작용과 명시적으로 제어하는 콘텐츠 표시를 구분합니다.
| 필요한 결과 | 시작점 | 확인할 사항 |
|---|---|---|
| 목록 선택 기능이 있는 일반적인 확장형 행 | List 및 DisclosureGroup |
실제 macOS 버전과 목록 스타일에서의 확장 동작 |
| 다른 디스클로저 레이블 또는 컨트롤 모양 | DisclosureGroupStyle |
주변 컨테이너가 여전히 원하는 기하 구조를 만드는지 여부 |
| 작은 계층 구조에서 높이를 정확히 정의하는 콘텐츠 표시 | ScrollView, VStack, 애니메이션 가능한 Layout |
중첩 크기 조정, 클리핑, 컨트롤 동작 |
| 열과 델리게이트가 있는 기존 AppKit 아웃라인 | NSOutlineView |
자체 확장 API와 데이터 소스 동작 |
아래의 사용자 지정 방식은 의도적으로 스크롤 뷰 안에 스택을 사용합니다. 자체 디스클로저 버튼과 기하 구조를 제공하므로 인터페이스에 필요한 목록 선택이나 행 명령도 직접 구현해야 합니다. Apple은 선택 기능을 List의 기능으로 문서화합니다. 컨테이너를 바꾸면 그 출발점도 달라집니다. 목록
사용자 지정 레이아웃으로 표시 높이에 애니메이션 적용하기
macOS 13 이상에서 Layout 프로토콜을 사용하면 컨테이너가 크기를 계산하고 자식을 배치할 수 있습니다. 이 프로토콜은 Animatable도 상속합니다. 삽입 효과에만 의존하지 않고 확장량을 보간되는 숫자로 만들기 위한 문서화된 구성 요소입니다. Layout
다음의 독창적인 구현은 표시 영역마다 자식 스택 하나를 사용합니다. 높이를 지정하지 않고 사용 가능한 너비로 스택을 측정한 다음, 측정된 높이의 일부를 보고합니다. 표시 영역이 변하는 동안에도 자식은 위쪽에 배치됩니다. Apple 문서에는 제안을 사용해 하위 뷰를 측정하고, 이에 맞는 제안으로 배치하는 방법이 모두 설명되어 있습니다. sizeThatFits(_:), place(at:anchor:proposal:)
이전 예제를 대체할 전체 프로그램으로 다음 코드를 사용하세요. 배포 대상을 macOS 13 이상으로 설정하세요. 이 환경에는 Swift 실행 파일이나 macOS 런타임이 없어 코드를 컴파일하거나 화면에서 테스트하지는 않았지만, 문서화된 API를 기준으로 코드를 검토했습니다.
import SwiftUI
@main
struct RevealDemoApp: App {
var body: some Scene {
WindowGroup {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
RevealGroup("Collection") {
RevealGroup("Shapes") {
Text("Circle")
Text("Square")
}
Text("Palette")
}
Text("Following item")
.padding(.vertical, 8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.frame(minWidth: 360, minHeight: 280)
}
}
}
struct RevealGroup<Content: View>: View {
let title: String
let content: Content
@State private var expanded = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
init(_ title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(
reduceMotion ? nil : .easeInOut(duration: 0.25)
) {
expanded.toggle()
}
} label: {
HStack {
Image(systemName: "chevron.right")
.rotationEffect(.degrees(expanded ? 90 : 0))
.accessibilityHidden(true)
Text(title)
Spacer(minLength: 0)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityValue(expanded ? "Expanded" : "Collapsed")
RevealLayout(progress: expanded ? 1 : 0) {
VStack(alignment: .leading, spacing: 8) {
content
}
.padding(.leading, 18)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.clipped()
.allowsHitTesting(expanded)
.disabled(!expanded)
.accessibilityHidden(!expanded)
}
}
}
struct RevealLayout: Layout {
var progress: CGFloat
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard let child = subviews.first else { return .zero }
let width = proposal.width.flatMap { $0.isFinite ? $0 : nil }
let size = child.sizeThatFits(
ProposedViewSize(width: width, height: nil)
)
return CGSize(
width: width ?? size.width,
height: size.height * min(max(progress, 0), 1)
)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard let child = subviews.first else { return }
child.place(
at: CGPoint(x: bounds.minX, y: bounds.minY),
anchor: .topLeading,
proposal: ProposedViewSize(width: bounds.width, height: nil)
)
}
}
핵심 값은 progress입니다. 애니메이션 중에 SwiftUI는 이전 값과 새 animatableData 값 사이를 보간합니다. 여기서는 보고되는 높이가 0에서 자식의 측정된 높이까지 연속적으로 바뀝니다. 예를 들어 측정된 높이가 160포인트라면 진행도 0.5에서 80포인트가 표시됩니다. 이 수치는 산술 예시일 뿐, 앱에서 측정한 결과가 아닙니다. Animatable
높이를 지정하지 않으면 현재 표시 높이에 맞춰 자식을 압축하는 대신 제안된 너비에서의 이상적인 높이를 요청합니다. 줄 바꿈되는 레이블에서는 이 점이 중요합니다. 표시 영역은 레이블을 점차 압축하는 게 아니라 레이아웃을 그대로 보여 줘야 합니다. Apple은 지정하지 않은 치수를 이상적인 크기를 얻는 방법으로 설명합니다. ProposedViewSize
그런 다음 클리핑으로 보고된 경계 바깥 부분을 숨깁니다. 클리핑하지 않으면 콘텐츠가 레이아웃에서 예약한 영역 바깥에도 그려질 수 있습니다. 예제처럼 아래쪽 패딩을 표시 영역 안에 두면 접힐 높이에 패딩도 포함됩니다. clipped(antialiased:)
이 구현은 접힌 상태에서도 자식 계층 구조를 의도적으로 유지합니다. 상호작용을 비활성화하고 접힌 영역을 접근성 표현에서 제거하며, 표시되는 버튼은 확장 상태를 알립니다. 히트 테스트와 접근성 표시 여부는 별도의 제어 항목이므로 클리핑만으로는 충분하지 않습니다. allowsHitTesting(_:), accessibilityHidden(_:)
사용자 지정 버전을 채택하기 전에 중첩 동작 확인하기
다음 순서로 테스트해 단순한 상태에서만 제대로 보이는 애니메이션과 실제로 작동하는 표시 효과를 구분하세요.
Collection을 확장한 다음Shapes를 확장하세요. 두 단계가 바뀌는 동안Following item을 살펴보세요.Shapes가 열려 있는 상태에서Collection을 접었다가 다시 여세요. 의도한 자식 확장 상태가 유지되는지 확인하세요.- 애니메이션이 끝나기 전에 디스클로저 버튼을 다시 클릭하세요. 빈 공간을 남기지 않고 표시 효과가 반대로 움직이는지 확인하세요.
- 윈도우 너비를 줄이고 샘플 레이블 하나를 더 긴 합성 레이블로 바꾸세요. 줄 바꿈과 최종 표시 높이를 확인하세요.
Reduce Motion을 활성화하고 애니메이션 움직임 없이도 확장을 사용할 수 있는지 확인하세요.- 키보드 활성화와 펼친 그룹 및 접힌 그룹의 접근성 표현을 확인하세요.
사용자 지정 레이아웃은 이 레이블과 중첩 스택처럼 높이가 유한한 콘텐츠를 위해 설계되었습니다. 세로로 제한 없이 스크롤되는 영역을 표시 영역 안에 넣고 전체 콘텐츠 높이를 유용하게 제공하리라고 기대하지 마세요. 이 구현은 이상적인 높이를 명시적으로 요청하므로 자식은 해당 제안에 대해 의미 있는 높이를 반환할 수 있어야 합니다. 이는 선택한 크기 조정 방식에 따른 결과입니다. ProposedViewSize
접힌 콘텐츠를 유지하면 생성 및 레이아웃 비용도 계속 발생합니다. 작은 계층 구조에는 일반 VStack으로 시작하고, 이 예제를 큰 트리에 적용하기 전에 다른 구조를 검토하세요. 데이터 기반 행에서는 확장과 재정렬이 일어나도 모델 식별자를 안정적으로 유지하세요. Swift의 Identifiable 프로토콜은 안정적인 엔터티 식별성을 표현하며, ForEach는 식별된 데이터를 사용해 뷰를 만듭니다. Identifiable, ForEach
AppKit과 TextKit은 어떤 경우에 적합한가요?
인터페이스에서 이미 AppKit을 사용한다면 NSOutlineView는 계층 구조의 행과 열을 표시하는 문서화된 컨트롤입니다. 이 컨트롤의 expandItem(_:) 메서드는 항목을 확장하며, 항목이 이미 확장되어 있거나 확장할 수 없는 경우에는 아무 작업도 하지 않습니다. 이 메서드에는 SwiftUI 애니메이션 곡선 매개변수가 노출되어 있지 않으므로 위에서 살펴본 사용자 지정 타이밍을 제공한다고 가정하지 마세요. NSOutlineView, expandItem(_:)
TextKit은 NSTextView에서 사용하는 기능을 포함해 텍스트 저장, 텍스트 레이아웃, 렌더링을 관리합니다. 일반 레이블로 구성된 계층 구조에서는 확장 컨테이너와 별개의 영역입니다. 콘텐츠 자체에 텍스트 편집이나 사용자 지정 텍스트 레이아웃이 필요할 때 사용하세요. SwiftUI 목록 확장에 애니메이션을 적용하는 방법으로 문서화된 기능은 아닙니다. TextKit
기본 디스클로저 그룹으로 시작해 대상 Mac 인터페이스에서 동작을 확인하세요. 높이를 제어하는 표시 효과가 필요하다면 사용자 지정 레이아웃에서 그 높이를 명시적인 애니메이션 가능 값으로 지정할 수 있습니다. 필요한 상호작용에 맞춰 선택하세요. 기본 목록 동작, 사용자 지정 표시 기하 구조 또는 기존 AppKit 아웃라인 중에서 고르면 됩니다.
출처
- DisclosureGroup — Apple
- 애니메이션 — Apple
- move(edge:) — Apple
- 목록 — Apple
- accessibilityReduceMotion — Apple
- DisclosureGroupStyle — Apple
- Layout — Apple
- sizeThatFits(_:) — Apple
- place(at:anchor:proposal:) — Apple
- Animatable — Apple
- ProposedViewSize — Apple
- clipped(antialiased:) — Apple
- allowsHitTesting(_:) — Apple
- accessibilityHidden(_:) — Apple
- Identifiable — Apple
- ForEach — Apple
- NSOutlineView — Apple
- expandItem(_:) — Apple
- TextKit — Apple