UITextFieldDelegate로 Return 키를 잡고, becomeFirstResponder()로 다음 칸으로 넘기고, resignFirstResponder()로 키보드를 내리고, editingChanged 타깃으로 값을 모델에 복사했다. SwiftUI에서는 이 일들이 전부 상태 두 개로 줄어든다. 입력값은 Binding으로, 포커스는 @FocusState로 들고 있으면 된다. 1부는 첫 번째 상태, 입력값이다. 텍스트 입력 3종과 format: 바인딩, 그리고 Form과 선택 컨트롤로 설정 화면을 조립한다.1부 목표 · 17분
- 문자열·숫자·비밀번호·긴 글 입력에 맞는 컨트롤(
TextField/SecureField/TextEditor)을 고른다. format:필드의 값이 언제 바뀌는지 코드를 보고 예측하고, 실시간 값이 필요하면 String을 진실로 둔다.Form·Section·Picker·Toggle·Stepper·DatePicker로 설정 화면을 조립한다.Picker의 tag 타입 불일치 버그를 찾아 고친다.
1. 텍스트 입력 3종과 format 바인딩 🔥 핵심
SwiftUI 입력 컨트롤은 값을 소유하지 않는다. 모두 Binding을 받아서, 사용자가 고친 값을 그 바인딩에 써 넣을 뿐이다(Day 3의 "진실은 한 곳"). 그래서 editingChanged 타깃이나 델리게이트로 값을 복사하는 코드가 사라진다.
| 하고 싶은 일 | UIKit | SwiftUI |
|---|---|---|
| 한 줄 텍스트 | UITextField + editingChanged | TextField("…", text: $value) |
| 비밀번호 | isSecureTextEntry = true | SecureField("…", text: $value) |
| 여러 줄 긴 글 | UITextView | TextEditor(text: $value) |
| 늘어나는 입력칸 | UITextView + 높이 제약 계산 | TextField(…, axis: .vertical) + lineLimit(1...4) |
| 숫자·통화 입력 | NumberFormatter로 직접 파싱 | TextField(…, value: $n, format: .number) |
| 키보드 종류 · 자동완성 | keyboardType · textContentType | 같은 이름의 모디파이어 .keyboardType(_:) · .textContentType(_:) |
struct ProfileFields: View {
@State private var nickname = ""
@State private var password = ""
@State private var memo = ""
@State private var bio = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("닉네임", text: $nickname)
.textInputAutocapitalization(.never) // 첫 글자 대문자 끄기
.autocorrectionDisabled() // 자동 수정 끄기
SecureField("비밀번호", text: $password)
.textContentType(.password) // 암호 자동 완성 연결
TextField("한 줄 소개", text: $memo, axis: .vertical)
.lineLimit(1...4) // 1줄에서 시작해 4줄까지 늘어남
TextEditor(text: $bio)
.frame(minHeight: 120)
.border(.secondary)
}
.textFieldStyle(.roundedBorder)
.padding()
}
}SecureField 문서에 따르면 이 필드는 글자마다 점을 보여 주고, iOS에서 스크린숏을 찍으면 점도 숨기고, 잘라내기·복사를 막고, Caps Lock 표시를 띄운다. TextField에 모디파이어를 붙여 흉내 내지 말고 SecureField를 쓴다.
format: 필드는 "확정할 때" 값이 바뀐다
TextField에 String이 아닌 값(Int, Decimal, Date…)을 묶으려면 value:format: 이니셜라이저를 쓴다. 입력 문자열을 FormatStyle이 파싱해서 바인딩 타입으로 바꿔 준다. 여기서 갱신 시점이 달라진다.
"If the value is a string, the text field updates this value continuously as the user types or otherwise edits the text in the field. For non-string types, it updates the value when the user commits their edits, such as by pressing the Return key." — Apple 문서 · TextField
풀이: String 바인딩은 한 글자마다 갱신된다. 숫자 같은 다른 타입은 Return 키 등으로 편집을 확정할 때 한 번 갱신된다.
struct LiveCountWrong: View {
@State private var quantity = 0
var body: some View {
VStack {
TextField("수량", value: $quantity, format: .number)
.keyboardType(.numberPad) // 숫자 패드에는 Return 키도 없다
Text("주문 \(quantity)개") // 확정 전까지 갱신되지 않음
}
.padding()
}
}struct LiveCount: View {
@State private var quantityText = ""
private var quantity: Int? { Int(quantityText) } // 계산 프로퍼티 = 두 번째 진실이 아님
var body: some View {
VStack {
TextField("수량", text: $quantityText)
.keyboardType(.numberPad)
if let quantity {
Text("주문 \(quantity)개")
} else {
Text("숫자를 입력하세요").foregroundStyle(.secondary)
}
}
.padding()
}
}struct PriceForm: View {
@State private var quantity = 1
@State private var price: Decimal = 0
var body: some View {
Form {
TextField("수량", value: $quantity, format: .number)
TextField("단가", value: $price, format: .currency(code: "KRW"))
.keyboardType(.decimalPad)
Text("합계 \(price * Decimal(quantity), format: .currency(code: "KRW"))")
}
}
}2. Form과 선택 컨트롤 — 설정 화면 조립
폼과 컨트롤은 전용 WWDC 세션이 없다. 이 섹션의 설명과 예시는 Apple 문서(Form, Picker, DatePicker, LabeledContent)를 기준으로 한다.
Form은 레이아웃 컨테이너가 아니라 "입력용 묶음"이라는 의미를 선언한다. Form 문서는 "forms appear as grouped lists on iOS, and as aligned vertical stacks on macOS"라고 적는다. 같은 코드가 iOS에서는 그룹 리스트(UIKit의 .insetGrouped 테이블)로, macOS에서는 정렬된 세로 스택으로 그려진다. 버튼·토글·피커도 폼 안에서는 폼 전용 모양으로 바뀐다.
enum NotifyScope: String, CaseIterable, Identifiable {
case all = "모든 알림"
case mentions = "멘션만"
case off = "끄기"
var id: Self { self } // id 타입 == selection 타입 (아래 함정 참고)
}
struct SettingsForm: View {
@State private var scope: NotifyScope = .mentions
@State private var sound = true
@State private var fontSize = 16
@State private var quietStart = Date()
var body: some View {
NavigationStack {
Form {
Section {
Picker("알림 받기", selection: $scope) {
ForEach(NotifyScope.allCases) { scope in
Text(scope.rawValue) // ForEach가 id로 tag를 자동 부여
}
}
Toggle("소리", isOn: $sound)
} header: {
Text("알림")
} footer: {
Text("멘션만 선택하면 나를 부른 메시지만 알린다.")
}
Section("표시") {
Stepper("글자 크기 \(fontSize)pt", value: $fontSize, in: 12...24, step: 2)
DatePicker("방해 금지 시작", selection: $quietStart,
displayedComponents: .hourAndMinute)
LabeledContent("버전", value: "2.4.1") // 읽기 전용 행
}
}
.navigationTitle("설정")
}
}
}| UIKit | SwiftUI | 바인딩 타입 |
|---|---|---|
정적 셀 UITableView (.insetGrouped) | Form + Section | — |
UISwitch | Toggle | Binding<Bool> |
UIStepper + 라벨 | Stepper(_:value:in:step:) | Binding<Int> 등 Strideable |
UIDatePicker | DatePicker(_:selection:displayedComponents:) | Binding<Date> |
UISegmentedControl / 메뉴 버튼 / 선택 화면 push | Picker + .pickerStyle(_:) | tag와 같은 타입의 Binding |
.value1 스타일 셀 (제목 + 오른쪽 값) | LabeledContent | — |
Picker 함정 — tag 타입이 selection 타입과 다르면 선택이 안 된다
Picker 문서는 각 항목의 tag가 "the type of each selection matches the type of the bound state variable"이 되게 하라고 한다. ForEach는 요소의 id로 tag를 자동으로 붙인다. 그래서 id 타입이 selection 타입과 다르면 컴파일은 되는데, 탭해도 선택값이 바뀌지 않는다.
struct SizePickerWrong: View {
@State private var size = "M"
let sizes = ["S", "M", "L"]
var body: some View {
Picker("사이즈", selection: $size) {
ForEach(sizes.indices, id: \.self) { i in
Text(sizes[i]) // 자동 tag = Int 인덱스
}
}
.pickerStyle(.segmented)
}
}struct SizePicker: View {
@State private var size = "M"
let sizes = ["S", "M", "L"]
var body: some View {
Picker("사이즈", selection: $size) {
ForEach(sizes, id: \.self) { size in
Text(size) // 자동 tag = String
}
}
.pickerStyle(.segmented)
}
}✅ 복습 · 5분
TextField("수량", value: $qty, format: .number)에서 qty는 언제 바뀌나?
String을 진실로 두고 숫자는 계산 프로퍼티로 파생한다.비밀번호 칸을 TextField에 모디파이어를 붙여 흉내 내지 않고 SecureField로 만드는 이유는?
SecureField는 글자를 점으로 가리는 것 말고도, iOS에서 스크린숏에 점까지 숨기고, 잘라내기·복사를 막고, Caps Lock 표시를 띄운다(SecureField 문서). 모디파이어로는 이 동작들을 다 얻지 못한다.🔮 코드 예측: 2절 ❌ SizePickerWrong에서 세그먼트의 "L"을 탭하면 size는 무엇이 되나?
ForEach(sizes.indices, id: \.self)는 각 항목의 tag를 Int 인덱스(0·1·2)로 붙인다. selection은 String이라 타입이 맞는 tag가 없으므로 바인딩에 쓸 값이 없다. ForEach(sizes, id: \.self)로 바꾸면 tag가 String이 되어 "L"이 들어간다.🔁 지난 Day 복습 (Day 10): sheet(item:)과 sheet(isPresented:)는 언제 각각 쓰나?
sheet(item:). 옵셔널 값이 nil이 아니게 되면 그 값을 받아 시트를 띄우고, nil이 되면 닫는다. 데이터 없이 켜고 끄기만 하면 isPresented. Bool과 옵셔널 데이터를 따로 들면 "열렸는데 데이터가 없는" 상태가 생긴다.2부는 두 번째 상태, 포커스다. @FocusState로 다음 칸 이동·오류 칸 되돌리기·키보드 내리기를 하고, onSubmit·키보드 툴바를 붙인 뒤 @Observable 폼 모델로 검증을 모은다.