Binding으로 들고 Form을 조립했다. 2부는 두 번째 상태, 포커스다. UIKit에서 becomeFirstResponder()·resignFirstResponder() 명령과 델리게이트로 흩어져 있던 "다음 칸으로 · 키보드 내리기 · 오류 칸으로 되돌리기"를 @FocusState 변수 하나에 값을 쓰는 일로 바꾼다. 마지막으로 칸이 늘어난 폼의 검증을 @Observable 모델의 계산 프로퍼티로 모은다.2부 목표 · 25분
@FocusState(Bool / enum)로 "다음 칸 이동 · 오류 칸으로 되돌리기 · 키보드 내리기"를becomeFirstResponder없이 구현한다.onSubmit·submitLabel·키보드 툴바로 Return 키 없는 숫자 패드까지 다룬다.- 검증 로직을
@Observable폼 모델의 계산 프로퍼티로 옮기고, 버튼을disabled(_:)로 막는다.
1. @FocusState — 포커스를 상태로 들고 있기 🔥 핵심
UIKit에서 포커스는 명령이었다. "이 칸, 응답자가 돼라(becomeFirstResponder)", "그만둬라(resignFirstResponder)". 지금 어느 칸이 포커스인지 알려면 델리게이트 콜백을 받아 따로 기록해야 했다.
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:)로 칸과 값을 연결하면, 연결은 양방향이 된다.
"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
풀이: 사용자가 칸을 탭하면 값이 바뀌고(읽기), 코드가 값을 바꾸면 포커스가 그 칸으로 간다(쓰기). 한 변수로 둘 다 한다.
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)이 반드시 필요하기 때문이다.
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을 받는다.
| UIKit | SwiftUI | 메모 |
|---|---|---|
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 = nil | 1절 |
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
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와 같다. 입력값만 저장 프로퍼티, 오류 문구와 "제출 가능 여부"는 계산 프로퍼티. 그러면 어떤 경로로 값이 바뀌어도 검증 결과가 어긋날 수 없다.
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).
struct AgreementRow: View {
@Bindable var model: SignUpModel
var body: some View {
Toggle("약관에 동의", isOn: $model.agreed)
}
}#Preview { SignUpView() } 하나로 포커스 이동·키보드 툴바·버튼 활성화를 전부 확인할 수 있다. 확인 순서: 이름 입력 → Return → 커서가 이메일로 이동하는지, 이메일을 비운 채 다른 칸으로 옮기면 그때 처음 오류 문구가 뜨는지, 약관 토글을 켜야 버튼이 활성화되는지.
흔한 실수 모음
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)
}
}
}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)이어야 하나?
nil/false를 대입하면 모든 칸에서 포커스가 빠지고 키보드가 내려간다.Form에 .onSubmit { }을 한 번 걸면 어느 칸의 Return에 반응하나? 한 칸만 빼려면?
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 }
}
}
}🔮 코드 예측: 위 코드에서 "찾기"를 누르면?
.a가 두 칸에 묶여 모호하면 첫 후보를 고른다(FocusState 문서 "Avoid ambiguous focus bindings"). Y 칸은 .b로 바꿔야 한다.🔁 1부 복습: TextField("수량", value: $qty, format: .number)에서 qty는 언제 바뀌나? 입력 중 합계를 실시간으로 보여 주려면?
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() }- 요구 1 — enum
@FocusState로 받는 분 → 주소 순서로 Return이 다음 칸으로 넘어가게 한다(submitLabel도.next/.done). - 요구 2 — 전화번호는
.numberPad로 바꾸고, Return이 없으니 키보드 툴바에 "다음"(→ 주소)과 "완료"(키보드 내림)를 단다. - 요구 3 — 세 칸이 비어 있지 않고 전화번호 숫자가 10자리 이상일 때만 "저장"이 활성화된다. 판단은 계산 프로퍼티 하나로.
확인 방법: 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는 저장 프로퍼티를 읽기만 하는 계산값이라 어긋날 수 없다.