스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI의 인스펙터: 디테일 발견하기
앱에 한층 더 디테일을 살려줄 구조적 API인 인스펙터를 소개합니다. 인스펙터 API의 기본을 안내하고 인스펙터를 도입하는 법을 알려드리겠습니다. 시트 프레젠테이션 사용자화에 최근 어떤 업데이트가 있었는지 배우고, 이 두 가지를 조합해 완벽한 프레젠테이션 경험을 만들어 내는 법을 알아봅니다.
챕터
- 0:32 - Inspector
- 3:27 - 🍎🍐🍋🍒
- 9:11 - 🍏
- 9:37 - Presentation customizations
리소스
관련 비디오
WWDC23
-
다운로드
♪ ♪
안녕하세요, SwiftUI 팀 엔지니어 닉이라고 합니다 인스펙터는 SwiftUI에 새로 등장한 흥미진진한 엘리먼트인데 인스펙터가 무엇이고 이 API는 어떻게 쓰는지 설명한 다음 프레젠테이션을 사용자화하는 수정자를 검토하겠습니다 인스펙터란 선택한 콘텐츠의 디테일을 보여주는 뷰입니다 아마 전에도 인스펙터와 상호 작용하신 적이 있을 겁니다 Keynote가 인스펙터를 사용해서 선택한 내용의 포맷 디테일을 보여주거든요 이 경우에는 도형 포맷터죠 여기서 인스펙터는 따라오는 사이드바 형태로 나타납니다 인스펙터는 앱의 주 콘텐츠를 보조하는 콘텐츠를 보여줄 때도 흔히 쓰입니다 단축어가 이런 목적으로 인스펙터를 사용하는데 주 인터페이스는 사용자가 편집하는 단축어이고 인스펙터는 사용할 수 있는 앱 및 동작의 라이브러리를 띄워 주 인터페이스를 보조합니다 저는 이 샘플 앱을 사용해 인스펙터 API를 살펴보겠습니다 전 Apple Park 안과 근방의 동물들을 더 잘 알게 됐는데 이 앱은 제가 만난 동물을 저장해 동물의 이름과 동물이 좋아하는 과일을 기록하죠 나중에 다룰 이 열의 제목은 '수상함 수준'입니다 자, 이제 SwiftUI의 인스펙터를 만나 보세요 이 인스펙터는 동물을 선택하면 동물 디테일의 읽기 및 쓰기 뷰를 띄웁니다 여기서 파브리치오피시의 수상함 수준을 '극히 수상함'으로 높이겠습니다 아니, 그냥 '미심쩍다'로 유지할게요 SwiftUI 개발자들은 인스펙터를 macOS뿐만 아니라 iPadOS와 iOS에서도 만날 수 있답니다 인스펙터 API에서는 프로그램으로 열 너비를 제어할 수 있어서 따라오는 열의 너비를 조정할 수 있습니다 이 API에서는 표현 상태를 프로그램으로 제어할 수 있어서 필요할 때마다 인스펙터를 숨길 수도 보일 수도 있습니다 인스펙터는 따라오는 사이드바 이상으로 추상적인 개념입니다 인스펙터는 소형 클래스에서는 크기가 조절되는 시트로 변하고 큰 iPad의 Split 화면에서는 자동으로 오버레이됩니다 SwiftUI는 이미 일련의 구조적 API를 갖추고 있는데 인스펙터는 이 API들과 잘 어울립니다 내비게이션 컴포넌트와 프레젠테이션의 특성을 모두 갖췄거든요 NavigationSplitView 및 NavigationStack과 비슷하게 인스펙터도 여러분이 만드는 씬의 작업대가 되어 줍니다 시트, 팝오버 경고 및 확인 대화 상자처럼 인스펙터도 프레젠테이션이므로 필요에 따라 없애거나 나타나게 할 수 있습니다 새로운 인스펙터 API를 도입할 방법을 배울 시간입니다 앞에서 이 세션에 쓰려고 제가 만든 앱을 보여드렸죠 제가 동물 각각의 수상함 수준을 기록한다는 걸 눈치채셨을 겁니다 저는 아주 중요한 수수께끼를 풀려고 하거든요 누가 Apple Park의 과일을 다 먹어 치우고 있어요 저, 조사관 닉은 앱에 인스펙터를 추가했습니다 동물 각자의 디테일을 수집하려고요 인스펙터 API를 얼마나 쉽게 도입할 수 있었는지 보여드리죠 어쩌면 그러다가 이 흥미진진한 수수께끼를 풀지도 모르겠군요 어서 Xcode로 갑시다 게임을 시작합니다 인스펙터를 추가하려면 가장 먼저 새로 생긴 inspector 수정자를 사용해야 합니다 다른 몇몇 프레젠테이션처럼 이 경우에도 불리언으로 표현한 바인딩이 필요하고 따라오는 뷰 빌더에 있는 인스펙터의 콘텐츠도 필요합니다 인스펙터 콘텐츠에 쓸 AnimalInspectorForm은 미리 만들어 뒀습니다 조사 대상인 동물을 전달하는 사용자 지정 메서드도 있고요 이게 제 인스펙터입니다 macOS에서는 따라오는 열의 모습으로 나타나죠 인스펙터를 멋지게 경험할 씨앗은 심었습니다 이 AnimalForm은 그룹 스타일을 사용합니다 폼 스타일을 혹시 처음 보시나요? 이렇게 적용하는 거랍니다 하지만 인스펙터 콘텍스트는 그룹 스타일이 기본값이므로 제가 직접 지정할 필요는 없습니다 미리보기 캔버스에서 인스펙터와 상호 작용하는 것도 가능합니다 이제 생각났네요 인스펙터는 접히는 게 기본값이지만 기본값으로 크기가 조절되지는 않습니다 inspectorColumnWidth 수정자를 쓰면 크기를 조절할 수 있죠 기본값은 합리적으로 정하겠습니다 최소 200, 이상적 값은 300 최대 400으로 하죠 열을 처음 실행할 때 열의 너비는 이상적 값 매개변수와 같지만 사용자가 인스펙터의 크기를 조정한다면 매번 실행할 때마다 그 크기가 시스템에서 동일하게 유지됩니다 마지막으로 도구 막대 항목을 추가해 표현 상태를 전환하겠습니다 표현 프로퍼티를 전환하는 버튼을 하나 사용하고 레이블로는 info.circle 시스템 이미지를 사용하겠습니다 도구 막대 항목은 인스펙터 위쪽에 있는 도구 막대에 나타날 텐데 그 이유는 이것이 인스펙터 뷰 빌더 내부에서 선언됐기 때문입니다 스크롤하면 도구 막대는 제가 예상한 그대로 동작하는군요 도구 막대 밑으로 콘텐츠가 많이 들어가면 그림자 지대가 생겨납니다
저는 일부러 AnimalTable에 inspector 수정자를 썼습니다 뷰 계층 구조의 다른 지점에 쓰지 않고요 다른 많은 SwiftUI API처럼 inspector 수정자도 어디에 적용하느냐에 따라 다르게 동작합니다 구체적으로 말해 이걸 적용하는 위치에 따라 전체 높이 스타일 즉 도구 막대와 콘텐츠 사이를 분리하지 않는 스타일을 쓸지 아니면 밑 도구 막대 스타일 즉 인스펙터가 도구 막대 밑에 중첩된 스타일을 쓸지가 정해집니다 밑 도구 막대를 사용할 경우 제목 분리자가 창의 너비 전체를 가로지른다는 데 주목하세요 마찬가지로 도구 막대 콘텐츠도 주 콘텐츠의 도구 막대에 위치하거나 인스펙터의 도구 막대에 위치할 텐데 그건 toolbar 수정자를 어디에 썼느냐에 달렸습니다 인스펙터 API를 사용할 때는 두 가지 사항을 고려해야 합니다 첫째, 인스펙터를 내비게이션 구조 안이나 바깥 중 어디에 둘 건가요? NavigationStack이나 NavigationSplitView처럼요 둘째, 도구 막대 콘텐츠는 인스펙터 뷰 빌더의 안에 둘 건가요 바깥에 둘 건가요? 이런 구성 두 가지를 검토해 보겠습니다 첫째로 인스펙터를 내비게이션 구조 안에 넣고 도구 막대 콘텐츠는 인스펙터 밖에 두겠습니다 인스펙터가 NavigationStack 안에 들어 있으면 인스펙터는 내비게이션 스택의 도구 막대 밑에 자리하죠 이 경우 도구 막대 콘텐츠는 인스펙터 바깥에서 주 콘텐츠 위에서 선언되고 내비게이션 스택의 도구 막대에서 렌더링됩니다 소형 가로 크기 클래스에서 인스펙터는 시트로 표현되고 도구 막대 항목은 여전히 주 콘텐츠의 도구 막대 안에 있습니다 다시 제 가능성 테이블로 돌아옵시다 두 번째 구성에서는 인스펙터를 내비게이션 구조 바깥에 두고 도구 막대 콘텐츠를 인스펙터 뷰 빌더 안에 두겠습니다 inspector 수정자를 내비게이션 구조 바깥에 두면 인스펙터는 따라오는 열의 전체 높이를 차지할 수 있습니다 인스펙터에 도구 막대 콘텐츠가 있다면 콘텐츠는 인스펙터를 위해 별도로 마련한 도구 막대 섹션 안에 위치할 겁니다 이 두 도구 막대 항목은 주요 위치에 함께 자리 잡으며 인스펙터 위 내비게이션 도구 막대 한가운데에 자리 잡게 됩니다 하지만 이 경우에는 도구 막대 콘텐츠가 인스펙터 뷰 빌더 안에 있으므로 인스펙터가 시트로 표현되면 도구 막대 콘텐츠도 시트 안에 있게 됩니다
이 원칙은 macOS에도 이어집니다 단, macOS에서는 인스펙터가 시트로 표현되지 않으므로 테이블이 더 단순해지죠 내비게이션 구조 안이냐 밖이냐 하는 축만을 고민하면 됩니다 마지막으로 중요한 얘기를 하겠습니다 NavigationSplitView 안에서 인스펙터를 사용한다면 인스펙터는 디테일 열의 뷰 빌더 안에 둬야 합니다 아니면 예전처럼 내비게이션 구조에서 완전히 밖으로 내보내도 됩니다 세상에, 수수께끼를 풀다 보니 배가 고프네요 이런 앱에서 '갉아 먹음 알림'이 왔네요 앱에서 보여주는 갉아 먹음 알림은 크기를 조절할 수 있는 시트로 과일이 언제 어디에서 갉아 먹혔는지를 알려줍니다 프레젠테이션 사용자화에 관해 얘기하려면 지금이 좋겠군요 이 설정은 SwiftUI가 iOS 16.4와 함께 릴리즈했답니다 과일 갉아 먹음 알림은 인스펙터가 아니라 시트입니다 presentation 수정자를 사용하면 시트만이 아니라 팝오버 등 다른 프레젠테이션에도 상세하게 사용자화할 수 있죠 시험 삼아 몇 가지 해 보겠습니다 presentationBackground 수정자는 이름 그대로 프레젠테이션의 백그라운드를 정합니다 기존의 백그라운드 수정자와는 달리 프레젠테이션에 특화된 수정자는 프레젠테이션 전체를 채우고 아래에 깔린 콘텐츠가 비쳐 보이도록 합니다 그래서 제가 thinMaterial을 사용한다면 시트 뒤에 리스트가 희미하게 보이겠죠 전 시트 뒤에 있는 콘텐츠와 상호 작용이 되게 하고 싶습니다 갉아 먹음 알림을 읽다가 리스트를 스크롤하며 용의자를 보고 싶을 수 있으니까요 presentationBackground Interaction을 활성화하기만 하면 됩니다 그러면 어두운 뷰가 없어지고 백그라운드 콘텐츠와 상호 작용 할 수 있게 됩니다 presentationBackground Interaction 타입은 프레젠테이션 디텐트를 수용하는 upThrough 매개변수를 가질 수도 있습니다 제시된 인수가 해당 프레젠테이션의 디텐트 중 하나와 일치하기만 한다면 SwiftUI는 upThrough 인수보다 큰 디텐트에서만 어두운 뷰를 제공할 겁니다 저는 높이 200 이상인 디텐트에서 어두워지는 걸 원하니 presentationDetents 수정자를 사용해 먼저 높이 200짜리 디텐트를 추가하겠습니다 현재 디텐트가 '중간'인 시트를 조정해 제가 설정한 높이 200으로 낮추겠습니다 백그라운드와의 상호 작용을 높이 200까지만 활성화하면 중간 및 큰 디텐트에서는 어두운 뷰가 다시 나타나겠죠 더욱 다양한 사용자화를 활용해 룩 앤드 필이 완벽한 프레젠테이션을 만들 수 있습니다 이 중 여러 수정자가 시트만이 아니라 다른 프레젠테이션에도 영향을 미칩니다 프레젠테이션 수정자에 대해 한마디 더 하겠습니다 이 수정자들은 인스펙터가 시트로 표현될 때 인스펙터와 함께 구성을 합니다 아까 본 제 인스펙터에서는 중간 디텐트에서 백그라운드 콘텐츠와의 상호 작용을 비활성화했는데 전에 썼던 코드를 그대로 다시 써서 높이 디텐트를 선언한 다음 동일한 디텐트까지만 백그라운드 상호 작용을 활성화할 수도 있죠 그러면 인스펙터는 중간 및 그보다 높은 디텐트에서 어두워집니다 이상 SwiftUI의 인스펙터였습니다 이 영상에서 저는 인스펙터 API를 소개하고 인스펙터를 사용할 때의 미묘한 차이를 설명했습니다 제가 가장 좋아하는 시트 프레젠테이션 수정자를 골라 시범도 보여드렸고 수정자가 인스펙터와 어떻게 구성을 하는지도 보여드렸습니다 뭘 망설이시나요? 가서 인스펙터를 사용하세요 앱 어딘가에 인스펙터를 넣으세요 프레젠테이션을 사용자화해 계속 나아가세요 과일을 갉아 먹은 범인요? 수수께끼를 풀려고 애썼지만 수확이 없었던 것 같네요 ♪ ♪
-
-
3:35 - Sample models and views
// Copy+Paste the below into an Xcode project to support building and running the session's code snippets import SwiftUI @main struct SwiftUIInspectors: App { var body: some Scene { WindowGroup { ContentView() .environmentObject(AnimalStore()) } } } struct AnimalInspectorForm: View { var animal: Binding<Animal>? @EnvironmentObject private var animalStore: AnimalStore var body: some View { Form { if let animal = animal { SelectedAnimalInspector(animal: animal, animalStore: animalStore) } else { ContentUnavailableView { Image(systemName: "magnifyingglass.circle") } description: { Text("Select a suspect to inspect") } actions: { Text("Fill out details from the interview") } } } #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif } } struct SelectedAnimalInspector: View { @Binding var animal: Animal @ObservedObject var animalStore: AnimalStore var body: some View { Section("Identity") { TextField("Name", text: $animal.name) Picker("Paw Size", selection: $animal.pawSize) { Text("Small").tag(PawSize.small) Text("Medium").tag(PawSize.medium) Text("Large").tag(PawSize.large) } FruitList(selectedFruits: $animal.favoriteFruits, fruits: allFruits) } Section { TextField(text: animalStore(\.alibi, for: animal), prompt: Text("What was \(animal.name) doing at the time of nibbling?"), axis: .vertical) { Text("Alibi") } .lineLimit(4, reservesSpace: true) if let schedule = Binding(animalStore(\.sleepSchedule, for: animal)) { SleepScheduleView(schedule: schedule) } else { Button("Add Sleep Schedule") { animalStore.write(\.sleepSchedule, value: Animal.Storage.newSleepSchedule, for: animal) } } Slider( value: animalStore(\.suspiciousLevel, for: animal), in: 0...1) { Text("Suspicion Level") } minimumValueLabel: { Image(systemName: "questionmark") } maximumValueLabel: { Image(systemName: "exclamationmark.3") } } header: { Text("Interview") } .presentationDetents([.medium, .large]) } } private struct FruitList: View { @Binding var selectedFruits: [Fruit] var fruits: [Fruit] var body: some View { Section("Favorite Fruits") { ForEach(allFruits) { fruit in Toggle(isOn: .init(get: { selectedFruits.contains(fruit) }, set: { newValue in if newValue && !selectedFruits.contains(fruit) { selectedFruits.append(fruit) } else { _ = selectedFruits.firstIndex(of: fruit).map { selectedFruits.remove(at: $0) } } })) { HStack { FruitImage(fruit: fruit, size: .init(width: 40, height: 40), bordered: true) Text(fruit.name).font(.body) } } } } } @ViewBuilder private func selectionBackground(isSelected: Bool) -> some View { if isSelected { RoundedRectangle(cornerRadius: 2).inset(by: -2) .fill(.selection) } } } private struct SleepScheduleView: View { @Binding var schedule: Animal.Storage.SleepSchedule var body: some View { DatePicker(selection: .init(get: { Calendar.current.date(from: schedule.sleepTime) ?? Date() }, set: { newDate in schedule.sleepTime = Calendar.current.dateComponents([.hour, .minute], from: newDate) }), displayedComponents: [.hourAndMinute]) { Text("Sleep at: ") } DatePicker(selection: .init(get: { Calendar.current.date(from: schedule.wakeTime) ?? Date() }, set: { newDate in schedule.wakeTime = Calendar.current.dateComponents([.hour, .minute], from: newDate) }), displayedComponents: [.hourAndMinute]) { Text("Awake at: ") } } } struct AppState { var selection: String? = "Snail" var animals: [Animal] = allAnimals var inspectorPresented: Bool = true var inspectorWidth: CGFloat = 270 var cornerRadius: CGFloat? = nil } extension Binding where Value == AppState { func binding() -> Binding<Animal>? { self.projectedValue.animals.first { $0.wrappedValue.id == self.selection.wrappedValue } } } extension Animal { struct Storage: Codable { var alibi: String = "" var sleepSchedule: SleepSchedule? = nil /// Value between 0 and 1 representing how suspicious the animal is. /// 1 is guilty. var suspiciousLevel: Double = 0.0 struct SleepSchedule: Codable { var sleepTime: DateComponents var wakeTime: DateComponents } static let newSleepSchedule: SleepSchedule = { // Asleep at 10:30, awake at 6:30 .init( sleepTime: DateComponents(hour: 22, minute: 30), wakeTime: DateComponents(hour: 6, minute: 30)) }() } } final class AnimalStore: ObservableObject { var storage: [Animal.ID: Animal.Storage] = [:] /// Getter for properties of an animal stored in self func callAsFunction<Result>(_ keyPath: WritableKeyPath<Animal.Storage, Result>, for animal: Animal) -> Binding<Result> { Binding { [self] in storage[animal.id, default: .init()][keyPath: keyPath] } set: { [self] newValue in self.objectWillChange.send() var animalStore = storage[animal.id, default: .init()] animalStore[keyPath: keyPath] = newValue storage[animal.id] = animalStore } } func write<Value>(_ keyPath: WritableKeyPath<Animal.Storage, Value>, value: Value, for animal: Animal) { objectWillChange.send() var animalStore = storage[animal.id, default: .init()] animalStore[keyPath: keyPath] = value storage[animal.id] = animalStore } func read<Value>(_ keyPath: WritableKeyPath<Animal.Storage, Value>, for animal: Animal) -> Value { storage[animal.id, default: .init()][keyPath: keyPath] } } struct AnimalTable: View { @Binding var state: AppState @EnvironmentObject private var animalStore: AnimalStore @Environment(\.horizontalSizeClass) private var sizeClass: UserInterfaceSizeClass? var fruitWidth: CGFloat { #if os(iOS) 40.0 #else 25.0 #endif } var body: some View { Table(state.animals, selection: $state.selection) { TableColumn("Name") { animal in HStack { Text(animal.emoji).font(.title) .padding(2) .background(.thickMaterial, in: RoundedRectangle(cornerRadius: 3)) Text(animal.name + " " + animal.species).font(.title3) } } TableColumn("Favorite Fruits") { animal in HStack { ForEach(animal.favoriteFruits.prefix(3)) { fruit in FruitImage(fruit: fruit, size: .init(width: fruitWidth, height: fruitWidth), scale: 2.0, bordered: state.selection == animal.id) } } .padding(3.5) } TableColumn("Suspicion Level") { animal in SuspicionTableCell(animal: animal) } } #if os(macOS) .alternatingRowBackgrounds(.disabled) #endif .tableStyle(.inset) } } private struct SuspicionTableCell: View { var animal: Animal @Environment(\.backgroundProminence) private var backgroundProminence @EnvironmentObject private var animalStore: AnimalStore var body: some View { let color = SuspiciousText.model(for: animalStore.read(\.suspiciousLevel, for: animal)).1 HStack { Image( systemName: "cellularbars", variableValue: animalStore.read(\.suspiciousLevel, for: animal) ) .symbolRenderingMode(.hierarchical) SuspiciousText( suspiciousLevel: animalStore.read(\.suspiciousLevel, for: animal), selected: backgroundProminence == .increased) } .foregroundStyle(backgroundProminence == .increased ? AnyShapeStyle(.white) : AnyShapeStyle(color)) } } private struct SuspiciousText: View { var suspiciousLevel: Double var selected: Bool static fileprivate func model(for level: Double) -> (String, Color) { switch level { case 0..<0.2: return ("Unlikely", .green) case 0.2..<0.5: return ("Fishy", .mint) case 0.5..<0.9: return ("Very suspicious", .orange) case 0.9...1: return ("Extremely suspicious!", .red) default: return ("Suspiciously Unsuspicious", .blue) } } var body: some View { let model = Self.model(for: suspiciousLevel) Text(model.0) .font(.callout) } } struct Animal: Identifiable { var name: String var species: String var pawSize: PawSize var favoriteFruits: [Fruit] var emoji: String var id: String { species } } var allAnimals: [Animal] = [ .init(name: "Fabrizio", species: "Fish", pawSize: .small, favoriteFruits: [.arbutusUnedo, .bigBerry, .elstar], emoji: "🐟"), .init(name: "Soloman", species: "Snail", pawSize: .small, favoriteFruits: [.elstar, .flavorKing], emoji: "🐌"), .init(name: "Ding", species: "Dove", pawSize: .small, favoriteFruits: [.quercusTomentella, .pinkPearlApple, .lapins], emoji: "🕊️"), .init(name: "Catie", species: "Crow", pawSize: .small, favoriteFruits: [.pinkPearlApple, .goldenNectar, .hauerPippin], emoji: "🐦⬛"), .init(name: "Miko", species: "Cat", pawSize: .small, favoriteFruits: [.belleDeBoskoop, .tompkinsKing, .lapins], emoji: "🐈"), .init(name: "Ricardo", species: "Rabbit", pawSize: .small, favoriteFruits: [.mariposa, .elephantHeart], emoji: "🐰"), .init(name: "Cornelius", species: "Duck", pawSize: .medium, favoriteFruits: [.greenGage, .goldenNectar], emoji: "🦆"), .init(name: "Maria", species: "Mouse", pawSize: .small, favoriteFruits: [.arbutusUnedo, .elephantHeart], emoji: "🐹"), .init(name: "Haku", species: "Hedgehog", pawSize: .small, favoriteFruits: [.christmasBerry, .creepingSnowberry, .goldenGem], emoji: "🦔"), .init(name: "Rénard", species: "Raccoon", pawSize: .medium, favoriteFruits: [.belleDeBoskoop, .bigBerry, .christmasBerry, .kakiFuyu], emoji: "🦝") ] enum PawSize: Hashable { case small case medium case large } struct Fruit: Identifiable, Hashable { var name: String var color: Color var id: String { name } } struct FruitImage: View { var fruit: Fruit var size: CGSize? = .init(width: 50, height: 50) var scale: CGFloat = 1.0 var bordered = false var body: some View { fruit.color // Actual assets replaced with Color .scaleEffect(scale) .scaledToFill() .frame(width: size?.width, height: size?.height) .mask { RoundedRectangle(cornerRadius: 4) } .overlay { if bordered { RoundedRectangle(cornerRadius: 4) .stroke(fruit.color, lineWidth: 2) } } } } extension Fruit { static let goldenGem = Fruit(name: "Golden Gem Apple", color: .yellow) static let flavorKing = Fruit(name: "Flavor King Plum", color: .purple) static let mariposa = Fruit(name: "Mariposa Plum", color: .red) static let tompkinsKing = Fruit(name: "Tompkins King Apple", color: .yellow) static let greenGage = Fruit(name: "Green Gage Plum", color: .green) static let lapins = Fruit(name: "Lapins Sweet Cherry", color: .purple) static let hauerPippin = Fruit(name: "Hauer Pippin Apple", color: .red) static let belleDeBoskoop = Fruit(name: "Belle De Boskoop Apple", color: .red) static let elstar = Fruit(name: "Elstar Apple", color: .yellow) static let goldenDeliciousApple = Fruit(name: "Golden Delicious Apple", color: .yellow) static let creepingSnowberry = Fruit(name: "Creeping Snowberry", color: .white) static let quercusTomentella = Fruit(name: "Channel Island Oak Acorn", color: .brown) static let elephantHeart = Fruit(name: "Elephant Heart Plum", color: .red) static let goldenNectar = Fruit(name: "Golden Nectar Plum", color: .yellow) static let pinkPearlApple = Fruit(name: "Pink Pearl Apple", color: .pink) static let christmasBerry = Fruit(name: "Christmas Berry", color: .red) static let kakiFuyu = Fruit(name: "Kaki Fuyu Persimmon", color: .orange) static let bigBerry = Fruit(name: "Big Berry Manzanita", color: .red) static let arbutusUnedo = Fruit(name: "Strawberry Tree", color: .red) } extension Array where Element == Fruit { var groupID: Fruit.ID { reduce("") { result, next in result.appending(next.id) } } } var allFruits: [Fruit] = [ .goldenGem, .flavorKing, .mariposa, .tompkinsKing, .greenGage, .lapins, .hauerPippin, .belleDeBoskoop, .elstar, .goldenDeliciousApple, .creepingSnowberry, .quercusTomentella, .elephantHeart, .goldenNectar, .kakiFuyu, .bigBerry, .arbutusUnedo, .pinkPearlApple, ]
-
3:54 - Xcode Previews
import SwiftUI #Preview("Meet Inspector", traits: .fixedLayout(width: 800, height: 500) ) { ContentView() .navigationTitle("SwiftUI Inspectors") .environmentObject(AnimalStore()) } public struct ContentView: View { @State private var state = AppState() @State private var presented = true public var body: some View { AnimalTable(state: $state) .inspector(isPresented: $presented) { AnimalInspectorForm(animal: $state.binding()) .inspectorColumnWidth( min: 200, ideal: 300, max: 400) .toolbar { Spacer() Button { presented.toggle() } label: { Label("Toggle Inspector", systemImage: "info.circle") } } } } } import MapKit struct FruitNibbleBulletin: View { var fruit: Fruit = .pinkPearlApple @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading) { Grid(horizontalSpacing: 12, verticalSpacing: 2) { GridRow { FruitImage(fruit: fruit, size: .init(width: 60, height: 60), bordered: false) Text(""" A \(fruit.name.lowercased()) was nibbled! The bite \ happened at 9:41 AM. The nibbler left behind only \ a few seeds. """ ) } GridRow { Text(""" The Fruit Inspectors were on \ the scene moments after it happened. \ Unfortunately, their efforts to catch the nibbler \ were fruitless. """).gridCellColumns(2) } } GroupBox("Clues") { LabeledContent("Paw Size") { Text("Large") } LabeledContent("Favorite Fruit") { Text("\(fruit.name.capitalized(with: .current))") } LabeledContent("Alibi") { Text("None") } } HStack { VStack { fruit.color .aspectRatio(contentMode: ContentMode.fit) .shadow(radius: 2.5) Text("The pink pearls left behind").font(.caption) .frame(alignment: .leading) } AppleParkMap() .mask(RoundedRectangle(cornerSize: CGSize(width: 20, height: 10))) } Text("The Fruit Inspection team was on the scene minutes after the incident. However, their attempts to discover any meaningful clues around the identity of the nibbler were fruitless.") } .scenePadding(.horizontal) .toolbar { ToolbarItem { Button(role: .cancel) { dismiss() } label: { Label("Close", systemImage: "xmark.circle.fill") } .symbolRenderingMode(.monochrome) .tint(.secondary) } } } #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .navigationTitle("Fruit Nibble Bulletin") } } } struct AppleParkMap: View { @State private var region = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: 37.334_371, longitude: -122.009_558), latitudinalMeters: 100, longitudinalMeters: 100 ) var body: some View { GeometryReader { geometry in Map(position: .constant(.automatic), bounds: .init(centerCoordinateBounds: region, minimumDistance: 100, maximumDistance: 100), interactionModes: [], scope: .none) { } } .frame(height: 180, alignment: .center) } }
-
7:14 - Inspector content inside a navigation structure
struct Example1: View { @State private var state = AppState() var body: some View { NavigationStack { AnimalTable(state: $state) .inspector(isPresented: $state.inspectorPresented) { AnimalInspectorForm(animal: $state.binding()) } .toolbar { Button { state.inspectorPresented.toggle() } label: { Label("Toggle Inspector", systemImage: "info.circle") } } } } }
-
7:55 - Inspector content outside a navigation structure
struct Example2: View { @State private var state = AppState() var body: some View { NavigationStack { AnimalTable(state: $state) } .inspector(isPresented: $state.inspectorPresented) { AnimalInspectorForm(animal: $state.binding()) .toolbar { ToolbarItem(placement: .principal) { HStack { Button { } label: { Image(systemName: "rectangle.and.pencil.and.ellipsis") } Button { } label: { Image(systemName: "pawprint.circle") } } } } } } }
-
8:56 - Inspector with NavigationSplitView: detail column
NavigationSplitView { Sidebar() } detail: { AnimalTable() .inspector(presented: $isPresented) { AnimalInspectorForm() } }
-
9:06 - Inspector with NavigationSplitView: Outside
NavigationSplitView { Sidebar() } detail: { AnimalTable() } .inspector(presented: $isPresented) { AnimalInspectorForm() }
-
9:49 - Presentation customizations
.sheet(item: $nibbledFruit) { fruit in FruitNibbleBulletin(fruit: fruit) .presentationBackground(.thinMaterial) .presentationDetents([.height(200), .medium, .large]) .presentationBackgroundInteraction(.enabled(upThrough: .height(200))) }
-
11:58 - Presentation customizations on Inspector
.inspector(presented: $state.inspectorPresented) { AnimalInspectorForm(animal: $state.binding()) .presentationDetents([.height(200), .medium, .large]) .presentationBackgroundInteraction(.enabled(upThrough: .height(200))) }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.