← SwiftUI 14일 마스터 11 / 14 · 1부

Day 11 · 1부 · 텍스트 입력과 Form

입력값은 Binding으로 들고, format 필드의 갱신 시점과 Picker tag 함정을 알고 설정 화면을 조립한다.
진행률
0 / 0 완료
UIKit에서 입력 화면을 만들면 코드 대부분이 입력 자체가 아니라 주변 일이었다. 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 타깃이나 델리게이트로 값을 복사하는 코드가 사라진다.

하고 싶은 일UIKitSwiftUI
한 줄 텍스트UITextField + editingChangedTextField("…", text: $value)
비밀번호isSecureTextEntry = trueSecureField("…", text: $value)
여러 줄 긴 글UITextViewTextEditor(text: $value)
늘어나는 입력칸UITextView + 높이 제약 계산TextField(…, axis: .vertical) + lineLimit(1...4)
숫자·통화 입력NumberFormatter로 직접 파싱TextField(…, value: $n, format: .number)
키보드 종류 · 자동완성keyboardType · textContentType같은 이름의 모디파이어 .keyboardType(_:) · .textContentType(_:)
✅ 입력 3종 + 세로로 늘어나는 TextField
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이 파싱해서 바인딩 타입으로 바꿔 준다. 여기서 갱신 시점이 달라진다.

📝 Apple 원문

"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 키 등으로 편집을 확정할 때 한 번 갱신된다.

"1" 입력 "2" 입력 "3" 입력 Return (확정) text: $name String "1" "12" "123" "123" value: $qty Int · format: .number 0 0 0 123 화면엔 "123"이 보여도, 확정 전까지 qty는 0이다 입력 중 실시간 계산이 필요하면 String을 진실로 두고 숫자는 계산 프로퍼티로 파생한다
같은 "1·2·3·Return" 입력이라도 String 바인딩은 네 번, format 바인딩은 한 번 갱신된다.
❌ format 필드로 "입력 중 실시간 합계"를 기대한다 — Return 전까지 0개로 보인다
struct LiveCountWrong: View {
    @State private var quantity = 0

    var body: some View {
        VStack {
            TextField("수량", value: $quantity, format: .number)
                .keyboardType(.numberPad)   // 숫자 패드에는 Return 키도 없다
            Text("주문 \(quantity)개")       // 확정 전까지 갱신되지 않음
        }
        .padding()
    }
}
✅ 실시간이 필요하면 String이 진실, 숫자는 파생값
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()
    }
}
✅ 확정 시점 갱신이 괜찮은 곳에서는 format이 가장 짧다 (Form 안에서 통화 입력)
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에서는 정렬된 세로 스택으로 그려진다. 버튼·토글·피커도 폼 안에서는 폼 전용 모양으로 바뀐다.

Form Section · header / footer Picker Toggle Section("표시") Stepper DatePicker iOS에서 알림 알림 받기 멘션만 › 소리 멘션만 선택하면 나를 부른 메시지만 알린다 표시 글자 크기 16pt − | + 방해 금지 시작 22:00
코드는 "Form 안의 Section 안의 컨트롤"이라는 의미만 적는다. 헤더·그룹 상자·구분선·푸터 모양은 iOS가 정한다.
✅ 설정 화면 — Section header/footer, 컨트롤 4종, LabeledContent
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("설정")
        }
    }
}
UIKitSwiftUI바인딩 타입
정적 셀 UITableView (.insetGrouped)Form + Section—
UISwitchToggleBinding<Bool>
UIStepper + 라벨Stepper(_:value:in:step:)Binding<Int> 등 Strideable
UIDatePickerDatePicker(_:selection:displayedComponents:)Binding<Date>
UISegmentedControl / 메뉴 버튼 / 선택 화면 pushPicker + .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 타입과 다르면 컴파일은 되는데, 탭해도 선택값이 바뀌지 않는다.

❌ 인덱스(Int)가 tag가 된다 — String 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)
    }
}
✅ 요소 자체(String)를 id로 — tag 타입이 selection과 같다
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는 언제 바뀌나?
한 글자마다가 아니라, 사용자가 Return 키 등으로 편집을 확정할 때 바뀐다(TextField 문서: String이 아닌 타입은 "when the user commits their edits"). 입력 중 실시간 값이 필요하면 String을 진실로 두고 숫자는 계산 프로퍼티로 파생한다.
쉽게 말하면 글자 칸은 쓰는 대로 받아 적고, 숫자 칸은 "다 썼어요!" 하고 손을 들어야 받아 적는다.
비밀번호 칸을 TextField에 모디파이어를 붙여 흉내 내지 않고 SecureField로 만드는 이유는?
SecureField는 글자를 점으로 가리는 것 말고도, iOS에서 스크린숏에 점까지 숨기고, 잘라내기·복사를 막고, Caps Lock 표시를 띄운다(SecureField 문서). 모디파이어로는 이 동작들을 다 얻지 못한다.
쉽게 말하면 금고처럼 보이게 칠한 상자와 진짜 금고는 다르다. 비밀번호는 진짜 금고에 넣는다.
🔮 코드 예측: 2절 ❌ SizePickerWrong에서 세그먼트의 "L"을 탭하면 size는 무엇이 되나?
"M" 그대로다. 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과 옵셔널 데이터를 따로 들면 "열렸는데 데이터가 없는" 상태가 생긴다.
쉽게 말하면 "선물 상자가 있으면 연다"가 item, "문을 열까 말까"가 isPresented다.
➡️ 2부 예고

2부는 두 번째 상태, 포커스다. @FocusState로 다음 칸 이동·오류 칸 되돌리기·키보드 내리기를 하고, onSubmit·키보드 툴바를 붙인 뒤 @Observable 폼 모델로 검증을 모은다.

출처 · 참고자료