챌린지 1-2 · 8 Topics

🔄 버튼 누르면 화면이 바뀌게

버튼을 눌렀는데 텍스트가 안 바뀐다

@State @Binding 단방향 데이터 흐름 TextField · Toggle · Slider List + ForEach NavigationStack UserDefaults + Codable Property Wrappers
시작 전에 던져볼 질문
01
버튼을 눌렀는데 텍스트가 안 바뀐다면, SwiftUI에서 화면을 업데이트하려면 어떻게 해야 하는가?
검색 → SwiftUI @State property wrapper
02
부모 화면의 데이터를 자식 화면에서 바꾸려면 어떻게 전달하는가?
검색 → SwiftUI @Binding parent child
03
배열 데이터를 목록으로 보여주고, 스와이프로 삭제하려면 어떻게 하는가?
검색 → SwiftUI List ForEach onDelete
Topic 01

SwiftUI 렌더링 루프

상태 변화 → View 재생성 메커니즘
Swift
struct CounterView: View {
    @State private var count = 0

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

            Button("+ 1") {
                count += 1
            }
            .buttonStyle(.borderedProminent)
        }
    }
}
생각해보기
  • SwiftUI View는 struct인데, struct 안의 프로퍼티를 왜 직접 바꿀 수 없는가?
  • @State로 선언한 값이 View가 재생성되어도 사라지지 않는 이유는 무엇인가?
  • 버튼을 눌러 count를 바꾸면 SwiftUI 내부에서 어떤 일이 순서대로 일어나는가?
View는 struct — 불변이다
SwiftUI View는 값 타입. 상태가 바뀌면 새 View를 만든다. 기존 View를 수정하는 게 아님
왜 직접 var를 바꿔도 안 바뀌는가
struct 내부에서 자신의 프로퍼티를 바꾸려면 mutating이 필요. body는 mutating이 아님
State의 저장 위치
@State는 View 바깥의 SwiftUI 저장소에 저장. View가 재생성되어도 값 유지
Topic 02

@State · @Binding

소유와 공유의 차이
Swift
struct ParentView: View {
    @State private var name = ""

    var body: some View {
        VStack {
            Text("이름: \(name)")
            ChildView(name: $name)
        }
    }
}

struct ChildView: View {
    @Binding var name: String

    var body: some View {
        TextField("이름 입력", text: $name)
            .textFieldStyle(.roundedBorder)
    }
}
생각해보기
  • @State와 @Binding의 소유권 차이는 무엇인가? 누가 값을 "가지고" 있는가?
  • 부모의 @State를 자식에게 전달할 때 $ 기호는 무엇을 의미하는가?
  • 자식 View에서 @State를 직접 선언하면 안 되는 이유는?
@State — View가 소유
private으로 선언. 해당 View가 상태의 단독 소유자. 변경 시 자동 재렌더
@Binding — 참조를 전달
$state로 전달. 부모의 State를 자식이 읽고 쓸 수 있음. 값이 아닌 참조
단방향 데이터 흐름
상태는 위에서 아래로. View는 상태를 그릴 뿐, 상태를 소유하지 않는 것이 원칙
Property Wrapper란
@State, @Binding처럼 @ 기호가 붙는 것들. 프로퍼티에 특별한 동작을 추가하는 Swift 문법. SwiftUI는 이를 통해 값 변경을 감지하고 화면을 다시 그린다
Topic 03

TextField

텍스트 입력과 @State String 연결
Swift
struct InputView: View {
    @State private var email = ""

    var body: some View {
        VStack(alignment: .leading) {
            Text("이메일")
                .font(.caption)
                .foregroundStyle(.secondary)

            TextField("example@email.com",
                      text: $email)
                .textFieldStyle(.roundedBorder)
                .keyboardType(.emailAddress)
        }
        .padding()
    }
}
생각해보기
  • TextField의 text: 파라미터에 왜 $를 붙여야 하는가? 안 붙이면 어떻게 되는가?
  • 키보드가 올라올 때 화면이 가려지는 문제는 어떻게 해결하는가?
  • .onSubmit { } 은 언제 호출되는가?
TextField("placeholder", text: $name)
$name으로 Binding 전달. 입력 즉시 @State 업데이트
키보드 타입 · 완료 처리
.keyboardType(.numberPad), .onSubmit { }, .focused() 활용
Topic 04

Toggle · Slider · Picker

다양한 입력 컴포넌트
Swift
struct SettingsView: View {
    @State private var isDark = false
    @State private var fontSize: Double = 16

    var body: some View {
        Form {
            Toggle("다크 모드", isOn: $isDark)

            VStack(alignment: .leading) {
                Text("폰트 크기: \(Int(fontSize))")
                Slider(value: $fontSize,
                       in: 12...24, step: 1)
            }
        }
    }
}
생각해보기
  • Toggle에 바인딩하는 @State 변수의 타입은 반드시 무엇이어야 하는가?
  • Slider의 step: 파라미터를 생략하면 어떻게 동작하는가?
  • Picker의 .pickerStyle()을 바꾸면 모양이 어떻게 달라지는가?
Toggle("이름", isOn: $bool)
Bool @State에 바인딩. 설정 화면의 기본 패턴
Slider(value: $val, in: 0...100)
범위 내 실수 값 선택. step: 으로 스텝 설정
Picker
.pickerStyle(.segmented) / .menu / .wheel. ForEach로 옵션 생성
Topic 05

List + ForEach + Identifiable

배열을 목록으로 렌더링
Swift
struct Fruit: Identifiable {
    let id = UUID()
    let name: String
    let emoji: String
}

struct FruitList: View {
    @State private var fruits = [
        Fruit(name: "사과", emoji: "🍎"),
        Fruit(name: "바나나", emoji: "🍌"),
        Fruit(name: "포도", emoji: "🍇"),
    ]

    var body: some View {
        List {
            ForEach(fruits) { fruit in
                HStack {
                    Text(fruit.emoji)
                    Text(fruit.name)
                }
            }
            .onDelete { fruits.remove(atOffsets: $0) }
        }
    }
}
생각해보기
  • ForEach에 전달하는 데이터가 Identifiable을 채택해야 하는 이유는 무엇인가?
  • id: \.self는 언제 쓰고, Identifiable은 언제 쓰는가?
  • List와 ScrollView + ForEach의 차이는 무엇인가?
  • .onDelete(perform:)에서 받는 IndexSet은 무엇인가?
List { ForEach(items) }
배열의 각 요소를 셀로 렌더링. UITableView를 SwiftUI로 대체하는 방법
Identifiable 프로토콜
id 프로퍼티 필수. SwiftUI가 각 항목을 구별해 최소한만 재렌더하기 위해
onDelete(perform:)
스와이프 삭제. IndexSet으로 제거할 항목 인덱스 전달
Topic 06

NavigationStack · Sheet · Alert

화면 이동과 모달
Swift
struct HomeView: View {
    @State private var showSheet = false

    var body: some View {
        NavigationStack {
            List {
                NavigationLink("상세 보기") {
                    Text("Detail View")
                }
            }
            .navigationTitle("홈")
            .toolbar {
                Button("추가") {
                    showSheet = true
                }
            }
            .sheet(isPresented: $showSheet) {
                Text("Sheet Content")
            }
        }
    }
}
생각해보기
  • NavigationLink와 .sheet()의 화면 전환 방식은 어떻게 다른가?
  • .sheet(isPresented:)에서 isPresented를 @State Bool로 관리하는 이유는?
  • NavigationStack과 (deprecated된) NavigationView의 차이는 무엇인가?
NavigationStack + NavigationLink
스택 기반 화면 이동. .navigationTitle(), .navigationBarItems()
.sheet(isPresented: $show)
Bool @State로 모달 표시/숨김. 아래에서 올라오는 시트
.alert("제목", isPresented: $show)
확인/취소 다이얼로그. 삭제 확인 UX 패턴
Topic 07

데이터 모델 설계

Todo struct — id · title · isCompleted
Swift
struct Todo: Identifiable, Codable {
    let id: UUID
    var title: String
    var isCompleted: Bool

    init(title: String) {
        self.id = UUID()
        self.title = title
        self.isCompleted = false
    }
}
생각해보기
  • Todo struct에 Identifiable을 채택하면 어떤 장점이 있는가?
  • Codable을 채택하면 자동으로 얻는 기능은 무엇인가?
  • isCompleted를 토글하려면 배열의 요소에 어떻게 접근해야 하는가?
Todo struct
struct Todo: Identifiable, Codable { let id: UUID; var title: String; var isCompleted: Bool }
@State [Todo]
추가: append(), 삭제: remove(atOffsets:), 완료 토글: 인덱스로 접근해 toggle()
Topic 08

UserDefaults + Codable 영속성

재시작 후에도 데이터 유지
Swift
// 저장
func save(_ todos: [Todo]) {
    if let data = try? JSONEncoder()
        .encode(todos) {
        UserDefaults.standard
            .set(data, forKey: "todos")
    }
}

// 불러오기
func load() -> [Todo] {
    guard let data = UserDefaults.standard
        .data(forKey: "todos"),
        let todos = try? JSONDecoder()
            .decode([Todo].self, from: data)
    else { return [] }
    return todos
}
생각해보기
  • UserDefaults에 직접 [Todo]를 저장할 수 없는 이유는 무엇인가?
  • JSONEncoder와 JSONDecoder는 각각 어떤 변환을 하는가?
  • 앱 재시작 후에도 데이터가 유지되려면 저장 시점을 언제로 잡아야 하는가?
JSONEncoder / JSONDecoder
Codable 타입을 Data로 변환 후 UserDefaults에 저장. 반대로 불러오기
.onAppear / .onChange
View 나타날 때 로드, 상태 변경될 때 저장 — 자동 저장 패턴
Empty State
items.isEmpty일 때 안내 뷰 표시. ContentUnavailableView (iOS 17+)
Preview
0
+ 1
Preview
이름:
이름 입력
Preview
이메일
example@email.com
Preview
다크 모드
폰트 크기: 16
Preview
🍎 사과
🍌 바나나
🍇 포도
Preview
추가
상세 보기
Preview
장보기
SwiftUI 공부
운동하기
Preview
장보기
SwiftUI 공부
앱 재시작 후에도 유지됨
Homework

챌린지 1-2를 마쳤다면

필수
카운터 앱 만들기
+1, -1, 리셋 버튼이 있는 카운터. @State로 숫자 관리. 0 미만이면 텍스트 색상을 빨간색으로 변경
필수
Todo 앱 완성
TextField로 입력 → List에 추가 → 스와이프 삭제 → 탭하면 완료 토글. UserDefaults로 앱 재시작 후에도 데이터 유지
도전
설정 화면 만들기
Toggle, Slider, Picker를 조합한 설정 화면. 다크모드 토글, 폰트 크기 슬라이더, 언어 선택 Picker. 부모-자식 간 @Binding으로 데이터 전달
완료 체크리스트
@State 없이 var만 쓰면 왜 안 되는지 설명할 수 있다
@State vs @Binding의 소유권 차이를 안다
List + ForEach + Identifiable 조합을 쓸 수 있다
UserDefaults + Codable로 데이터를 저장/불러올 수 있다
Solution — 답안 보기
1. 카운터 앱
Swift
struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack(spacing: 24) {
            Text("\(count)")
                .font(.system(size: 72, weight: .bold))
                .foregroundStyle(count < 0 ? .red : .primary)

            HStack(spacing: 16) {
                Button("-1") { count -= 1 }
                    .buttonStyle(.bordered)

                Button("리셋") { count = 0 }
                    .buttonStyle(.bordered)
                    .tint(.gray)

                Button("+1") { count += 1 }
                    .buttonStyle(.borderedProminent)
            }
        }
    }
}
2. Todo 앱
Swift
struct Todo: Identifiable, Codable {
    let id: UUID
    var title: String
    var isCompleted: Bool

    init(title: String) {
        self.id = UUID()
        self.title = title
        self.isCompleted = false
    }
}

struct TodoListView: View {
    @State private var todos: [Todo] = []
    @State private var newTitle = ""

    var body: some View {
        NavigationStack {
            List {
                ForEach($todos) { $todo in
                    HStack {
                        Image(systemName: todo.isCompleted
                            ? "checkmark.circle.fill"
                            : "circle")
                            .foregroundStyle(
                                todo.isCompleted
                                    ? .blue : .gray)
                            .onTapGesture {
                                todo.isCompleted.toggle()
                                save()
                            }
                        Text(todo.title)
                            .strikethrough(
                                todo.isCompleted)
                    }
                }
                .onDelete { offsets in
                    todos.remove(atOffsets: offsets)
                    save()
                }
            }
            .navigationTitle("할 일")
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    HStack {
                        TextField("새 할 일",
                            text: $newTitle)
                        Button("추가") {
                            guard !newTitle.isEmpty
                                else { return }
                            todos.append(
                                Todo(title: newTitle))
                            newTitle = ""
                            save()
                        }
                    }
                }
            }
            .onAppear { load() }
        }
    }

    func save() {
        if let data = try? JSONEncoder()
            .encode(todos) {
            UserDefaults.standard
                .set(data, forKey: "todos")
        }
    }

    func load() {
        guard let data = UserDefaults.standard
            .data(forKey: "todos"),
            let saved = try? JSONDecoder()
                .decode([Todo].self, from: data)
        else { return }
        todos = saved
    }
}
3. 설정 화면
Swift
struct SettingsView: View {
    @State private var isDarkMode = false
    @State private var fontSize: Double = 16
    @State private var language = "한국어"
    let languages = ["한국어", "English", "日本語"]

    var body: some View {
        NavigationStack {
            Form {
                Section("화면") {
                    Toggle("다크 모드",
                        isOn: $isDarkMode)
                    VStack(alignment: .leading) {
                        Text("폰트 크기: \(Int(fontSize))")
                        Slider(value: $fontSize,
                            in: 12...28, step: 1)
                    }
                }
                Section("언어") {
                    Picker("언어 선택",
                        selection: $language) {
                        ForEach(languages, id: \.self) {
                            Text($0)
                        }
                    }
                }
                Section {
                    PreviewCard(
                        isDarkMode: $isDarkMode,
                        fontSize: $fontSize)
                }
            }
            .navigationTitle("설정")
        }
    }
}

struct PreviewCard: View {
    @Binding var isDarkMode: Bool
    @Binding var fontSize: Double

    var body: some View {
        Text("미리보기 텍스트")
            .font(.system(size: fontSize))
            .padding()
            .frame(maxWidth: .infinity)
            .background(isDarkMode ? .black : .white)
            .foregroundStyle(isDarkMode ? .white : .black)
            .cornerRadius(12)
    }
}
← 이전 챌린지 1-1 화면에 뭔가 띄우기
코멘트
후원하기
콘텐츠가 도움이 됐다면
커피 한 잔의 후원을 부탁드려요 :)
카카오페이 QR
이현호
카카오페이
카카오톡 QR
막히면 여기서 질문하세요 💬
카카오톡 오픈채팅 · 무료 · 링크로 참여