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

Day 11 · 2부 · @FocusState와 폼 모델 검증

포커스를 @FocusState 값으로 들고 becomeFirstResponder 없이 입력 흐름을 만들고, 검증은 폼 모델의 계산 프로퍼티로 모은다.
진행률
0 / 0 완료
1부에서 입력값을 Binding으로 들고 Form을 조립했다. 2부는 두 번째 상태, 포커스다. UIKit에서 becomeFirstResponder()·resignFirstResponder() 명령과 델리게이트로 흩어져 있던 "다음 칸으로 · 키보드 내리기 · 오류 칸으로 되돌리기"를 @FocusState 변수 하나에 값을 쓰는 일로 바꾼다. 마지막으로 칸이 늘어난 폼의 검증을 @Observable 모델의 계산 프로퍼티로 모은다.

2부 목표 · 25분

  • @FocusState(Bool / enum)로 "다음 칸 이동 · 오류 칸으로 되돌리기 · 키보드 내리기"를 becomeFirstResponder 없이 구현한다.
  • onSubmit·submitLabel·키보드 툴바로 Return 키 없는 숫자 패드까지 다룬다.
  • 검증 로직을 @Observable 폼 모델의 계산 프로퍼티로 옮기고, 버튼을 disabled(_:)로 막는다.

1. @FocusState — 포커스를 상태로 들고 있기 🔥 핵심

UIKit에서 포커스는 명령이었다. "이 칸, 응답자가 돼라(becomeFirstResponder)", "그만둬라(resignFirstResponder)". 지금 어느 칸이 포커스인지 알려면 델리게이트 콜백을 받아 따로 기록해야 했다.

UIKit — 포커스 이동이 델리게이트 안의 명령으로 흩어진다
import UIKit

final class LoginViewController: UIViewController, UITextFieldDelegate {
    private let emailField = UITextField()
    private let passwordField = UITextField()

    override func viewDidLoad() {
        super.viewDidLoad()
        emailField.delegate = self
        emailField.returnKeyType = .next
        passwordField.delegate = self
        passwordField.isSecureTextEntry = true
        passwordField.returnKeyType = .go
    }

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        if textField === emailField {
            passwordField.becomeFirstResponder()   // 다음 칸으로
        } else {
            textField.resignFirstResponder()       // 키보드 내림
        }
        return true
    }
}

SwiftUI에서는 포커스가 값이다. WWDC21 · Direct and reflect focus in SwiftUI는 @FocusState를 "a special type of state that changes depending on the current location of focus"라고 소개한다. .focused(_:equals:)로 칸과 값을 연결하면, 연결은 양방향이 된다.

📝 Apple 원문

"The link between focus placement and FocusState works both ways. This means that we are not limited to reacting to focus changes; we can move focus programmatically just by updating our FocusState property." — WWDC21 · Direct and reflect focus in SwiftUI

풀이: 사용자가 칸을 탭하면 값이 바뀌고(읽기), 코드가 값을 바꾸면 포커스가 그 칸으로 간다(쓰기). 한 변수로 둘 다 한다.

읽기 — 포커스가 움직이면 값이 바뀐다 사용자가 이메일 칸을 탭 focusedField == .email UI가 값으로 판단 테두리 · 취소 버튼 표시 쓰기 — 값을 바꾸면 포커스가 움직인다 코드에서 대입 focusedField = .password SwiftUI가 연결된 .password 칸을 찾음 SecureField로 포커스 · 키보드 이동 focusedField = nil → 연결된 모든 칸에서 포커스 해제 = 키보드가 내려간다
becomeFirstResponder/resignFirstResponder 두 명령이 "변수 하나에 값 쓰기"로 합쳐진다.

Bool 하나 — 칸이 하나일 때

✅ 검색칸 하나: 포커스일 때만 취소 버튼, 취소하면 키보드 내림
struct SearchBox: View {
    @State private var query = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        HStack {
            TextField("검색", text: $query)
                .textFieldStyle(.roundedBorder)
                .focused($isFocused)
            if isFocused {
                Button("취소") {
                    query = ""
                    isFocused = false      // resignFirstResponder 대신
                }
            }
        }
        .padding()
        .animation(.default, value: isFocused)
    }
}

enum — 칸이 여러 개일 때

세션은 타입 조건을 이렇게 정리한다: "types used for FocusState must be both hashable and optional, with nil used for cases where focus is in an unrelated part of the screen." FocusState 문서도 "the wrapped value must be either an optional or a Boolean"이라고 적는다. 포커스가 이 화면 밖에 있을 때를 표현할 nil(또는 false)이 반드시 필요하기 때문이다.

✅ 로그인 폼 — Return으로 다음 칸, 오류면 되돌리기, 성공이면 키보드 내림
struct LoginForm: View {
    enum Field: Hashable { case email, password }

    @State private var email = ""
    @State private var password = ""
    @FocusState private var focusedField: Field?

    var body: some View {
        Form {
            TextField("이메일", text: $email)
                .focused($focusedField, equals: .email)
                .keyboardType(.emailAddress)
                .textInputAutocapitalization(.never)
                .submitLabel(.next)                 // 키보드 Return 자리에 "다음"
                .border(focusedField == .email && !isEmailValid ? .red : .clear)
            SecureField("비밀번호", text: $password)
                .focused($focusedField, equals: .password)
                .submitLabel(.go)
        }
        .onSubmit {                                 // Form 안의 어느 칸이든 Return이면 호출
            if focusedField == .email {
                focusedField = .password            // 다음 칸으로
            } else {
                signIn()
            }
        }
    }

    private var isEmailValid: Bool { email.contains("@") }

    private func signIn() {
        guard isEmailValid else {
            focusedField = .email                   // 문제 있는 칸으로 되돌림
            return
        }
        focusedField = nil                          // 키보드 내리고 로그인 진행
    }
}

빨간 테두리는 WWDC21 데모 그대로다. 포커스가 이메일 칸에 있고 형식이 틀렸을 때만 보인다. 포커스가 다른 칸으로 가면 focusedField == .email이 거짓이 되어 테두리가 사라진다. 포커스 위치가 값이라서 다른 UI 판단에 그대로 쓸 수 있다.

⚠️ 레거시 우회 — 응답자 체인에 직접 쏜다. 누가 포커스였는지 상태에 남지 않는다
import UIKit

struct CloseKeyboardLegacy: View {
    @State private var text = ""

    var body: some View {
        VStack {
            TextField("메모", text: $text)
            Button("닫기") {
                UIApplication.shared.sendAction(
                    #selector(UIResponder.resignFirstResponder),
                    to: nil, from: nil, for: nil)
            }
        }
    }
}

iOS 15 이전 SwiftUI 코드에서 자주 보이는 패턴이다. 동작은 하지만 SwiftUI 상태와 따로 논다. 새 코드는 @FocusState 값에 nil/false를 쓴다.

♿ 접근성 한 줄

WWDC21 세션은 포커스를 "keyboards, remotes, game controllers, accessible switch controls" 같은 입력을 받는 시스템이라고 설명한다. 검증 실패 시 focusedField = .email로 포커스를 옮기면, 화면을 보지 않는 하드웨어 키보드·스위치 제어 사용자도 오류 칸으로 바로 이동한다.

2. 제출·키보드 다루기 — onSubmit, submitLabel, 키보드 툴바

onSubmit은 textFieldShouldReturn(_:) 자리다. onSubmit 문서에 따르면 TextField·SecureField는 하드웨어/소프트웨어 Return 키에서 이 액션을 부르고, 이 모디파이어는 "an individual view or an entire view hierarchy"에 걸 수 있다. 즉 컨테이너에 한 번 걸면 안쪽 모든 칸의 Return을 받는다.

UIKitSwiftUI메모
textFieldShouldReturn(_:).onSubmit { }컨테이너에 걸면 하위 칸 전체에 적용
returnKeyType = .next.submitLabel(.next).done .go .search .send .return 등
inputAccessoryView.toolbar { ToolbarItemGroup(placement: .keyboard) { } }숫자 패드처럼 Return 키 없는 키보드에 필수
keyboardDismissMode = .interactive.scrollDismissesKeyboard(.interactively)스크롤로 키보드 내리기
becomeFirstResponder() / resignFirstResponder()focused = .field / focused = nil1절
✅ 숫자 패드 + 키보드 툴바 + 스크롤로 키보드 내리기
struct TransferForm: View {
    enum Field: Hashable { case amount, memo }

    @State private var amount = ""
    @State private var memo = ""
    @FocusState private var focused: Field?

    var body: some View {
        NavigationStack {
            Form {
                TextField("금액", text: $amount)
                    .keyboardType(.numberPad)         // Return 키가 없다 → 툴바가 필요
                    .focused($focused, equals: .amount)
                TextField("메모", text: $memo)
                    .focused($focused, equals: .memo)
                    .submitLabel(.done)
            }
            .scrollDismissesKeyboard(.interactively)  // 끌어내리면 키보드도 따라 내려감
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    Button("다음") { focused = .memo }
                        .disabled(focused == .memo)
                    Spacer()
                    Button("완료") { focused = nil }
                }
            }
            .navigationTitle("송금")
        }
    }
}

ToolbarItemPlacement.keyboard 문서: iOS에서 이 항목은 소프트웨어 키보드 위에 붙고, 하드웨어 키보드가 연결되면 화면 아래에 놓인다. scrollDismissesKeyboard 문서는 iOS 16 이상으로 링크하면 TextEditor는 기본이 interactive이고, 다른 스크롤 콘텐츠는 스크롤 시 키보드를 항상 내린다고 적는다. .never로 끌 수 있다.

한 칸만 제출에서 빼기 — submitScope

✅ 태그 칸의 Return은 폼 저장으로 전파하지 않는다
struct PostForm: View {
    @State private var title = ""
    @State private var tags = ""

    var body: some View {
        Form {
            TextField("제목", text: $title)
            TextField("태그 (쉼표로 구분)", text: $tags)
                .submitScope()          // 이 칸의 제출은 위쪽 onSubmit에 닿지 않는다
        }
        .onSubmit { save() }            // 제목 칸 Return → 저장
    }

    private func save() { print("저장: \(title)") }
}

submitScope()도 onSubmit 문서에 있는 예시를 줄인 것이다. .onSubmit(of: .search)처럼 트리거를 걸러서 searchable 검색칸의 제출만 받을 수도 있다(Day 10).

3. @Observable 폼 모델 — 검증은 계산 프로퍼티로

칸이 3개를 넘으면 뷰에 @State를 줄줄이 두는 대신 폼 모델 하나로 모은다. 규칙은 Day 3·5와 같다. 입력값만 저장 프로퍼티, 오류 문구와 "제출 가능 여부"는 계산 프로퍼티. 그러면 어떤 경로로 값이 바뀌어도 검증 결과가 어긋날 수 없다.

@Observable SignUpModel name · email age · agreed touched: Set 저장 프로퍼티 = 진실 계산 프로퍼티 nameError · emailError isValid Button .disabled(!isValid) 오류 Text touched에 든 칸만 표시 @FocusState focused onChange로 떠난 칸 기록
진실은 모델의 저장 프로퍼티뿐이다. 버튼 활성화와 오류 문구는 거기서 계산되고, 포커스 변화는 "어느 칸을 이미 거쳐 갔는지"만 모델에 남긴다.
✅ 폼 모델 — 입력은 저장, 검증은 계산
import Observation

enum SignUpField: Hashable { case name, email }

@Observable
final class SignUpModel {
    var name = ""
    var email = ""
    var age = 20
    var agreed = false
    var touched: Set<SignUpField> = []      // 사용자가 거쳐 간 칸

    var nameError: String? {
        name.trimmingCharacters(in: .whitespaces).isEmpty ? "이름을 입력하세요" : nil
    }
    var emailError: String? {
        email.contains("@") && email.contains(".") ? nil : "이메일 형식이 아니다"
    }
    var isValid: Bool {
        nameError == nil && emailError == nil && agreed
    }
}
✅ 뷰 — 떠난 칸만 오류 표시, 유효할 때만 버튼 활성
struct SignUpView: View {
    @State private var model = SignUpModel()
    @FocusState private var focused: SignUpField?

    var body: some View {
        Form {
            Section {
                TextField("이름", text: $model.name)
                    .focused($focused, equals: .name)
                    .submitLabel(.next)
                if model.touched.contains(.name), let error = model.nameError {
                    Text(error).font(.footnote).foregroundStyle(.red)
                }
                TextField("이메일", text: $model.email)
                    .focused($focused, equals: .email)
                    .keyboardType(.emailAddress)
                    .textInputAutocapitalization(.never)
                    .submitLabel(.done)
                if model.touched.contains(.email), let error = model.emailError {
                    Text(error).font(.footnote).foregroundStyle(.red)
                }
            }
            Section {
                Stepper("나이 \(model.age)", value: $model.age, in: 14...120)
                Toggle("약관에 동의", isOn: $model.agreed)
            }
            Button("가입하기") { focused = nil }
                .disabled(!model.isValid)               // 계산값 하나로 막는다
        }
        .onSubmit {
            focused = (focused == .name) ? .email : nil
        }
        .onChange(of: focused) { oldValue, _ in
            if let oldValue { model.touched.insert(oldValue) }   // 방금 떠난 칸
        }
    }
}

$model.name이 되는 이유: @State의 projected value가 Binding<SignUpModel>이고, 거기서 프로퍼티로 내려가면 Binding<String>이 나온다. 모델을 자식 뷰에 넘겨서 바인딩이 필요하면 자식에서 @Bindable로 받는다(Day 5).

✅ 자식 뷰는 @Bindable로 받아서 $로 바인딩
struct AgreementRow: View {
    @Bindable var model: SignUpModel

    var body: some View {
        Toggle("약관에 동의", isOn: $model.agreed)
    }
}
👀 Preview 팁

#Preview { SignUpView() } 하나로 포커스 이동·키보드 툴바·버튼 활성화를 전부 확인할 수 있다. 확인 순서: 이름 입력 → Return → 커서가 이메일로 이동하는지, 이메일을 비운 채 다른 칸으로 옮기면 그때 처음 오류 문구가 뜨는지, 약관 토글을 켜야 버튼이 활성화되는지.

흔한 실수 모음

❌ 검증 결과를 @State로 복사 — 진실이 둘. 초기값·다른 경로 수정에서 어긋난다
struct EmailFormWrong: View {
    @State private var email = "a@b.com"
    @State private var isValid = false          // 초기값부터 이미 틀렸다

    var body: some View {
        Form {
            TextField("이메일", text: $email)
                .onChange(of: email) { _, newValue in
                    isValid = newValue.contains("@")
                }
            Button("보내기") { }.disabled(!isValid)
        }
    }
}
✅ 검증은 계산 프로퍼티 — 언제 읽어도 현재 값과 일치
struct EmailForm: View {
    @State private var email = "a@b.com"
    private var isValid: Bool { email.contains("@") }

    var body: some View {
        Form {
            TextField("이메일", text: $email)
            Button("보내기") { }.disabled(!isValid)
        }
    }
}
❌ 두 칸에 같은 포커스 값 — 코드로 .name을 쓰면 첫 칸만 선택되고 런타임 경고
struct NameFieldsWrong: View {
    enum Field: Hashable { case name, fullName }
    @State private var name = ""
    @State private var fullName = ""
    @FocusState private var focused: Field?

    var body: some View {
        VStack {
            TextField("이름", text: $name)
                .focused($focused, equals: .name)
            TextField("전체 이름", text: $fullName)
                .focused($focused, equals: .name)   // .fullName이어야 한다
        }
    }
}

FocusState 문서의 "Avoid ambiguous focus bindings" 절 내용이다. 같은 값을 두 칸에 묶으면 코드로 그 값을 대입했을 때 SwiftUI가 첫 번째 후보를 고르고, "likely a programmer error"라며 런타임 경고를 낸다. 반대로 한 칸에 여러 값을 묶는 것은 허용된다.

🚨 흔한 실수 요약

① 숫자 패드에 키보드 툴바를 안 달아 키보드를 닫을 방법이 없음 ② 검증 결과를 상태로 복사 ③ 포커스 enum 값을 두 칸에 중복 사용. 여기에 1부의 두 가지(format: 필드로 실시간 반영 기대, Picker tag 타입 ≠ selection 타입)를 더하면 입력 화면에서 가장 자주 보는 실수 다섯 개다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
@FocusState의 값 타입은 왜 옵셔널(또는 Bool)이어야 하나?
포커스가 이 화면의 어떤 칸에도 없을 때를 표현할 값이 필요하기 때문이다. WWDC21 세션: "nil used for cases where focus is in an unrelated part of the screen." 같은 이유로 nil/false를 대입하면 모든 칸에서 포커스가 빠지고 키보드가 내려간다.
쉽게 말하면 교실에서 "지금 발표하는 사람" 칸에는 "아무도 없음"도 적을 수 있어야 한다. 그게 nil이다.
Form에 .onSubmit { }을 한 번 걸면 어느 칸의 Return에 반응하나? 한 칸만 빼려면?
onSubmit은 "an individual view or an entire view hierarchy"에 걸 수 있어서, 컨테이너에 걸면 안쪽 모든 TextField·SecureField의 Return에 반응한다(onSubmit 문서). 특정 칸만 빼려면 그 칸에 .submitScope()를 건다.
쉽게 말하면 교실 전체에 "손 들면 선생님이 온다"고 정하고, 한 명에게만 "넌 손 들어도 안 온다" 스티커를 붙이는 것이다.
🔮 아래 코드에서 "찾기" 버튼을 누르면 어느 칸에 포커스가 가나?
struct Predict: View {
    enum F: Hashable { case a, b }
    @State private var x = ""
    @State private var y = ""
    @FocusState private var f: F?

    var body: some View {
        VStack {
            TextField("X", text: $x).focused($f, equals: .a)
            TextField("Y", text: $y).focused($f, equals: .a)
            Button("찾기") { f = .a }
        }
    }
}
🔮 코드 예측: 위 코드에서 "찾기"를 누르면?
첫 번째 칸 X에 포커스가 가고, SwiftUI가 런타임 경고를 낸다. 같은 값 .a가 두 칸에 묶여 모호하면 첫 후보를 고른다(FocusState 문서 "Avoid ambiguous focus bindings"). Y 칸은 .b로 바꿔야 한다.
쉽게 말하면 두 친구에게 같은 번호표를 주고 "3번 나와!" 하면 앞줄 친구만 나온다. 번호표는 한 사람에 하나.
🔁 1부 복습: TextField("수량", value: $qty, format: .number)에서 qty는 언제 바뀌나? 입력 중 합계를 실시간으로 보여 주려면?
한 글자마다가 아니라, 사용자가 Return 키 등으로 편집을 확정할 때 바뀐다(TextField 문서: String이 아닌 타입은 "when the user commits their edits"). 숫자 패드에는 Return도 없다. 실시간 값이 필요하면 String을 진실로 두고 숫자는 Int(text) 계산 프로퍼티로 파생한다.
쉽게 말하면 글자 칸은 쓰는 대로 받아 적고, 숫자 칸은 "다 썼어요!" 하고 손을 들어야 받아 적는다.

🛠 미니 과제 (선택 · 10분)

Preview 하나로 끝나는 배송지 입력 폼이다. 아래 시작 코드를 붙여 넣고 요구사항 3개를 채운다.

시작 코드
struct AddressForm: View {
    @State private var receiver = ""
    @State private var phone = ""
    @State private var address = ""

    var body: some View {
        NavigationStack {
            Form {
                TextField("받는 분", text: $receiver)
                TextField("전화번호", text: $phone)
                TextField("주소", text: $address)
                Button("저장") { }
            }
            .navigationTitle("배송지")
        }
    }
}

#Preview { AddressForm() }

확인 방법: Preview에서 받는 분 칸에 입력 후 Return → 전화번호 칸으로 커서가 이동, 툴바 "다음" → 주소 칸, 주소에서 Return → 키보드가 내려가는지. 전화번호를 9자리만 넣으면 저장 버튼이 흐려져 있는지.

정답 예시 펼치기
✅ 정답 예시
struct AddressFormAnswer: View {
    enum Field: Hashable { case receiver, phone, address }

    @State private var receiver = ""
    @State private var phone = ""
    @State private var address = ""
    @FocusState private var focused: Field?

    private var canSave: Bool {
        !receiver.isEmpty && !address.isEmpty
            && phone.filter(\.isNumber).count >= 10
    }

    var body: some View {
        NavigationStack {
            Form {
                TextField("받는 분", text: $receiver)
                    .focused($focused, equals: .receiver)
                    .submitLabel(.next)
                TextField("전화번호", text: $phone)
                    .focused($focused, equals: .phone)
                    .keyboardType(.numberPad)
                TextField("주소", text: $address)
                    .focused($focused, equals: .address)
                    .submitLabel(.done)
                Button("저장") { focused = nil }
                    .disabled(!canSave)
            }
            .onSubmit {
                switch focused {
                case .receiver: focused = .phone
                default: focused = nil
                }
            }
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    Button("다음") { focused = .address }
                        .disabled(focused != .phone)
                    Spacer()
                    Button("완료") { focused = nil }
                }
            }
            .navigationTitle("배송지")
        }
    }
}

포인트: 전화번호 칸에는 Return이 없으므로 onSubmit의 .receiver 분기에서 .phone으로 보내고, 그다음은 툴바가 맡는다. canSave는 저장 프로퍼티를 읽기만 하는 계산값이라 어긋날 수 없다.

출처 · 참고자료