← SwiftUI 14일 마스터 10 / 14 · 2부

Day 10 · 2부 · 모달·검색·탭

모달·검색·탭을 Bool과 옵셔널 상태로 켜고 끄며, dismiss가 작용하는 환경과 흔한 실수를 짚는다.
진행률
0 / 0 완료
1부에서 push를 path 배열로 표현했다. 2부는 나머지 화면 전환이다. UIKit의 present·UIAlertController·UISearchController·UITabBarController가 하던 일을 SwiftUI는 모두 Bool 또는 옵셔널 상태로 켜고 끈다. 상태 하나로 "무엇을, 언제" 띄울지 정하는 법과, dismiss가 어느 환경에 작용하는지가 핵심이다.

2부 목표 · 22분

  • sheet(isPresented:)+옵셔널 상태 조합을 sheet(item:)으로 고치고, dismiss를 올바른 뷰에서 호출한다.
  • 상황에 맞게 fullScreenCover·alert·confirmationDialog를 고르고 presenting:으로 대상 값을 넘긴다.
  • 탭마다 독립 NavigationStack을 두고 searchable로 검색을 붙인다.
  • 중첩 스택·불안정한 item id 같은 흔한 실수를 코드에서 찾아 고친다.

1. 모달 — sheet · fullScreenCover · alert · confirmationDialog

UIKit에서는 present(_:animated:)로 띄우고 dismiss(animated:)로 닫았다. SwiftUI에서는 "띄울지"도 상태다. Bool이나 옵셔널 값이 켜지면 뜨고, 꺼지면 닫힌다.

UIKitSwiftUI
present(vc, animated:) (pageSheet).sheet(item:) / .sheet(isPresented:)
modalPresentationStyle = .fullScreen.fullScreenCover(item:) / (isPresented:)
sheetPresentationController.detents.presentationDetents([.medium, .large]) (iOS 16+)
isModalInPresentation = true.interactiveDismissDisabled()
UIAlertController(.alert).alert(_:isPresented:presenting:actions:message:)
UIAlertController(.actionSheet).confirmationDialog(…)
dismiss(animated:)@Environment(\.dismiss)를 호출
무엇을 보여주나? 화면 깊이 들어감 NavigationLink path 배열 잠깐 딴 작업 sheet(item:) 옵셔널 값 끝낼 때까지 몰입 fullScreenCover Bool 또는 값 확인 · 오류 알림 alert Bool + presenting 행동 여럿 중 택1 confirmation… Bool + presenting 맨 아래 줄 = 그 도구를 켜고 끄는 상태의 모양
push는 "더 깊이", 모달은 "잠깐 옆길". 표시할 데이터가 있으면 Bool이 아니라 그 데이터(옵셔널)로 켠다.

sheet(item:) — 상태 하나로 "무엇을, 언제"

❌ Bool + 옵셔널 두 상태 — 둘이 어긋나면 빈 시트가 뜬다
import SwiftUI

struct Recipe: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct BadSheetList: View {
    let recipes: [Recipe]
    @State private var selected: Recipe?
    @State private var showDetail = false

    var body: some View {
        List(recipes) { recipe in
            Button(recipe.name) {
                selected = recipe
                showDetail = true
            }
        }
        .sheet(isPresented: $showDetail) {
            if let selected {                 // nil이면 빈 화면
                Text(selected.name)
            }
        }
    }
}
✅ sheet(item:) — 값이 있으면 뜨고 nil이면 닫힘 · detents로 절반 높이
import SwiftUI

struct Recipe: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct RecipeSheet: View {
    let recipe: Recipe
    @Environment(\.dismiss) private var dismiss      // 시트 안의 뷰에서 선언

    var body: some View {
        NavigationStack {
            Text(recipe.name)
                .toolbar {
                    Button("완료") { dismiss() }
                }
        }
    }
}

struct GoodSheetList: View {
    let recipes: [Recipe]
    @State private var selected: Recipe?

    var body: some View {
        List(recipes) { recipe in
            Button(recipe.name) { selected = recipe }
        }
        .sheet(item: $selected) { recipe in
            RecipeSheet(recipe: recipe)
                .presentationDetents([.medium, .large])
                .presentationDragIndicator(.visible)
        }
    }
}
📝 Apple 원문

"If item changes, the system dismisses the sheet and replaces it with a new one using the same process." — sheet(item:onDismiss:content:)

item이 다른 값으로 바뀌면 지금 시트를 닫고 새 시트로 바꾼다. 그래서 item의 id는 안정적이어야 한다(3절 실수 ②).

dismiss는 "닫힐 뷰" 안에서 꺼낸다

DismissAction은 자기가 선언된 환경에 작용한다. 시트를 띄운 부모에서 꺼내 시트 클로저 안에서 부르면 시트가 닫히지 않는다. push된 화면에서 부르면 스택에서 pop한다.

❌ 부모의 dismiss를 시트 안에서 호출 — 시트가 안 닫힌다 (Apple 문서 예시)
import SwiftUI

struct DetailView: View {
    @State private var isSheetPresented = false
    @Environment(\.dismiss) private var dismiss   // DetailView의 환경

    var body: some View {
        Button("시트 열기") { isSheetPresented = true }
            .sheet(isPresented: $isSheetPresented) {
                Button("완료") {
                    dismiss()                      // 시트가 아니라 DetailView에 작용
                }
            }
    }
}

✅ 고치는 법은 위 RecipeSheet처럼 시트 내용을 별도 뷰로 빼고, 그 뷰 안에서 @Environment(\.dismiss)를 선언하는 것이다.

alert · confirmationDialog · fullScreenCover

✅ presenting: 으로 "무엇에 대한" 질문인지 값을 함께 넘긴다
import SwiftUI

struct Recipe: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct DeletableList: View {
    @State private var recipes = [Recipe(id: 1, name: "파이"), Recipe(id: 2, name: "수프")]
    @State private var pending: Recipe?
    @State private var showConfirm = false
    @State private var showOnboarding = false

    var body: some View {
        List(recipes) { recipe in
            Text(recipe.name)
                .swipeActions {
                    Button("삭제", role: .destructive) {
                        pending = recipe
                        showConfirm = true
                    }
                }
        }
        .confirmationDialog("삭제할까요?", isPresented: $showConfirm,
                            titleVisibility: .visible, presenting: pending) { recipe in
            Button("\(recipe.name) 삭제", role: .destructive) {
                recipes.removeAll { $0.id == recipe.id }
            }
        } message: { _ in
            Text("되돌릴 수 없다.")
        }
        .toolbar {
            Button("안내") { showOnboarding = true }
        }
        .fullScreenCover(isPresented: $showOnboarding) {
            VStack(spacing: 20) {
                Text("환영합니다").font(.largeTitle)
                Button("시작") { showOnboarding = false }   // 닫는 경로를 버튼으로 직접 준다
            }
        }
    }
}
♿ 접근성 한 줄

시트는 아래로 끌어서 닫을 수 있지만, 모든 사람이 그 제스처를 쓰는 건 아니다. 시트 안에 "완료"·"닫기" 버튼을 둔다. Apple DismissAction 문서의 예시도 Done 버튼으로 닫는다. fullScreenCover에도 같은 이유로 닫기 버튼을 직접 둔다.

2. 검색과 탭

searchable — 검색창의 "설정"을 환경으로 내려보낸다

WWDC21 · Craft search experiences in SwiftUI: searchable은 검색 필드를 구성해서 환경으로 내려보낸다. 내비게이션 컨테이너가 그걸 받아 플랫폼에 맞는 검색 바로 그린다. UIKit의 UISearchController를 만들어 navigationItem.searchController에 꽂던 일이 모디파이어 하나로 끝난다.

✅ 검색 + 추천어 + 제출 이벤트
import SwiftUI

struct SearchableRecipes: View {
    @State private var query = ""
    private let all = ["김치찌개", "된장찌개", "불고기", "비빔밥", "잡채"]

    // 작은 배열이라 계산 프로퍼티로 충분하다. 큰 데이터면 모델에서 (Day 9)
    private var results: [String] {
        query.isEmpty ? all : all.filter { $0.contains(query) }
    }

    var body: some View {
        NavigationStack {
            List(results, id: \.self) { Text($0) }
                .navigationTitle("레시피")
        }
        .searchable(text: $query, prompt: "레시피 검색")
        .searchSuggestions {
            ForEach(["찌개", "볶음"], id: \.self) { word in
                Text(word).searchCompletion(word)   // 탭하면 검색어가 된다
            }
        }
        .onSubmit(of: .search) {
            print("검색 제출:", query)              // 키보드 검색 버튼 / 추천어 선택
        }
    }
}

isSearching 환경값으로 "검색 중인가"를 읽는다. 10176은 결과를 overlay로 덮으라고 권한다. 그래야 검색을 마치고 돌아왔을 때 원래 목록 상태(스크롤 위치 등)가 그대로다. isSearching은 searchable이 붙은 뷰의 안쪽 뷰에서 읽어야 값이 들어온다.

✅ 검색 중일 때만 결과를 overlay로 — 안쪽 뷰에서 isSearching 읽기
import SwiftUI

struct CityList: View {
    @Binding var query: String
    @Environment(\.isSearching) private var isSearching

    var body: some View {
        List(["서울", "부산", "제주"], id: \.self) { Text($0) }
            .overlay {
                if isSearching && !query.isEmpty {
                    List { Text("‘\(query)’ 검색 결과") }
                }
            }
    }
}

struct CityScreen: View {
    @State private var query = ""

    var body: some View {
        NavigationStack {
            CityList(query: $query)
                .navigationTitle("도시")
        }
        .searchable(text: $query)
    }
}

TabView — 탭마다 NavigationStack 하나씩

UITabBarController의 각 탭에 UINavigationController를 하나씩 넣던 구조와 같다. 탭 안에 스택을 두면 탭을 바꿔도 각 탭의 경로가 따로 유지된다. 탭 선택도 상태(TabView(selection:))라서 코드로 탭을 바꾼다.

TabView(selection: $tab) 홈 · NavigationStack path 2개 검색 · NavigationStack 루트만 설정 · NavigationStack path 1개 탭을 바꿔도 각 탭의 스택은 따로 유지된다
스택을 TabView 바깥에 하나 두지 말고, 탭마다 안쪽에 둔다. UITabBarController + 탭별 UINavigationController와 같은 모양이다.
✅ iOS 17 — 탭별 스택 + 코드로 탭 전환
import SwiftUI

enum AppTab: Hashable { case home, search, settings }

struct RootTabs: View {
    @State private var tab: AppTab = .home

    var body: some View {
        TabView(selection: $tab) {
            NavigationStack {
                Button("검색 탭으로") { tab = .search }
                    .navigationTitle("홈")
            }
            .tabItem { Label("홈", systemImage: "house") }
            .tag(AppTab.home)

            NavigationStack {
                Text("검색").navigationTitle("검색")
            }
            .tabItem { Label("검색", systemImage: "magnifyingglass") }
            .tag(AppTab.search)

            NavigationStack {
                Text("설정").navigationTitle("설정")
            }
            .tabItem { Label("설정", systemImage: "gear") }
            .tag(AppTab.settings)
        }
    }
}
🆕 최신 API iOS 18+

iOS 18은 Tab 타입을 추가했다(iOS 18.0+). Apple 문서는 tabItem 대신 Tab(title:image:value:content:) 계열을 쓰라고 안내한다. 검색 탭은 role: .search를 주면 시스템 검색 탭 모양을 얻는다(WWDC25 · What's new in SwiftUI). iOS 17을 지원하는 동안은 위 tabItem 코드를 쓴다.

iOS 18+ — Tab 타입과 search role
import SwiftUI

enum AppTab: Hashable { case home, search }

@available(iOS 18, *)
struct ModernTabs: View {
    @State private var tab: AppTab = .home

    var body: some View {
        TabView(selection: $tab) {
            Tab("홈", systemImage: "house", value: AppTab.home) {
                NavigationStack { Text("홈") }
            }
            Tab(value: AppTab.search, role: .search) {
                NavigationStack { Text("검색") }
            }
        }
    }
}

3. 흔한 실수 모음

① push된 화면 안에 NavigationStack을 또 만든다

push된 뷰는 이미 바깥 스택 안에 있다. 1부의 RecipeGrid(10054 예제)가 스택 안에 있기 때문에 그 안에 navigationDestination 같은 스택 모디파이어를 둘 수 있다. 스택은 루트(또는 탭·칼럼마다) 하나이고, 하위 화면은 모디파이어만 단다.

❌ 목적지 뷰가 자기 NavigationStack을 또 연다
import SwiftUI

struct OrderDetail: View {
    let id: Int
    var body: some View {
        NavigationStack {                         // 이미 바깥 스택 안이다
            Text("주문 \(id)")
                .navigationTitle("주문")
        }
    }
}
✅ 하위 화면은 모디파이어만 — 바깥 스택이 읽는다
import SwiftUI

struct OrderDetail: View {
    let id: Int
    var body: some View {
        Text("주문 \(id)")
            .navigationTitle("주문")
            .navigationBarTitleDisplayMode(.inline)
    }
}

② sheet(item:)의 id가 매번 새로 만들어진다

item이 바뀌면 시스템은 시트를 닫고 새로 띄운다(Apple 문서). 계산 프로퍼티에서 UUID()를 돌려주면 읽을 때마다 "다른 item"이 된다.

❌ 읽을 때마다 새 id — 같은 항목도 매번 다른 값으로 보인다
import SwiftUI

struct ShareItem: Identifiable {
    var url: URL
    var id: UUID { UUID() }                       // 매번 다른 값
}
✅ 저장된 안정 id — 내용에서 나온 값이나 생성 시 한 번 만든 값
import SwiftUI

struct ShareItem: Identifiable {
    var url: URL
    var id: URL { url }                           // 같은 항목 = 같은 id
}
🚨 한 번 더 확인할 것

· NavigationLink(value:)의 값 타입과 navigationDestination(for:)의 타입이 정확히 같아야 한다. 스택은 타입으로 링크와 목적지를 짝짓는다(NavigationStack 문서).
· 저장·복원할 path에는 모델 전체가 아니라 id를 넣는다(1부 2절의 상태 복원 박스).
· searchable은 컨테이너(스택)에, isSearching은 그 안쪽 뷰에서 읽는다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
sheet(isPresented:) + @State var selected: Item? 대신 sheet(item:)을 쓰는 이유는?
"떠 있는가"와 "무엇을 보여주나"를 상태 하나로 합치기 위해서다. 두 상태면 둘이 어긋날 때(Bool은 true, 값은 nil) 빈 시트가 뜬다. sheet(item:)은 값이 있으면 그 값을 클로저에 넘겨 띄우고, nil이 되면 닫는다.
쉽게 말하면 "택배 왔음" 쪽지와 택배 상자를 따로 두면 쪽지만 오고 상자가 없을 수 있다. 상자가 문 앞에 있으면 온 것이다.
@Environment(\.isSearching)를 .searchable을 붙인 바로 그 뷰에서 읽으면 왜 값이 안 바뀌나?
searchable은 검색 설정을 환경으로 내려보낸다(10176). 환경값은 모디파이어 아래쪽(안쪽) 뷰에만 전달되므로, isSearching은 searchable이 붙은 뷰의 안쪽 뷰(2절의 CityList)에서 읽어야 한다.
쉽게 말하면 물은 위에서 아래로 흐른다. 수도꼭지를 튼 사람 말고 아래층 사람이 물을 받는다.
🔮 코드 예측: 1절 ❌ DetailView에서 시트를 열고 "완료"를 누르면 어떻게 되나? (DetailView는 어떤 스택에도 push되지 않은 루트 뷰)
시트가 닫히지 않는다. dismiss는 선언된 환경(DetailView)에 작용하는데, DetailView는 표시된(presented) 뷰가 아니라서 아무 일도 없다. Apple 문서는 macOS·iPadOS에서 DetailView가 창의 루트면 창이 닫힐 수 있다고도 경고한다. 시트 내용을 별도 뷰로 빼고 그 안에서 dismiss를 선언해야 한다.
쉽게 말하면 옆 반 선생님께 "우리 반 끝내 주세요"라고 해도 우리 반은 안 끝난다. 우리 반 선생님께 말해야 한다.
🔁 1부 복습: NavigationLink(value:)는 들고 있는 값을 어떻게 화면으로 바꾸나? UIKit과 무엇이 다른가?
링크는 값만 path에 넣는다. 스택이 그 값의 타입과 같은 navigationDestination(for:)를 찾아 클로저로 뷰를 만든다. UIKit에서는 VC가 다음 VC를 직접 생성해 push했지만, SwiftUI에서는 "값→뷰" 규칙이 한 곳에 모이고 링크는 무엇을 보여줄지 모른다.
쉽게 말하면 링크는 주문서만 내고, 요리는 주방(navigationDestination)이 한다.

🛠 미니 과제 (선택 · 10분)

레거시 내비게이션을 새 API로 옮기고 딥링크 버튼을 단다. Preview 하나로 확인한다.

시작 코드
import SwiftUI

struct Contact: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct ContactsView: View {
    let contacts = [Contact(id: 1, name: "민지"), Contact(id: 2, name: "준호"), Contact(id: 3, name: "서연")]
    @State private var editing: Contact?
    @State private var showEditor = false

    var body: some View {
        NavigationView {
            List(contacts) { contact in
                NavigationLink(contact.name) {
                    Button("편집") { editing = contact; showEditor = true }
                        .navigationTitle(contact.name)
                }
            }
            .navigationTitle("연락처")
        }
        .sheet(isPresented: $showEditor) {
            if let editing { Text("\(editing.name) 편집") }
        }
    }
}

#Preview { ContactsView() }

요구사항

확인 방법: Preview에서 ① 행 탭 → 상세 push ② "편집" → 시트, "완료"로 닫힘 ③ 루트에서 "서연 바로가기" → 서연 상세가 바로 뜨고 뒤로 가면 목록.

정답 코드 보기
✅ 정답 예시
import SwiftUI

struct Contact: Identifiable, Hashable {
    let id: Int
    var name: String
}

struct ContactEditor: View {
    let contact: Contact
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            Text("\(contact.name) 편집")
                .toolbar { Button("완료") { dismiss() } }
        }
    }
}

struct ContactsView: View {
    let contacts = [Contact(id: 1, name: "민지"), Contact(id: 2, name: "준호"), Contact(id: 3, name: "서연")]
    @State private var path: [Contact] = []
    @State private var editing: Contact?

    var body: some View {
        NavigationStack(path: $path) {
            List(contacts) { contact in
                NavigationLink(contact.name, value: contact)
            }
            .navigationTitle("연락처")
            .navigationDestination(for: Contact.self) { contact in
                Button("편집") { editing = contact }
                    .navigationTitle(contact.name)
            }
            .toolbar {
                Button("서연 바로가기") {
                    if let seoyeon = contacts.first(where: { $0.id == 3 }) {
                        path = [seoyeon]
                    }
                }
            }
        }
        .sheet(item: $editing) { contact in
            ContactEditor(contact: contact)
        }
    }
}

#Preview { ContactsView() }

출처 · 참고자료