[Swift 입문] 18편 — 리스트·내비게이션·시트

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [17편] 상태 관리 2부

앱의 화면 구조 만들기

단일 화면만 있는 앱은 드뭅니다. 대부분의 앱은 목록 → 상세 화면으로 이동하거나, 버튼을 누르면 시트(하단에서 올라오는 화면)가 표시됩니다. 이번 편에서는 SwiftUI에서 이런 화면 전환을 구현하는 방법을 배웁니다.


List — 스크롤 가능한 목록

앞 편에서 List를 간단히 봤는데, 더 자세히 살펴봅니다.

기본 List

struct Note: Identifiable {
    let id = UUID()
    var title: String
    var body: String
    var date: Date
}

struct NoteListView: View {
    let notes: [Note]

    var body: some View {
        List(notes) { note in
            VStack(alignment: .leading, spacing: 4) {
                Text(note.title)
                    .font(.headline)
                Text(note.body)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    .lineLimit(2)
                Text(note.date, style: .date)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
            .padding(.vertical, 4)
        }
    }
}

삭제와 이동

struct EditableList: View {
    @State private var items = ["사과", "바나나", "오렌지", "포도", "딸기"]

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
            }
            .onDelete { offsets in
                items.remove(atOffsets: offsets)
            }
            .onMove { from, to in
                items.move(fromOffsets: from, toOffset: to)
            }
        }
        .toolbar {
            EditButton()  // 편집 모드 토글 버튼
        }
    }
}

EditButton()을 툴바에 넣으면 SwiftUI가 알아서 편집 모드를 관리합니다. 편집 모드에서는 삭제 버튼과 드래그 핸들이 자동으로 나타납니다.

섹션으로 그룹화

struct GroupedList: View {
    var body: some View {
        List {
            Section("과일") {
                Text("🍎 사과")
                Text("🍊 오렌지")
            }

            Section("채소") {
                Text("🥕 당근")
                Text("🥦 브로콜리")
            }
        }
        .listStyle(.insetGrouped)  // iOS 스타일
    }
}

NavigationStack — 화면 전환

NavigationStack은 스택 기반의 화면 전환을 담당합니다. 화면을 “쌓아 올리고(push)” 뒤로 가기로 “꺼내는(pop)” 방식입니다.

기본 내비게이션

struct BookListView: View {
    let books = [
        Book(title: "Swift 프로그래밍", author: "Apple", rating: 4),
        Book(title: "클린 코드", author: "Robert Martin", rating: 5),
        Book(title: "리팩터링", author: "Martin Fowler", rating: 5)
    ]

    var body: some View {
        NavigationStack {
            List(books) { book in
                NavigationLink(destination: BookDetailView(book: book)) {
                    HStack {
                        VStack(alignment: .leading) {
                            Text(book.title).font(.headline)
                            Text(book.author).font(.subheadline).foregroundColor(.secondary)
                        }
                        Spacer()
                        StarRating(rating: book.rating)
                    }
                }
            }
            .navigationTitle("내 서재")
        }
    }
}

struct BookDetailView: View {
    let book: Book

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(book.title)
                .font(.largeTitle)
                .bold()
            Text("저자: \(book.author)")
                .font(.title3)
            StarRating(rating: book.rating)
            Spacer()
        }
        .padding()
        .navigationTitle(book.title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

NavigationLink를 탭하면 destination에 지정한 뷰로 이동합니다. 뒤로 가기 버튼은 자동으로 생성됩니다.

프로그래밍 방식 내비게이션 (navigationDestination)

데이터 타입으로 목적지를 정의하는 더 현대적인 방식입니다.

struct BookListView: View {
    let books: [Book]
    @State private var path = NavigationPath()  // 내비게이션 스택 상태

    var body: some View {
        NavigationStack(path: $path) {
            List(books) { book in
                Button(book.title) {
                    path.append(book)  // 코드로 직접 이동
                }
            }
            .navigationTitle("서재")
            .navigationDestination(for: Book.self) { book in
                BookDetailView(book: book)
            }
        }
    }
}

navigationDestination을 사용하면 버튼 클릭, 알림 수신 등 다양한 트리거로 화면을 전환할 수 있습니다.


시트(Sheet)와 모달 표시

시트는 현재 화면 위에 새 화면을 표시하는 방식입니다. iOS에서 하단에서 올라오는 카드 형태가 대표적입니다.

기본 시트

struct MainView: View {
    @State private var showAddNote = false
    @State private var notes: [String] = []

    var body: some View {
        NavigationStack {
            List(notes, id: \.self) { note in
                Text(note)
            }
            .navigationTitle("메모")
            .toolbar {
                Button {
                    showAddNote = true
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
        .sheet(isPresented: $showAddNote) {
            AddNoteView { newNote in
                notes.append(newNote)
            }
        }
    }
}

struct AddNoteView: View {
    @State private var text = ""
    @Environment(\.dismiss) private var dismiss  // 시트 닫기
    var onSave: (String) -> Void

    var body: some View {
        NavigationStack {
            TextEditor(text: $text)
                .padding()
                .navigationTitle("새 메모")
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("취소") { dismiss() }
                    }
                    ToolbarItem(placement: .confirmationAction) {
                        Button("저장") {
                            onSave(text)
                            dismiss()
                        }
                        .disabled(text.isEmpty)
                    }
                }
        }
    }
}

@Environment(\.dismiss)는 현재 화면을 닫는 액션입니다. 시트 안에서 호출하면 시트가 닫히고, 풀스크린 커버 안에서 호출하면 커버가 닫힙니다.

다른 표시 방식

struct ModalExamples: View {
    @State private var showSheet = false
    @State private var showFullScreen = false
    @State private var showAlert = false
    @State private var showConfirmation = false

    var body: some View {
        VStack(spacing: 16) {
            // 하단 시트
            Button("시트 열기") { showSheet = true }
                .sheet(isPresented: $showSheet) {
                    Text("하단 시트").presentationDetents([.medium, .large])
                }

            // 풀스크린
            Button("전체 화면") { showFullScreen = true }
                .fullScreenCover(isPresented: $showFullScreen) {
                    VStack {
                        Text("전체 화면 커버")
                        Button("닫기") { showFullScreen = false }
                    }
                }

            // 경고창
            Button("알림") { showAlert = true }
                .alert("확인", isPresented: $showAlert) {
                    Button("확인") { }
                    Button("취소", role: .cancel) { }
                } message: {
                    Text("계속 진행하시겠습니까?")
                }

            // 확인 다이얼로그
            Button("삭제") { showConfirmation = true }
                .confirmationDialog("정말 삭제하시겠습니까?",
                                   isPresented: $showConfirmation) {
                    Button("삭제", role: .destructive) { }
                    Button("취소", role: .cancel) { }
                }
        }
    }
}

.presentationDetents로 시트가 절반(.medium)이나 전체(.large)로 열리도록 제어할 수 있습니다.


탭 바 (TabView)

여러 주요 섹션을 탭으로 전환하는 앱 구조입니다.

struct AppTabView: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("홈", systemImage: "house")
                }
                .tag(0)

            SearchView()
                .tabItem {
                    Label("검색", systemImage: "magnifyingglass")
                }
                .tag(1)

            ProfileView()
                .tabItem {
                    Label("프로필", systemImage: "person")
                }
                .tag(2)
        }
    }
}

실전 조합 — 노트 앱

@Observable
class NoteStore {
    var notes: [Note] = [
        Note(title: "첫 번째 메모", body: "SwiftUI를 배우고 있습니다.", date: .now),
        Note(title: "두 번째 메모", body: "내비게이션이 생각보다 쉽네요.", date: .now)
    ]

    func add(title: String, body: String) {
        notes.append(Note(title: title, body: body, date: .now))
    }

    func delete(at offsets: IndexSet) {
        notes.remove(atOffsets: offsets)
    }
}

struct NoteApp: View {
    @State private var store = NoteStore()
    @State private var showAdd = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(store.notes) { note in
                    NavigationLink(destination: NoteDetailView(note: note)) {
                        VStack(alignment: .leading) {
                            Text(note.title).font(.headline)
                            Text(note.body).font(.caption)
                                .foregroundColor(.secondary)
                                .lineLimit(1)
                        }
                    }
                }
                .onDelete(perform: store.delete)
            }
            .navigationTitle("메모장")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button { showAdd = true } label: {
                        Image(systemName: "square.and.pencil")
                    }
                }
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
            }
        }
        .sheet(isPresented: $showAdd) {
            AddNoteSheet { title, body in
                store.add(title: title, body: body)
            }
        }
        .environment(store)
    }
}

이 구조가 실제 앱의 기본 뼈대입니다: NavigationStack + List + NavigationLink로 목록/상세를 구성하고, sheet로 입력 화면을 표시합니다.


핵심 요약

  • List: 스크롤 가능한 목록. onDelete, onMove, Section으로 기능 확장
  • NavigationStack: 스택 기반 화면 전환. NavigationLink로 이동, navigationDestination으로 타입 기반 라우팅
  • sheet: 하단 카드 형태의 화면. isPresented Bool 바인딩으로 제어
  • fullScreenCover: 전체 화면 모달
  • @Environment(\.dismiss): 현재 화면(시트/커버) 닫기
  • TabView: 탭 바 기반 최상위 화면 구조

다음 편에서는 커스텀 뷰 컴포넌트를 만들고 애니메이션을 추가해 더 풍부한 UI를 만드는 방법을 배웁니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다