SwiftUI: iOS๋ฅผ ์ํ ๋ชจ๋ ์ธํฐํ์ด์ค ๊ตฌ์ถ ๊ฐ์ด๋
SwiftUI๋ก ๋ชจ๋ UI๋ฅผ ๊ตฌ์ถํ๋ ๋ฐฉ๋ฒ์ ์์๋ด ๋๋ค. ์ ์ธ์ ๊ตฌ๋ฌธ, ์ปดํฌ๋ํธ, ์ ๋๋ฉ์ด์ , iOS 18 ๋ชจ๋ฒ ์ฌ๋ก๋ฅผ ์ข ํฉ์ ์ผ๋ก ๋ค๋ฃน๋๋ค.

SwiftUI๋ Apple ํ๋ซํผ์ ์ธํฐํ์ด์ค ๊ฐ๋ฐ ๋ฐฉ์์ ๊ทผ๋ณธ์ ์ผ๋ก ๋ฐ๊พธ์์ต๋๋ค. ์ ์ธ์ ๊ตฌ๋ฌธ๊ณผ ๋ค์ดํฐ๋ธ ํตํฉ์ ํตํด ์ด์ ๋ณด๋ค ํจ์ฌ ์ ์ ์ฝ๋๋ก ์ธ๋ จ๋ ์ ํ๋ฆฌ์ผ์ด์ ์ ๊ตฌ์ถํ ์ ์์ต๋๋ค. iOS 18์์๋ ์ฑ๋ฅ์ ๋ํญ์ ์ธ ํฅ์๊ณผ ์๋ก์ด ๊ธฐ๋ฅ์ด ๋์ ๋์์ต๋๋ค.
SwiftUI๋ iOS 18์์ ์์ ํ ์ฑ์๊ธฐ์ ์ ์ด๋ค์์ต๋๋ค. ๋ ๋๋ง ์ต์ ํ, ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ ๊ฐ์ , UIKit ํตํฉ ๊ฐ์ํ๊ฐ ์ด๋ฃจ์ด์ก์ผ๋ฉฐ, ์ ๊ท iOS ์ ํ๋ฆฌ์ผ์ด์ ๊ฐ๋ฐ์ ํ์ค์ผ๋ก ์๋ฆฌ ์ก์์ต๋๋ค.
์ ์ธ์ ํจ๋ฌ๋ค์ ์ดํดํ๊ธฐ
์ฝ๋๋ฅผ ์์ฑํ๊ธฐ ์ ์ SwiftUI์ ๋ณธ์ง์ ์ธ ํน์ง์ ํ์ ํด์ผ ํฉ๋๋ค. UIKit(๊ธฐ์กด ํ๋ ์์ํฌ)์์๋ ์ธํฐํ์ด์ค๋ฅผ ๋จ๊ณ๋ณ๋ก ๊ตฌ์ถํ๋๋ก ์ง์ํด์ผ ํ์ต๋๋ค. "๋ ์ด๋ธ์ ์์ฑํ๊ณ , ์ฌ๊ธฐ์ ๋ฐฐ์นํ๊ณ , ์ฌ์ฉ์๊ฐ ํด๋ฆญํ๋ฉด ์์์ ๋ณ๊ฒฝํ๋ผ"๋ ์์ ๋๋ค. ์ด๊ฒ์ด ๋ช ๋ นํ ํจ๋ฌ๋ค์์ ๋๋ค.
SwiftUI๋ ๋ค๋ฅธ ์ ๊ทผ ๋ฐฉ์์ ์ทจํฉ๋๋ค. ํ์ํ๊ณ ์ ํ๋ ๋ด์ฉ์ ๊ธฐ์ ํ๋ฉด ํ๋ ์์ํฌ๊ฐ ๋๋จธ์ง๋ฅผ ์ฒ๋ฆฌํฉ๋๋ค. ๋ด๋น๊ฒ์ด์ ์์ ๊ฒฝ๋ก๋ฅผ ํ๋์ฉ ์๋ดํ๋ ๊ฒ(๋ช ๋ นํ)๊ณผ ๋ชฉ์ ์ง๋ง ๋งํ๋ ๊ฒ(์ ์ธํ)์ ์ฐจ์ด์ ์ ์ฌํฉ๋๋ค.
์ฒซ ๋ฒ์งธ View ๋ง๋ค๊ธฐ
SwiftUI์์ ๋ชจ๋ ์ธํฐํ์ด์ค ์์๋ View์
๋๋ค. View๋ ํ๋ฉด์ ํ์๋ ๋ด์ฉ์ ๊ธฐ์ ํ๋ struct์
๋๋ค. ์ ๋ชฉ, ๋ถ์ ๋ชฉ, ๋ฒํผ์ด ํฌํจ๋ ์ฒซ ๋ฒ์งธ ํ๋ฉด์ ๋ง๋ค์ด ๋ณด๊ฒ ์ต๋๋ค.
import SwiftUI
// Each screen is a struct that implements the View protocol
struct ContentView: View {
// The "body" property describes what the view displays
// "some View" means "a type of View, but Swift figures it out automatically"
var body: some View {
// VStack = "Vertical Stack": stacks elements vertically
// spacing: 20 = 20 points of space between each element
VStack(spacing: 20) {
// A simple text with style modifiers
Text("Welcome to SharpSkill")
.font(.largeTitle) // Large title font
.fontWeight(.bold) // Bold text
Text("Prepare for your iOS interviews")
.font(.subheadline) // Smaller font
.foregroundColor(.secondary) // Gray secondary color
// Button with an action (closure) and a label
Button("Get Started") {
print("Button tapped!")
}
.buttonStyle(.borderedProminent) // Filled blue button style
}
.padding() // Adds margins around the VStack
}
}์ด ๊ตฌ์กฐ๋ฅผ ์ดํด๋ณด๋ฉด, ํ์ํ ์์(ํ
์คํธ, ๋ฒํผ)๋ฅผ ์ ์ธํ๊ณ , ์ธ๋ก๋ก ๋ฐฐ์นํ๊ณ (VStack), ๋ชจ๋ํ์ด์ด(.font(), .padding())๋ก ์คํ์ผ์ ์ ์ฉํ๊ณ ์์ต๋๋ค.
ํต์ฌ ํฌ์ธํธ: SwiftUI์์๋ UI ๊ฐ์ฒด๋ฅผ "์์ฑ"ํ๋ ๊ฒ์ด ์๋๋ผ, ์ํ๋ ์ธํฐํ์ด์ค๋ฅผ ๊ธฐ์ ํฉ๋๋ค. ์ค์ ์์์ ์์ฑ, ๊ฐฑ์ , ์๋ฉธ์ SwiftUI๊ฐ ๋ด๋นํฉ๋๋ค.
๋ชจ๋ํ์ด์ด: View ๋ณํํ๊ธฐ
๋ชจ๋ํ์ด์ด๋ View ๋ค์ ์ฒด์ด๋ํ์ฌ ๋ณํ์ ์ ์ฉํ๋ ๋ฉ์๋์ ๋๋ค. Instagram ํํฐ๋ฅผ ์์๋๋ก ์ ์ฉํ๋ ๊ฒ๊ณผ ๋น์ทํ๋ค๊ณ ์๊ฐํ๋ฉด ๋ฉ๋๋ค. ์์๊ฐ ์ค์ํฉ๋๋ค. ๊ฐ ๋ชจ๋ํ์ด์ด๊ฐ ์ด์ View๋ฅผ ๊ฐ์ธ๋ ์๋ก์ด View๋ฅผ ์์ฑํ๊ธฐ ๋๋ฌธ์ ๋๋ค.
์์๊ฐ ์ ์ค์ํ์ง ๋ณด์ฌ์ฃผ๋ ์์ ๋ฅผ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
// Example 1: padding THEN background
Text("SwiftUI")
.padding() // 1. Adds 16pt of space around the text
.background(.blue) // 2. Blue background covers text + padding
.foregroundColor(.white)
// Result: white text on blue rectangle with margins
// Example 2: background THEN padding (reversed order!)
Text("SwiftUI")
.background(.blue) // 1. Tight blue background around text only
.padding() // 2. Transparent padding around blue background
.foregroundColor(.white)
// Result: white text on small blue rectangle, surrounded by empty space์ฐจ์ด์ ์ ๋ช ํํฉ๋๋ค. ์ฒซ ๋ฒ์งธ ์์ ์์๋ padding์ด ๋ฐฐ๊ฒฝ์ "์์ชฝ"์ ์๊ณ , ๋ ๋ฒ์งธ์์๋ "๋ฐ๊นฅ์ชฝ"์ ์์ต๋๋ค. ๋ฏธ๋ฌํ ์ฐจ์ด์ด์ง๋ง ๋ ์ด์์์ ์ ํํ๊ฒ ์ ์ดํ๋ ๋ฐ ํ์์ ์ธ ์ง์์ ๋๋ค.
Stack์ ํ์ฉํ ์ธํฐํ์ด์ค ๊ตฌ์ฑ
SwiftUI๋ View๋ฅผ ๊ตฌ์ฑํ๊ธฐ ์ํ ์ธ ๊ฐ์ง ์ฃผ์ ์ปจํ ์ด๋๋ฅผ ์ ๊ณตํฉ๋๋ค. ์ฝํ ์ธ ๋ฅผ ์๋ก ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ๋ฐฐ์นํ๋ ์์๋ผ๊ณ ์๊ฐํ๋ฉด ๋ฉ๋๋ค.
VStack, HStack, ZStack
- VStack (Vertical Stack): ์์๋ฅผ ์์์ ์๋๋ก ์์ต๋๋ค
- HStack (Horizontal Stack): ์์๋ฅผ ์ผ์ชฝ์์ ์ค๋ฅธ์ชฝ์ผ๋ก ๋์ดํฉ๋๋ค
- ZStack (Z์ถ Stack): ์์๋ฅผ ๊ฒน์ณ์ ๋ฐฐ์นํฉ๋๋ค
์ธ ๊ฐ์ง Stack์ ๋ชจ๋ ์กฐํฉํ์ฌ ์ฌ์ฉ์ ํ๋กํ ์นด๋๋ฅผ ๊ตฌ์ถํด ๋ณด๊ฒ ์ต๋๋ค. ์ฌ์ง ์์ ์ธ์ฆ ๋ฐฐ์ง๋ฅผ ๊ฒน์น๊ณ , ์์ ์ฌ์ฉ์ ์ด๋ฆ๊ณผ ์ง์ฑ ์ ํ์ํ๋ ๊ฒ์ด ๋ชฉํ์ ๋๋ค.
struct ProfileCard: View {
var body: some View {
// Main HStack: photo on left, info on right
HStack(spacing: 16) {
// ZStack to overlay the badge on the photo
ZStack(alignment: .bottomTrailing) {
// Profile image (circle)
Image("avatar")
.resizable()
.frame(width: 60, height: 60)
.clipShape(Circle())
// Green "verified" badge at bottom right
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.background(Circle().fill(.white)) // White circle behind
}
// VStack to stack name and role vertically
VStack(alignment: .leading, spacing: 4) {
Text("Marie Dupont")
.font(.headline)
Text("iOS Developer")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Spacer pushes everything to the left
Spacer()
// Chevron on right indicates it's tappable
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}์ฌ๊ธฐ์ ํต์ฌ์ Spacer()์
๋๋ค. ์ฌ์ฉ ๊ฐ๋ฅํ ๋ชจ๋ ๊ณต๊ฐ์ ์ฐจ์งํ๋ ๋ณด์ด์ง ์๋ ์์์
๋๋ค. Spacer๊ฐ ์์ผ๋ฉด ์์๋ค์ด ์ค์์ ๋ฐฐ์น๋์ง๋ง, Spacer๋ฅผ ์ฌ์ฉํ๋ฉด ์์๋ค์ด ์ผ์ชฝ์ผ๋ก ๋ฐ๋ ค๋๊ณ ์
ฐ๋ธ๋ก ์ด ์ค๋ฅธ์ชฝ ๋์ ๊ณ ์ ๋ฉ๋๋ค.
Xcode์์ ์์์ View๋ฅผ โ + ํด๋ฆญํ๋ฉด ๋น์ฃผ์ผ ์ธ์คํํฐ์ ์ ๊ทผํ ์ ์์ต๋๋ค. ์ฝ๋๋ฅผ ์ง์ ์
๋ ฅํ์ง ์๊ณ ๋ ๋ชจ๋ํ์ด์ด๋ฅผ ์ถ๊ฐํ ์ ์์ต๋๋ค.
@State์ @Binding์ ํ์ฉํ ์ํ ๊ด๋ฆฌ
์ํ ๊ด๋ฆฌ๋ SwiftUI์ ํต์ฌ์ ๋๋ค. ์ํ๋ ๋ณ๊ฒฝ๋ ์ ์์ผ๋ฉฐ ์ธํฐํ์ด์ค๋ฅผ ๊ฐฑ์ ํด์ผ ํ๋ ๋ฐ์ดํฐ๋ฅผ ์๋ฏธํฉ๋๋ค. ์ํ๊ฐ ๋ณ๊ฒฝ๋๋ฉด SwiftUI๋ ์ํฅ์ ๋ฐ๋ View๋ฅผ ์๋์ผ๋ก ์ฌ๊ณ์ฐํฉ๋๋ค.
@State: View์ ๋ก์ปฌ ์ํ
@State๋ ํ๋กํผํฐ ๋ํผ๋ก, SwiftUI์ "์ด ๋ณ์๋ฅผ ๊ฐ์ํ๊ณ , ๋ณ๊ฒฝ๋๋ฉด View๋ฅผ ๊ฐฑ์ ํ์ญ์์ค"๋ผ๊ณ ์ ๋ฌํฉ๋๋ค. ๋จ์ผ View์ ๋ก์ปฌ ์ํ์ ์ ํฉํฉ๋๋ค.
๋ํํ ์นด์ดํฐ๋ฅผ ๋ง๋ค์ด ์ด ๋ฉ์ปค๋์ฆ์ ์ดํดํด ๋ณด๊ฒ ์ต๋๋ค.
struct CounterView: View {
// @State creates a "source of truth" for this view
// private because state shouldn't be modified from outside
@State private var count = 0
var body: some View {
VStack(spacing: 30) {
// This Text updates automatically when count changes
Text("\(count)")
.font(.system(size: 72, weight: .bold))
HStack(spacing: 40) {
// Decrement button
Button(action: {
count -= 1 // Modifies state โ view refreshes
}) {
Image(systemName: "minus.circle.fill")
.font(.largeTitle)
}
// Increment button
Button(action: {
count += 1
}) {
Image(systemName: "plus.circle.fill")
.font(.largeTitle)
}
}
}
}
}๋ฒํผ์ ํญํ๋ฉด count๊ฐ ๋ณ๊ฒฝ๋ฉ๋๋ค. SwiftUI๋ ์ด ๋ณ๊ฒฝ์ ๊ฐ์งํ๊ณ body๋ฅผ ๋ค์ ์คํํ์ฌ ํ๋ฉด์ ๊ฐฑ์ ํฉ๋๋ค. ๋ ์ด๋ธ์ ์๋์ผ๋ก ์
๋ฐ์ดํธํ ํ์๊ฐ ์์ต๋๋ค. ๋ชจ๋ ๊ฒ์ด ์๋์ผ๋ก ์ฒ๋ฆฌ๋ฉ๋๋ค.
@Binding: View ๊ฐ ์ํ ๊ณต์ ํ๊ธฐ
์์ View๊ฐ ๋ถ๋ชจ View์ ์ํ๋ฅผ ๋ณ๊ฒฝํด์ผ ํ๋ ๊ฒฝ์ฐ๊ฐ ์์ต๋๋ค. ์ด๋ @Binding์ด ํ์ํฉ๋๋ค. ๊ธฐ์กด @State์ ๋ํ ์๋ฐฉํฅ ์ฐ๊ฒฐ์ ์์ฑํฉ๋๋ค.
๊ตฌ์ฒด์ ์ธ ์์ ๋ก, ์ค์๊ฐ ์ ํจ์ฑ ๊ฒ์ฌ๊ฐ ํฌํจ๋ ์ฌ์ฉ์ ์ด๋ฆ ์ ๋ ฅ ํ๋๋ฅผ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
// Parent view: owns the state
struct SignupForm: View {
@State private var username = "" // Source of truth
@State private var isValid = false // Validation state
var body: some View {
VStack(spacing: 20) {
// We pass BINDINGS (with $) to the child view
UsernameField(username: $username, isValid: $isValid)
Button("Create Account") {
// Submit the form
}
.disabled(!isValid) // Disabled if invalid
.buttonStyle(.borderedProminent)
}
.padding()
}
}์์ View๋ ๋ฐ์ธ๋ฉ์ ์ ๋ฌ๋ฐ์ ๋ณ๊ฒฝํ ์ ์์ต๋๋ค.
// Child view: receives and modifies state via @Binding
struct UsernameField: View {
@Binding var username: String // Connection to parent's @State
@Binding var isValid: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
.onChange(of: username) { oldValue, newValue in
// Validation: at least 3 characters
isValid = newValue.count >= 3
}
// Visual feedback
HStack {
Image(systemName: isValid ? "checkmark.circle" : "xmark.circle")
Text("Minimum 3 characters")
}
.font(.caption)
.foregroundColor(isValid ? .green : .red)
}
}
}์ฌ์ฉ์๊ฐ TextField์ ์
๋ ฅํ๋ฉด username์ด ๋ฐ์ธ๋ฉ์ ํตํด ๋ณ๊ฒฝ๋ฉ๋๋ค. ๋ถ๋ชจ View๋ ์ด ๋ณ๊ฒฝ ์ฌํญ์ ์ธ์ํ๊ณ ํ์ฉํ ์ ์์ต๋๋ค. ๊น๋ํ ์๋ฐฉํฅ ํต์ ์ด ๊ตฌํ๋ ๊ฒ์
๋๋ค.
@State๋ ๋จ์ํ ๋ก์ปฌ ์ํ์๋ง ์ฌ์ฉํด์ผ ํฉ๋๋ค. ์ฌ๋ฌ ํ๋ฉด์ ๊ฑธ์น ๋ฐ์ดํฐ๋ ๋ณต์กํ ๋ก์ง์๋ @Observable(iOS 17 ์ด์) ๋๋ MVVM ํจํด์ ์ฌ์ฉํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
iOS ๋ฉด์ ์ค๋น๊ฐ ๋์ จ๋์?
์ธํฐ๋ํฐ๋ธ ์๋ฎฌ๋ ์ดํฐ, flashcards, ๊ธฐ์ ํ ์คํธ๋ก ์ฐ์ตํ์ธ์.
๋์ ๋ฆฌ์คํธ ํ์ํ๊ธฐ
๋ฆฌ์คํธ๋ ๋ชจ๋ฐ์ผ ์ฑ ์ด๋์๋ ์กด์ฌํฉ๋๋ค. SwiftUI๋ ๋ค์ดํฐ๋ธ iOS ์คํ์ผ๋ง(๊ตฌ๋ถ์ , ์ค์์ดํ ์ก์
๋ฑ)์ ๊ฐ์ถ ๋ฐ์ดํฐ ์ปฌ๋ ์
ํ์๋ฅผ ์ํด List๋ฅผ ์ ๊ณตํฉ๋๋ค.
๊ฐ๋จํ ๋ฆฌ์คํธ ๋ง๋ค๊ธฐ
๋ฆฌ์คํธ๋ฅผ ํ์ํ๋ ค๋ฉด ๋ฐ์ดํฐ์ ๊ทธ๊ฒ์ ์๋ณํ๋ ๋ฐฉ๋ฒ ๋ ๊ฐ์ง๊ฐ ํ์ํฉ๋๋ค. Identifiable ํ๋กํ ์ฝ์ ํตํด SwiftUI๋ ์ด๋ค View๊ฐ ์ด๋ค ๋ฐ์ดํฐ์ ๋์ํ๋์ง ํ์
ํ ์ ์์ต๋๋ค.
๋จผ์ ๋ฐ์ดํฐ ๋ชจ๋ธ์ ์ ์ํฉ๋๋ค.
// Identifiable lets SwiftUI track each element
struct Interview: Identifiable {
let id = UUID() // Auto-generated unique identifier
let technology: String
let difficulty: String
let questionCount: Int
}์ด์ ๋ฆฌ์คํธ๋ฅผ ์์ฑํฉ๋๋ค. ForEach๋ก ๋ฐ์ดํฐ๋ฅผ ๋ฐ๋ณต ์ฒ๋ฆฌํ๊ณ ๊ฐ ์์์ ๋ํ ํ์ ์์ฑํ๋ ๋ฐฉ์์
๋๋ค.
struct InterviewListView: View {
// Data to display (in reality, this would come from an API)
@State private var interviews = [
Interview(technology: "iOS", difficulty: "Intermediate", questionCount: 25),
Interview(technology: "Android", difficulty: "Advanced", questionCount: 30),
Interview(technology: "React", difficulty: "Beginner", questionCount: 20)
]
var body: some View {
// NavigationStack enables the navigation bar
NavigationStack {
List {
// ForEach iterates over each interview
// Thanks to Identifiable, no need to specify id:
ForEach(interviews) { interview in
InterviewRow(interview: interview)
}
}
.navigationTitle("My Interviews")
}
}
}๊ฐ ํ์ View๋ฅผ ๋ ๋ฆฝ๋ ์ปดํฌ๋ํธ๋ก ๋ถ๋ฆฌํฉ๋๋ค. ๊ฐ๋ ์ฑ๊ณผ ์ฌ์ฌ์ฉ์ฑ์ด ํฅ์๋ฉ๋๋ค.
struct InterviewRow: View {
let interview: Interview
var body: some View {
HStack {
// Left column: title and subtitle
VStack(alignment: .leading, spacing: 4) {
Text(interview.technology)
.font(.headline)
Text(interview.difficulty)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
// Badge with question count
Text("\(interview.questionCount) Q")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
.padding(.vertical, 4)
}
}InterviewRow๋ฅผ ๋
๋ฆฝ๋ struct๋ก ๋ถ๋ฆฌํจ์ผ๋ก์จ ์ฝ๋์ ๊ฐ๋
์ฑ๊ณผ ์ฌ์ฌ์ฉ์ฑ์ด ํฌ๊ฒ ํฅ์๋ฉ๋๋ค. ์ด๊ฒ์ SwiftUI์ ๋ชจ๋ฒ ์ฌ๋ก์
๋๋ค.
์ก์ ์ถ๊ฐ: ์ญ์ ์ ์ฌ์ ๋ ฌ
iOS ๋ฆฌ์คํธ๋ ์ญ์ (์ผ์ชฝ ์ค์์ดํ)์ ์ฌ์ ๋ ฌ(๋๋๊ทธ ์ค ๋๋กญ)์ ๊ธฐ๋ณธ์ ์ผ๋ก ์ง์ํฉ๋๋ค. SwiftUI์์๋ .onDelete์ .onMove๋ก ๊ฐ๋จํ๊ฒ ๊ตฌํํ ์ ์์ต๋๋ค.
struct InterviewListView: View {
@State private var interviews = [/* ... data ... */]
var body: some View {
NavigationStack {
List {
ForEach(interviews) { interview in
InterviewRow(interview: interview)
}
// Swipe to delete
.onDelete(perform: deleteInterview)
// Drag and drop to reorder
.onMove(perform: moveInterview)
}
.navigationTitle("My Interviews")
.toolbar {
// "Edit" button that activates edit mode
EditButton()
}
}
}
// Deletes elements at specified indices
private func deleteInterview(at offsets: IndexSet) {
interviews.remove(atOffsets: offsets)
}
// Moves elements from one position to another
private func moveInterview(from source: IndexSet, to destination: Int) {
interviews.move(fromOffsets: source, toOffset: destination)
}
}๋จ 4์ค์ ์ถ๊ฐ ์ฝ๋(.onDelete, .onMove, EditButton, ๋ ๊ฐ์ ํจ์)๋ง์ผ๋ก ์์ ํ ์ธํฐ๋ํฐ๋ธ ๋ฆฌ์คํธ๊ฐ ์์ฑ๋ฉ๋๋ค. SwiftUI์ ์ง๊ฐ๊ฐ ์ฌ๊ธฐ์ ๋๋ฌ๋ฉ๋๋ค.
์ธํฐํ์ด์ค ์ ๋๋ฉ์ด์
SwiftUI๋ ์ ๋๋ฉ์ด์ ์์ ํ์ํ ์ฑ๋ฅ์ ๋ฐํํฉ๋๋ค. UIKit์์ ๋๋์ ์ฝ๋๊ฐ ํ์ํ๋ ์ ๋๋ฉ์ด์ ๋ ์ฌ๊ธฐ์๋ ๋ชจ๋ ์ ์ธ์ ์ผ๋ก ๊ธฐ์ ํ ์ ์์ต๋๋ค. ์ต์ข ์ํ๋ฅผ ๊ธฐ์ ํ๋ฉด SwiftUI๊ฐ ์ ํ์ ์ฒ๋ฆฌํฉ๋๋ค.
withAnimation์ ํ์ฉํ ์์์ ์ ๋๋ฉ์ด์
์ ๋๋ฉ์ด์
์ ์ ์ฉํ๋ ๊ฐ์ฅ ๊ฐ๋จํ ๋ฐฉ๋ฒ์ ์ํ ๋ณ๊ฒฝ์ withAnimation์ผ๋ก ๊ฐ์ธ๋ ๊ฒ์
๋๋ค. SwiftUI๊ฐ ๋ณ๊ฒฝ ์ฌํญ์ ๊ฐ์งํ๊ณ ์ํฅ์ ๋ฐ๋ ์๊ฐ์ ์์ฑ์ ์๋์ผ๋ก ์ ๋๋ฉ์ด์
์ฒ๋ฆฌํฉ๋๋ค.
struct AnimatedCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
// Dimensions change based on state
.frame(
width: isExpanded ? 300 : 150,
height: isExpanded ? 200 : 100
)
Button(isExpanded ? "Collapse" : "Expand") {
// withAnimation animates ALL visual changes
// resulting from this state change
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
isExpanded.toggle()
}
}
.padding(.top)
}
}
}๋ฒํผ์ ํญํ๋ฉด isExpanded๊ฐ ํ ๊ธ๋ฉ๋๋ค. withAnimation ๋๋ถ์ ์ฌ๊ฐํ์ ํฌ๊ธฐ ๋ณ๊ฒฝ์ด ์คํ๋ง ํจ๊ณผ๋ก ์ ๋๋ฉ์ด์
์ฒ๋ฆฌ๋ฉ๋๋ค. ๋ฌด์์ ์ ๋๋ฉ์ด์
ํ ์ง ์ง์ ํ ํ์๊ฐ ์์ต๋๋ค. SwiftUI๊ฐ ์๋์ผ๋ก ํ๋จํฉ๋๋ค.
ํธ๋์ง์ : ํ์์ ์ฌ๋ผ์ง์ ์ ๋๋ฉ์ด์
ํธ๋์ง์
์ View๊ฐ ์ด๋ป๊ฒ ๋ํ๋๊ณ ์ฌ๋ผ์ง๋์ง๋ฅผ ์ ์ํฉ๋๋ค. ๊ธฐ๋ณธ๊ฐ์ ํ์ด๋(opacity)์ด์ง๋ง ๋ง์ถค ์ค์ ์ด ๊ฐ๋ฅํฉ๋๋ค.
struct TransitionDemo: View {
@State private var showDetails = false
var body: some View {
VStack(spacing: 20) {
Button("Show Details") {
withAnimation(.easeInOut(duration: 0.3)) {
showDetails.toggle()
}
}
// This view appears/disappears with a transition
if showDetails {
DetailCard()
// Asymmetric transition: different for entry and exit
.transition(
.asymmetric(
insertion: .scale.combined(with: .opacity), // Entry: zoom + fade
removal: .slide // Exit: slide
)
)
}
}
}
}
struct DetailCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Interview Details")
.font(.headline)
Text("25 questions โข 45 minutes")
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(16)
}
}์ด ์์ ์์ ์นด๋๋ ์ค์์์ ํ๋(scale + opacity)๋๋ฉฐ ๋ํ๋๊ณ , ์์ผ๋ก ์ฌ๋ผ์ด๋(slide)ํ๋ฉฐ ์ฌ๋ผ์ง๋๋ค. ์ด๋ฌํ ๋ง์ดํฌ๋ก ์ ๋๋ฉ์ด์ ์ด ์ธํฐํ์ด์ค์ ์๋๊ฐ๊ณผ ์ ๋ฌธ์ ์ธ ๋๋์ ๋ถ์ฌํฉ๋๋ค.
SwiftUI๋ ์ ๋๋ฉ์ด์
์ ์๋์ผ๋ก ์ต์ ํํฉ๋๋ค. ์์ฐ์ค๋ฌ์ด ๋์์ ์ํด .spring()์ ์ฌ์ฉํ๊ณ , ์ฌ์ฉ์์๊ฒ ๋ถํธ์ ์ฃผ๋ ๊ณผ๋ํ๊ฒ ๊ธด ์ ๋๋ฉ์ด์
(0.5์ด ์ด์)์ ํผํด์ผ ํฉ๋๋ค.
๋น๋๊ธฐ ๋ฐ์ดํฐ ๋ก๋ฉ
์ค์ ์ ํ๋ฆฌ์ผ์ด์
์์ ๋ฐ์ดํฐ๋ ๋๋ถ๋ถ API์์ ๊ฐ์ ธ์ต๋๋ค. Swift Concurrency(async/await)๋ .task ๋ชจ๋ํ์ด์ด๋ฅผ ํตํด SwiftUI์ ์ํํ๊ฒ ํตํฉ๋ฉ๋๋ค.
Loading / Error / Success ํจํด
API ๋ฐ์ดํฐ๋ฅผ ํ์ํ๊ธฐ ์ํ ํ์ค ํจํด์ ์๊ฐํฉ๋๋ค. ๋ก๋ฉ ์ค, ์ค๋ฅ, ์ฑ๊ณต์ ์ธ ๊ฐ์ง ์ํ๋ฅผ ์ฒ๋ฆฌํฉ๋๋ค.
struct AsyncDataView: View {
@State private var questions: [Question] = []
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
Group {
if isLoading {
// State: loading in progress
ProgressView("Loading...")
} else if let error = errorMessage {
// State: error
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.orange)
Text(error)
Button("Retry") {
Task { await loadQuestions() }
}
}
} else {
// State: success, display data
List(questions) { question in
Text(question.title)
}
}
}
// .task runs automatically when the view appears
.task {
await loadQuestions()
}
// Pull-to-refresh
.refreshable {
await loadQuestions()
}
}
private func loadQuestions() async {
isLoading = true
errorMessage = nil
do {
// Async API call
questions = try await QuestionService.shared.fetchQuestions()
} catch {
errorMessage = "Unable to load questions"
}
isLoading = false
}
}.task ๋ชจ๋ํ์ด์ด๋ ํ์์ ์
๋๋ค. View๊ฐ ํ์๋ ๋ ๋น๋๊ธฐ ํ์คํฌ๋ฅผ ์คํํ๊ณ , View๊ฐ ์ฌ๋ผ์ง๋ฉด ์๋์ผ๋ก ์ทจ์ํฉ๋๋ค. ๋ฉ๋ชจ๋ฆฌ ๋์์ ์ํ์ด ์์ต๋๋ค.
๊ฒฐ๋ก
SwiftUI๋ ๋ชจ๋ iOS ๊ฐ๋ฐ์์ ํ์์ ์ธ ์กด์ฌ๊ฐ ๋์์ต๋๋ค. iOS 18์ ํตํด ํ๋ก๋์ ํ๊ฒฝ์ ์ ํ๋ฆฌ์ผ์ด์ ์ ์ถฉ๋ถํ ์ฑ์๋์ ๋๋ฌํ์ต๋๋ค.
ํต์ฌ ์์ฝ
- ์ ์ธ์ ํจ๋ฌ๋ค์: ๊ตฌ์ถ ๋ฐฉ๋ฒ์ด ์๋, ํ์ํ๊ณ ์ถ์ ๋ด์ฉ์ ๊ธฐ์ ํ๋ค
- @State์ @Binding: ์ํ๋ฅผ ๋ฆฌ์กํฐ๋ธํ๊ฒ ๊ด๋ฆฌํ๊ณ View ๊ฐ์ ์ ๋ฌํ๋ค
- Stack: VStack, HStack, ZStack์ ์กฐํฉํ์ฌ ์ ์ฐํ ๋ ์ด์์์ ๊ตฌ์ถํ๋ค
- List: ์ต์ํ์ ์ฝ๋๋ก ๋ค์ดํฐ๋ธ ์ธํฐ๋์ ์ด ํฌํจ๋ ์ปฌ๋ ์ ์ ํ์ํ๋ค
- ์ ๋๋ฉ์ด์
:
withAnimation์ผ๋ก ๋ถ๋๋ฌ์ด ์๋ ์ ํ์ ๊ตฌํํ๋ค - Async/await:
.task๋ก ๋ฐ์ดํฐ๋ฅผ ๋ก๋ฉํ๊ณ ๋ก๋ฉ/์๋ฌ ์ํ๋ฅผ ์ฒ๋ฆฌํ๋ค
์ฒดํฌ๋ฆฌ์คํธ
- ๋ช ๋ นํ(UIKit)๊ณผ ์ ์ธํ(SwiftUI)์ ์ฐจ์ด๋ฅผ ์ดํดํ๊ณ ์๋ค
- ๋ชจ๋ํ์ด์ด์ ์ ์ฉ ์์๋ฅผ ์๋ฌํ๊ณ ์๋ค
- @State, @Binding, @Observable์ ์ฌ์ฉ ๊ตฌ๋ถ์ ํ์ ํ๊ณ ์๋ค
- Stack์ ํ์ฉํ ๋ ์ด์์ ๊ตฌ์ถ์ด ๊ฐ๋ฅํ๋ค
- ์ก์ ์ด ํฌํจ๋ ๋ฆฌ์คํธ(์ญ์ , ์ด๋)๋ฅผ ๊ตฌํํ ์ ์๋ค
- withAnimation์ ํ์ฉํ ์ํ ๋ณ๊ฒฝ ์ ๋๋ฉ์ด์ ์ ์ ์ฉํ ์ ์๋ค
์ฐ์ต์ ์์ํ์ธ์!
๋ฉด์ ์๋ฎฌ๋ ์ดํฐ์ ๊ธฐ์ ํ ์คํธ๋ก ์ง์์ ํ ์คํธํ์ธ์.
SwiftUI๋ Apple ์ํ๊ณ ์ ๋ฐ์ ๊ฑธ์ณ ์ธ๋ จ๋ ์ ํ๋ฆฌ์ผ์ด์ ์ ๊ตฌ์ถํ ์ ์๋ ๊ธธ์ ์ด์ด์ค๋๋ค. ๊ฐ์ฅ ์ข์ ํ์ต ๋ฐฉ๋ฒ์ ์ค์ฒ์ ๋๋ค. ์์ ๊ฐ์ธ ํ๋ก์ ํธ๋ฅผ ๋ง๋ค๊ณ ์ด ๊ธ์์ ์๊ฐํ ๊ฐ ๊ฐ๋ ์ ์ง์ ์ํํด ๋ณด์๊ธฐ ๋ฐ๋๋๋ค.
ํ๊ทธ
๊ณต์
๊ด๋ จ ๊ธฐ์ฌ

SwiftUI ์ฑ๋ฅ: LazyVStack๊ณผ ๋ณต์กํ ๋ฆฌ์คํธ ์ต์ ํ
LazyVStack๊ณผ SwiftUI ๋ฆฌ์คํธ๋ฅผ ์ํ ์ต์ ํ ๊ธฐ๋ฒ. ๋ฉ๋ชจ๋ฆฌ ์๋น๋ฅผ ์ค์ด๊ณ ์คํฌ๋กค ์ฑ๋ฅ์ ํฅ์์ํค๋ฉฐ ํํ ํจ์ ์ ํผํฉ๋๋ค.

SwiftUI ์ปค์คํ ViewModifier: ๋์์ธ ์์คํ ์ ์ํ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ํจํด
์ผ๊ด๋ ๋์์ธ ์์คํ ์ ์ํด SwiftUI์์ ์ปค์คํ ViewModifier๋ฅผ ๊ตฌ์ถํฉ๋๋ค. iOS ๋ทฐ๋ฅผ ํจ์จ์ ์ผ๋ก ์คํ์ผ๋งํ๊ธฐ ์ํ ํจํด, ๋ฒ ์คํธ ํ๋ํฐ์ค, ์ค์ฉ์ ์ธ ์์๋ฅผ ๋ค๋ฃน๋๋ค.

SwiftUI @Observable vs @State: 2026๋ ์ ๋ฌด์์ ์ธ์ ์ฌ์ฉํ ๊น
SwiftUI์์ @Observable๊ณผ @State์ ์ฐจ์ด๋ฅผ ๋ง์คํฐํ๊ณ iOS ์ฑ์ ์ ํฉํ ์ํ ๊ด๋ฆฌ ๋๊ตฌ๋ฅผ ์ ํํด๋ณด์ธ์.