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이나 옵셔널 값이 켜지면 뜨고, 꺼지면 닫힌다.
| UIKit | SwiftUI |
|---|---|
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)를 호출 |
sheet(item:) — 상태 하나로 "무엇을, 언제"
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)
}
}
}
}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)
}
}
}"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한다.
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
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이 붙은 뷰의 안쪽 뷰에서 읽어야 값이 들어온다.
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:))라서 코드로 탭을 바꾼다.
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)
}
}
}iOS 18은 Tab 타입을 추가했다(iOS 18.0+). Apple 문서는 tabItem 대신 Tab(title:image:value:content:) 계열을 쓰라고 안내한다. 검색 탭은 role: .search를 주면 시스템 검색 탭 모양을 얻는다(WWDC25 · What's new in SwiftUI). iOS 17을 지원하는 동안은 위 tabItem 코드를 쓴다.
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 같은 스택 모디파이어를 둘 수 있다. 스택은 루트(또는 탭·칼럼마다) 하나이고, 하위 화면은 모디파이어만 단다.
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"이 된다.
import SwiftUI
struct ShareItem: Identifiable {
var url: URL
var id: UUID { UUID() } // 매번 다른 값
}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:)을 쓰는 이유는?
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과 무엇이 다른가?
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() }요구사항
NavigationStack(path:)+NavigationLink(value:)+navigationDestination(for:)로 바꾼다.- 편집 시트를
sheet(item:)으로 바꾸고, 시트 안에dismiss로 닫는 "완료" 버튼을 둔다. - 루트 툴바에 "서연 바로가기" 버튼을 두어, 누르면 코드로 서연 상세 화면이 push되게 한다.
확인 방법: 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() }