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과 화면 전환
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:
List와ForEach에 사용하는 데이터는Identifiable을 채택하여 고유 ID를 제공합니다.