사용자의 행동이 앱의 흐름을 만든다
지금까지는 상태가 바뀌면 SwiftUI 화면이 다시 그려진다는 원리를 배웠다. 이제 그 상태를 실제 사용자 행동과 연결할 차례다.
앱은 단순히 정보를 보여 주는 화면이 아니다. 사용자가 이름을 입력하고, 알림을 켜고, 날짜를 고르고, 목록에서 항목을 선택하고, 삭제 여부를 확인하는 흐름 전체가 앱이다. SwiftUI에서는 이런 흐름을 @State, @Binding, 입력 컴포넌트, 화면 전환 API로 구성한다.
기본 원리는 단순하다.
화면에 입력 UI를 놓는다.
입력 UI를 상태 값과 연결한다.
상태가 바뀌면 화면이 자동으로 갱신된다.
필요한 경우 다른 화면이나 모달을 띄운다.
이 장에서는 텍스트 입력, 선택 입력, 설정 화면, 상세 화면 이동, 알림과 확인 창까지 실제 앱에서 자주 쓰는 사용자 흐름을 구현한다.
AI 생성 이미지
TextField로 텍스트 입력 받기
가장 기본적인 입력은 문자열이다. 이름, 이메일, 검색어, 메모 제목처럼 사용자가 직접 글자를 입력하는 곳에는 TextField를 사용한다.
struct ProfileNameView: View {
@State private var name = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("이름을 입력하세요", text: $name)
.textFieldStyle(.roundedBorder)
Text("입력한 이름: (name)")
.foregroundStyle(.secondary)
}
.padding()
}
}여기서 중요한 부분은 $name이다. name은 실제 상태 값이고, $name은 TextField가 그 값을 읽고 수정할 수 있게 해 주는 바인딩이다.
사용자가 글자를 입력하면 name이 바뀐다. name이 바뀌면 Text("입력한 이름: (name)")도 다시 계산된다. 개발자가 직접 라벨을 새로 고치는 코드를 작성하지 않아도 된다.
이메일이나 숫자 입력처럼 키보드 종류를 바꾸고 싶을 때는 modifier를 붙인다.
TextField("이메일", text: $email)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()작은 차이지만 사용성에는 큰 영향을 준다. 이메일 입력란에서 자동 대문자가 켜져 있으면 사용자는 불편함을 느낀다. 입력 화면은 데이터만 받는 곳이 아니라 사용자의 실수를 줄여 주는 곳이어야 한다.
Toggle, Picker, Slider, DatePicker 연결하기
문자열 외에도 앱은 다양한 형태의 입력을 받는다. SwiftUI는 자주 쓰는 입력 방식을 기본 컴포넌트로 제공한다.
Toggle은 켜짐과 꺼짐처럼 참 또는 거짓 값을 다룬다.
@State private var isNotificationOn = true
Toggle("알림 받기", isOn: $isNotificationOn)Picker는 여러 선택지 중 하나를 고르게 한다.
struct CategoryPickerView: View {
@State private var category = "일상"
let categories = ["일상", "공부", "운동", "업무"]
var body: some View {
Picker("카테고리", selection: $category) {
ForEach(categories, id: .self) { item in
Text(item).tag(item)
}
}
}
}Picker에서 각 항목의 tag는 선택 값과 연결되는 실제 값이다. selection의 타입과 tag의 타입이 맞아야 한다.
Slider는 범위 안의 숫자를 조절할 때 쓴다. 볼륨, 글자 크기, 만족도 점수 같은 입력에 적합하다.
@State private var satisfaction = 3.0
VStack(alignment: .leading) {
Slider(value: $satisfaction, in: 1...5, step: 1)
Text("만족도: (Int(satisfaction))점")
}DatePicker는 날짜와 시간을 선택할 때 사용한다.
@State private var reservationDate = Date()
DatePicker(
"예약 날짜",
selection: $reservationDate,
displayedComponents: [.date, .hourAndMinute]
)이 컴포넌트들의 공통점은 모두 상태와 연결된다는 점이다. 사용자가 조작하면 상태가 바뀌고, 상태가 바뀌면 화면이 다시 표현된다. SwiftUI 입력 화면의 핵심은 이 흐름을 일관되게 유지하는 것이다.
Form과 Section으로 입력 화면 구성하기
입력 항목이 많아지면 VStack만으로는 화면이 금방 복잡해진다. 회원 정보, 설정, 예약, 문의 작성 화면처럼 여러 입력을 받아야 한다면 Form을 사용하는 것이 좋다.
Form은 iOS 설정 화면처럼 목록형 입력 UI를 구성하는 컨테이너다. 관련 항목은 Section으로 묶을 수 있다. 이 방식은 여러 입력 필드를 읽기 쉽게 그룹화하는 데 유용하다.1
struct ProfileFormView: View {
@State private var name = ""
@State private var email = ""
@State private var phone = ""
@State private var isMarketingOn = false
@State private var birthDate = Date()
var body: some View {
NavigationStack {
Form {
Section("기본 정보") {
TextField("이름", text: $name)
TextField("이메일", text: $email)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
TextField("전화번호", text: $phone)
.keyboardType(.phonePad)
}
Section("추가 정보") {
DatePicker("생년월일", selection: $birthDate, displayedComponents: .date)
Toggle("마케팅 정보 수신", isOn: $isMarketingOn)
}
}
.navigationTitle("프로필 수정")
}
}
}Section 제목은 화면의 구조를 설명한다. 사용자는 모든 입력란을 하나씩 해석하지 않아도 "기본 정보"와 "추가 정보"라는 묶음으로 이해할 수 있다.
입력 도움말이나 검증 메시지는 footer에 넣을 수 있다.
Section {
TextField("이메일", text: $email)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
} footer: {
if !email.isEmpty && !email.contains("@") {
Text("올바른 이메일 형식이 아닙니다.")
.foregroundStyle(.red)
}
}오류를 제출 버튼을 누른 뒤에만 알려 주면 사용자는 무엇을 잘못했는지 늦게 알게 된다. 가능하면 입력 중에도 부드럽게 안내하는 편이 좋다.
실제 앱에 가까운 예약 입력 화면 만들기
여러 입력 컴포넌트를 하나의 Form에 배치해 예약 화면을 만들어 보자.
struct ReservationFormView: View {
@State private var name = ""
@State private var numberOfPeople = 2.0
@State private var date = Date()
@State private var seatType = "일반석"
@State private var needsNotification = true
@State private var showAlert = false
private let seatTypes = ["일반석", "창가석", "조용한 좌석"]
private var isFormValid: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
Form {
Section("예약자") {
TextField("이름", text: $name)
} footer: {
if !isFormValid {
Text("이름은 필수 입력 항목입니다.")
.foregroundStyle(.red)
}
}
Section("예약 정보") {
DatePicker("날짜", selection: $date, displayedComponents: [.date, .hourAndMinute])
Picker("좌석", selection: $seatType) {
ForEach(seatTypes, id: .self) { type in
Text(type).tag(type)
}
}
VStack(alignment: .leading) {
Slider(value: $numberOfPeople, in: 1...10, step: 1)
Text("인원: (Int(numberOfPeople))명")
.foregroundStyle(.secondary)
}
}
Section("알림") {
Toggle("예약 알림 받기", isOn: $needsNotification)
}
}
.navigationTitle("예약하기")
.toolbar {
Button("완료") {
showAlert = true
}
.disabled(!isFormValid)
}
.alert("예약 완료", isPresented: $showAlert) {
Button("확인", role: .cancel) { }
} message: {
Text("(name)님의 예약이 저장되었습니다.")
}
}
}
}이 예제에는 입력 화면의 기본 요소가 대부분 들어 있다.
TextField로 이름을 입력한다.DatePicker로 날짜를 선택한다.Picker로 좌석 종류를 선택한다.Slider로 인원을 조절한다.Toggle로 알림 여부를 설정한다.필수 입력이 비어 있으면 버튼을 비활성화한다.
완료 후
alert로 결과를 알려 준다.
좋은 입력 화면은 사용자가 잘못 입력한 뒤에 혼내는 화면이 아니다. 잘못 입력하기 어렵게 만들고, 잘못 입력했을 때 바로 회복할 수 있게 도와주는 화면이다.
NavigationStack으로 화면 이동하기
앱은 보통 하나의 화면으로 끝나지 않는다. 목록에서 항목을 누르면 상세 화면으로 이동하고, 상세 화면에서 편집 화면을 열기도 한다. SwiftUI에서는 이런 계층형 이동에 NavigationStack과 NavigationLink를 사용한다.
struct Memo: Identifiable {
let id = UUID()
let title: String
let content: String
}
struct MemoListView: View {
let memos = [
Memo(title: "오늘의 기록", content: "SwiftUI 입력 화면을 공부했다."),
Memo(title: "아이디어", content: "간단한 메모 앱을 만들어 보자.")
]
var body: some View {
NavigationStack {
List(memos) { memo in
NavigationLink {
MemoDetailView(memo: memo)
} label: {
Text(memo.title)
}
}
.navigationTitle("메모")
}
}
}
struct MemoDetailView: View {
let memo: Memo
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(memo.title)
.font(.title.bold())
Text(memo.content)
}
.padding()
.navigationTitle("상세")
}
}NavigationStack은 이동 흐름을 담는 컨테이너다. NavigationLink는 사용자가 누를 수 있는 이동入口이다. 목록 화면에서 선택한 memo를 상세 화면으로 전달하면, 상세 화면은 전달받은 데이터로 내용을 표시한다.
화면 이동을 설계할 때는 "이 화면이 다음 화면에 무엇을 넘겨야 하는가"를 먼저 생각하면 된다. 전체 배열이 필요한지, 선택된 항목 하나만 필요한지, 아니면 항목의 id만 넘기고 상세 화면에서 다시 조회해야 하는지 판단해야 한다.
작은 앱에서는 모델 전체를 넘겨도 괜찮다. 앱이 커지고 데이터가 서버나 데이터베이스와 연결되면 id를 넘기고 상세 화면의 ViewModel이 필요한 데이터를 불러오는 구조가 더 적합할 수 있다.
sheet와 fullScreenCover로 보조 흐름 만들기
모든 화면 이동이 계층형일 필요는 없다. 현재 화면 위에 잠시 뜨는 보조 작업도 있다. 새 메모 작성, 필터 선택, 공유 옵션, 로그인 화면 등이 그렇다.
간단한 보조 입력에는 sheet를 사용한다.
struct MemoHomeView: View {
@State private var showEditor = false
var body: some View {
NavigationStack {
List {
Text("오늘의 기록")
Text("앱 아이디어")
}
.navigationTitle("메모")
.toolbar {
Button("추가") {
showEditor = true
}
}
.sheet(isPresented: $showEditor) {
MemoEditorView()
}
}
}
}sheet는 현재 흐름을 완전히 떠나는 것이 아니라, 잠시 위에 올려 작업을 처리하는 느낌에 가깝다. 사용자는 닫고 원래 화면으로 돌아올 수 있다.
반면 로그인, 온보딩, 결제처럼 사용자가 그 흐름에 집중해야 한다면 fullScreenCover를 고려할 수 있다.
.fullScreenCover(isPresented: $showLogin) {
LoginView()
}fullScreenCover는 화면 전체를 덮는다. 그래서 남용하면 답답한 앱이 된다. 정말 사용자가 기존 화면과 분리된 작업에 집중해야 할 때만 사용하는 것이 좋다.
alert와 confirmationDialog로 확인과 피드백 주기
사용자에게 중요한 정보를 알려야 할 때는 alert를 사용한다. 예를 들어 저장 실패, 필수 입력 누락, 네트워크 오류처럼 사용자가 알아야 하는 문제가 있을 때 적합하다.
@State private var showError = false
Button("저장") {
if title.isEmpty {
showError = true
} else {
// 저장 처리
}
}
.alert("저장할 수 없습니다", isPresented: $showError) {
Button("확인", role: .cancel) { }
} message: {
Text("제목을 입력한 뒤 다시 시도하세요.")
}삭제처럼 되돌리기 어려운 행동은 확인 절차가 필요하다. 이때는 confirmationDialog가 자연스럽다.
@State private var showDeleteDialog = false
Button("삭제", role: .destructive) {
showDeleteDialog = true
}
.confirmationDialog("이 메모를 삭제할까요?", isPresented: $showDeleteDialog) {
Button("삭제", role: .destructive) {
// 삭제 처리
}
Button("취소", role: .cancel) { }
} message: {
Text("삭제한 메모는 복구할 수 없습니다.")
}alert는 중요한 메시지를 전달하는 데 적합하고, confirmationDialog는 여러 선택지 중 하나를 고르게 하는 데 적합하다. 둘 다 자주 쓰면 사용자의 흐름을 끊는다. 정말 필요한 순간에만 사용해야 한다.
입력 검증은 상태로 표현한다
입력 검증은 "저장 버튼을 눌렀을 때 한 번 검사하는 코드"로만 생각하면 부족하다. SwiftUI에서는 검증 결과도 상태에서 계산되는 값으로 볼 수 있다.
@State private var email = ""
@State private var password = ""
private var isEmailValid: Bool {
email.contains("@") && email.contains(".")
}
private var isPasswordValid: Bool {
password.count >= 8
}
private var canSubmit: Bool {
isEmailValid && isPasswordValid
}이렇게 만들면 여러 곳에서 같은 조건을 재사용할 수 있다.
Button("가입하기") {
// 가입 처리
}
.disabled(!canSubmit)if !email.isEmpty && !isEmailValid {
Text("이메일 형식을 확인하세요.")
.font(.footnote)
.foregroundStyle(.red)
}검증 기준은 화면 코드 안에 계속 늘어놓기보다 계산 속성이나 ViewModel로 분리하는 편이 좋다. 간단한 화면에서는 View 안의 계산 속성으로 충분하지만, 회원가입처럼 규칙이 많아지면 별도 타입으로 옮기는 것이 유지보수에 유리하다.
어떤 화면 전환 방식을 선택할까
SwiftUI에는 여러 전환 방식이 있다. 중요한 것은 API 이름을 외우는 것이 아니라 상황에 맞게 선택하는 것이다.
| 방식 | 적합한 상황 |
|---|---|
NavigationStack + NavigationLink | 목록에서 상세로 이동하는 계층형 흐름 |
sheet | 현재 화면 위에서 잠깐 처리하는 작성, 선택, 필터 화면 |
fullScreenCover | 로그인, 온보딩처럼 전체 화면 집중이 필요한 흐름 |
alert | 오류, 경고, 중요한 안내 메시지 |
confirmationDialog | 삭제, 로그아웃처럼 사용자 확인이 필요한 선택 |
대부분의 앱은 이 방식을 섞어서 사용한다. 예를 들어 메모 앱은 목록에서 상세로 이동할 때 NavigationStack을 쓰고, 새 메모 작성에는 sheet를 쓰며, 삭제 전에는 confirmationDialog를 띄울 수 있다.
핵심은 사용자가 현재 어디에 있고, 다음에 무엇을 할 수 있는지 헷갈리지 않게 만드는 것이다.
입력과 이동도 데이터 흐름이다
사용자 입력과 화면 전환은 별개의 기술처럼 보이지만 실제로는 같은 데이터 흐름 위에 있다.
사용자가 TextField에 글자를 입력하면 상태가 바뀐다. 사용자가 Toggle을 켜면 설정 값이 바뀐다. 사용자가 목록의 항목을 누르면 선택한 데이터가 상세 화면으로 전달된다. 사용자가 삭제 버튼을 누르면 확인 상태가 바뀌고, 확인 후 실제 데이터가 삭제된다.
SwiftUI 앱을 만들 때는 화면을 직접 조작하려고 하기보다 상태를 먼저 생각해야 한다.
이 입력은 어떤 상태와 연결되는가?
이 버튼은 어떤 상태를 바꾸는가?
이 화면은 어떤 데이터를 받아야 하는가?
이 오류는 어디에서 표시되어야 하는가?
이 모달은 어떤 조건에서 열리고 닫히는가?
이 질문에 답할 수 있으면 입력 화면과 화면 전환은 훨씬 단순해진다. 앱은 사용자의 행동에 반응하는 흐름이다. 그리고 SwiftUI에서는 그 흐름을 상태와 바인딩, 내비게이션, 모달 표현으로 차근차근 만들 수 있다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
