SwiftUI 시작하기 — 선언적 UI와 상태 관리

SwiftUI란?

SwiftUI는 Apple이 2019년에 발표한 선언적 UI 프레임워크입니다. UIKit의 명령형 방식 대신, UI의 상태와 뷰를 선언하면 프레임워크가 자동으로 화면을 갱신합니다. React나 Flutter와 유사한 패러다임으로, 코드가 간결하고 실시간 프리뷰를 지원합니다.

이 글에서는 SwiftUI의 기본 뷰 구성, 상태 관리, 리스트, 내비게이션을 실전 예제로 정리합니다.

기본 뷰와 레이아웃

SwiftUI의 모든 UI 요소는 View 프로토콜을 채택하는 구조체입니다. VStack, HStack, ZStack으로 뷰를 배치합니다.

import SwiftUI

// 기본 뷰 구성 — @main으로 앱 진입점 정의
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack(spacing: 16) {
            // 텍스트
            Text("SwiftUI 시작하기")
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(.blue)

            // 이미지와 텍스트를 수평 배치
            HStack(spacing: 12) {
                Image(systemName: "swift")
                    .font(.system(size: 40))
                    .foregroundColor(.orange)
                VStack(alignment: .leading) {
                    Text("Swift 프로그래밍")
                        .font(.headline)
                    Text("선언적 UI 프레임워크")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
            }

            // 버튼
            Button(action: {
                print("버튼 클릭!")
            }) {
                Text("시작하기")
                    .font(.headline)
                    .foregroundColor(.white)
                    .padding(.horizontal, 40)
                    .padding(.vertical, 12)
                    .background(Color.blue)
                    .cornerRadius(10)
            }
        }
        .padding()
    }
}

SwiftUI의 뷰는 구조체이므로 가볍고 효율적입니다. 뷰를 재사용할 때는 별도의 구조체로 분리합니다.

@State와 @Binding — 상태 관리

@State는 뷰 내부의 로컬 상태를 관리합니다. 상태가 변경되면 SwiftUI가 자동으로 뷰를 다시 렌더링합니다. @Binding은 부모 뷰의 상태를 자식 뷰에 전달할 때 사용합니다.

import SwiftUI

// 카운터 예제 — @State로 상태 관리
struct CounterView: View {
    @State private var count = 0
    @State private var isShowingDetail = false

    var body: some View {
        VStack(spacing: 20) {
            Text("카운트: \(count)")
                .font(.system(size: 48, weight: .bold))

            HStack(spacing: 20) {
                // 감소 버튼
                Button("- 감소") {
                    if count > 0 { count -= 1 }
                }
                .buttonStyle(.bordered)

                // 증가 버튼
                Button("+ 증가") {
                    count += 1
                }
                .buttonStyle(.borderedProminent)
            }

            // 리셋 버튼 — @Binding으로 자식에게 상태 전달
            ResetButton(count: $count)

            // 토글
            Toggle("상세 보기", isOn: $isShowingDetail)
                .padding(.horizontal)

            if isShowingDetail {
                Text("현재 값의 제곱: \(count * count)")
                    .font(.headline)
                    .foregroundColor(.purple)
                    .transition(.slide)
            }
        }
        .padding()
        .animation(.easeInOut, value: isShowingDetail)
    }
}

// 자식 뷰 — @Binding으로 부모의 상태 수정
struct ResetButton: View {
    @Binding var count: Int

    var body: some View {
        Button("리셋") {
            withAnimation {
                count = 0
            }
        }
        .foregroundColor(.red)
        .disabled(count == 0)  // 0이면 비활성화
    }
}

@State는 뷰 내부에서만 사용하는 단순한 값에 적합합니다. 여러 뷰에서 공유하는 상태는 @ObservedObject@EnvironmentObject를 사용합니다.

List와 ForEach — 동적 리스트

List는 스크롤 가능한 목록을 생성합니다. Identifiable 프로토콜을 채택한 데이터와 함께 사용하면 효율적인 업데이트가 가능합니다.

import SwiftUI

// 데이터 모델 — Identifiable 채택
struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isCompleted: Bool
}

struct TodoListView: View {
    @State private var todos = [
        TodoItem(title: "Swift 공부하기", isCompleted: false),
        TodoItem(title: "SwiftUI 튜토리얼", isCompleted: true),
        TodoItem(title: "프로젝트 설계", isCompleted: false),
        TodoItem(title: "테스트 작성", isCompleted: false),
    ]
    @State private var newTodoTitle = ""

    var body: some View {
        NavigationStack {
            VStack {
                // 입력 필드
                HStack {
                    TextField("새 할 일", text: $newTodoTitle)
                        .textFieldStyle(.roundedBorder)
                    Button("추가") {
                        addTodo()
                    }
                    .disabled(newTodoTitle.isEmpty)
                }
                .padding(.horizontal)

                // 할 일 목록
                List {
                    // 진행 중 섹션
                    Section("진행 중 (\(pendingCount))") {
                        ForEach($todos) { $todo in
                            if !todo.isCompleted {
                                TodoRow(todo: $todo)
                            }
                        }
                    }

                    // 완료 섹션
                    Section("완료") {
                        ForEach($todos) { $todo in
                            if todo.isCompleted {
                                TodoRow(todo: $todo)
                            }
                        }
                        .onDelete(perform: deleteCompleted)
                    }
                }
            }
            .navigationTitle("할 일 목록")
        }
    }

    private var pendingCount: Int {
        todos.filter { !$0.isCompleted }.count
    }

    private func addTodo() {
        let todo = TodoItem(title: newTodoTitle, isCompleted: false)
        todos.append(todo)
        newTodoTitle = ""
    }

    private func deleteCompleted(at offsets: IndexSet) {
        let completedTodos = todos.filter { $0.isCompleted }
        for index in offsets {
            if let todoIndex = todos.firstIndex(where: { $0.id == completedTodos[index].id }) {
                todos.remove(at: todoIndex)
            }
        }
    }
}

struct TodoRow: View {
    @Binding var todo: TodoItem

    var body: some View {
        HStack {
            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                .foregroundColor(todo.isCompleted ? .green : .gray)
                .onTapGesture {
                    todo.isCompleted.toggle()
                }
            Text(todo.title)
                .strikethrough(todo.isCompleted)
                .foregroundColor(todo.isCompleted ? .secondary : .primary)
        }
    }
}

NavigationStack은 화면 간 이동을 관리합니다. NavigationLink로 대상 뷰를 연결합니다.

import SwiftUI

struct MenuItem: Identifiable {
    let id = UUID()
    let title: String
    let icon: String
    let description: String
}

struct MainMenuView: View {
    let menuItems = [
        MenuItem(title: "프로필", icon: "person.circle", description: "사용자 정보를 확인합니다"),
        MenuItem(title: "설정", icon: "gear", description: "앱 설정을 변경합니다"),
        MenuItem(title: "알림", icon: "bell", description: "알림 내역을 확인합니다"),
        MenuItem(title: "도움말", icon: "questionmark.circle", description: "자주 묻는 질문을 확인합니다"),
    ]

    var body: some View {
        NavigationStack {
            List(menuItems) { item in
                NavigationLink(value: item.title) {
                    HStack(spacing: 12) {
                        Image(systemName: item.icon)
                            .font(.title2)
                            .foregroundColor(.blue)
                            .frame(width: 36)
                        VStack(alignment: .leading) {
                            Text(item.title)
                                .font(.headline)
                            Text(item.description)
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                    .padding(.vertical, 4)
                }
            }
            .navigationTitle("메뉴")
            .navigationDestination(for: String.self) { title in
                DetailView(title: title)
            }
        }
    }
}

struct DetailView: View {
    let title: String

    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "doc.text")
                .font(.system(size: 60))
                .foregroundColor(.blue)
            Text("\(title) 상세 화면")
                .font(.title)
            Text("이 화면은 \(title) 메뉴의 상세 내용을 표시합니다.")
                .multilineTextAlignment(.center)
                .foregroundColor(.secondary)
                .padding(.horizontal)
        }
        .navigationTitle(title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

실전 팁

  • @State는 뷰 내부에만: 단순한 로컬 상태에 사용합니다. 복잡한 상태는 @Observable 클래스(iOS 17+)를 사용합니다.
  • 뷰 분리: 100줄이 넘는 뷰는 하위 뷰로 분리합니다. 각 뷰가 하나의 역할만 담당하도록 합니다.
  • Preview 활용: #Preview 매크로(iOS 17+)로 다양한 상태의 프리뷰를 작성하여 실시간으로 확인합니다.
  • $로 Binding 생성: @State 프로퍼티 앞에 $를 붙이면 Binding이 생성됩니다. 자식 뷰에 전달할 때 사용합니다.
  • withAnimation: 상태 변경을 withAnimation 블록에 감싸면 자동으로 애니메이션이 적용됩니다.
  • Identifiable: ListForEach에 사용하는 데이터는 Identifiable을 채택하여 고유 ID를 제공합니다.

이 글이 도움이 되었나요?