SwiftUI 레이아웃 시스템 완벽 가이드
소프트웨어스튜디오2 - 3주차 교재
📚 목차
- 3.1 스택(Stack) 완벽 이해
- 3.2 공간 제어 마스터
- 3.3 스크롤 가능한 컨테이너
- 3.4 동적 UI 생성
- 3.5 종합 프로젝트: 프로필 카드 앱
3.1 스택(Stack) 완벽 이해
학습목표: HStack, VStack, ZStack을 활용하여 복잡한 레이아웃을 구성할 수 있다.
📦 HStack - 수평 배치의 기본
HStack은 자식 뷰들을 수평으로 배치하는 컨테이너입니다. 왼쪽에서 오른쪽으로 순서대로 배치되며, alignment와 spacing을 통해 세밀한 제어가 가능합니다.
기본 사용법
struct HStackBasic: View { var body: some View { // 기본 HStack - 중앙 정렬, 기본 간격 HStack { Text("첫 번째") Text("두 번째") Text("세 번째") } .padding() .background(Color.gray.opacity(0.2)) } }
alignment 파라미터 활용
struct HStackAlignment: View { var body: some View { VStack(spacing: 20) { // top 정렬 HStack(alignment: .top) { Text("짧은") .font(.title) Text("긴\n텍스트\n입니다") .font(.body) } .frame(height: 100) .background(Color.blue.opacity(0.1)) // center 정렬 (기본값) HStack(alignment: .center) { Image(systemName: "star.fill") .font(.largeTitle) Text("중앙 정렬") } .background(Color.green.opacity(0.1)) // bottom 정렬 HStack(alignment: .bottom) { Text("바닥") .font(.caption) Text("정렬") .font(.title) } .frame(height: 60) .background(Color.red.opacity(0.1)) } .padding() } }
💡 Pro Tip: HStack의 alignment는 수직 정렬을 제어합니다. 수평 정렬은 Spacer()를 활용하세요.
spacing 제어하기
struct HStackSpacing: View { @State private var spacing: Double = 10 var body: some View { VStack { // 동적 spacing HStack(spacing: spacing) { ForEach(0..<4) { index in RoundedRectangle(cornerRadius: 10) .fill(Color.blue) .frame(width: 60, height: 60) .overlay( Text("\(index + 1)") .foregroundColor(.white) .bold() ) } } // Slider로 spacing 조절 Slider(value: $spacing, in: 0...50) .padding() Text("Spacing: \(Int(spacing))pt") .font(.caption) } .padding() } }
📚 VStack - 수직 배치의 기본
VStack은 자식 뷰들을 수직으로 배치합니다. 위에서 아래로 순서대로 배치되며, 수평 정렬을 제어할 수 있습니다.
alignment 옵션 비교
struct VStackAlignment: View { var body: some View { HStack(spacing: 30) { // leading 정렬 VStack(alignment: .leading, spacing: 10) { Text("왼쪽") Text("정렬된") Text("텍스트입니다") } .frame(width: 120) .padding() .background(Color.orange.opacity(0.2)) // center 정렬 VStack(alignment: .center, spacing: 10) { Text("중앙") Text("정렬된") Text("텍스트") } .frame(width: 120) .padding() .background(Color.blue.opacity(0.2)) // trailing 정렬 VStack(alignment: .trailing, spacing: 10) { Text("오른쪽") Text("정렬") Text("텍스트입니다") } .frame(width: 120) .padding() .background(Color.green.opacity(0.2)) } } }
🎯 실습 과제 3.1.1
다음 요구사항을 만족하는 뷰를 만들어보세요:
- 프로필 이미지(SF Symbol 사용)와 이름을 수평 배치
- 이름 아래에 직책과 이메일을 수직 배치
- 전체를 카드 형태로 꾸미기
🎨 ZStack - 겹침 레이아웃
ZStack은 자식 뷰들을 z축 방향으로 겹쳐서 배치합니다. 먼저 선언된 뷰가 아래에, 나중에 선언된 뷰가 위에 위치합니다.
기본 겹침과 정렬
struct ZStackBasic: View { var body: some View { ZStack { // 배경 레이어 RoundedRectangle(cornerRadius: 20) .fill( LinearGradient( colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 300, height: 200) // 중간 레이어 Circle() .fill(Color.white.opacity(0.3)) .frame(width: 150, height: 150) // 최상위 레이어 VStack { Image(systemName: "star.fill") .font(.system(size: 50)) .foregroundColor(.yellow) Text("Premium") .font(.title) .bold() .foregroundColor(.white) } } } }
ZStack alignment 활용
struct ZStackAlignment: View { var body: some View { ZStack(alignment: .topTrailing) { // 메인 콘텐츠 RoundedRectangle(cornerRadius: 15) .fill(Color.blue) .frame(width: 200, height: 150) // 배지 (오른쪽 상단) Circle() .fill(Color.red) .frame(width: 30, height: 30) .overlay( Text("3") .foregroundColor(.white) .bold() ) .offset(x: 10, y: -10) } } }
📊 스택 비교표
| 스택 유형 | 배치 방향 | 주요 alignment | 사용 예시 |
|---|---|---|---|
| HStack | 수평 (→) | top, center, bottom | 툴바, 버튼 그룹 |
| VStack | 수직 (↓) | leading, center, trailing | 폼, 리스트 아이템 |
| ZStack | 깊이 (z축) | 9개 위치 조합 | 오버레이, 배경 |
3.2 공간 제어 마스터
학습목표: Spacer, Padding, Frame을 활용하여 정교한 레이아웃을 구성할 수 있다.
🚀 Spacer의 마법
Spacer는 가능한 모든 공간을 차지하여 다른 뷰들을 밀어내는 특별한 뷰입니다.
Spacer 기본 활용
struct SpacerBasic: View { var body: some View { VStack { // 상단 정렬 HStack { Text("왼쪽") Spacer() Text("오른쪽") } .padding() .background(Color.gray.opacity(0.2)) // 중앙 정렬 HStack { Spacer() Text("중앙") Spacer() } .padding() .background(Color.blue.opacity(0.2)) // 균등 분배 HStack { Spacer() Text("A") Spacer() Text("B") Spacer() Text("C") Spacer() } .padding() .background(Color.green.opacity(0.2)) } } }
minLength로 최소 공간 보장
struct SpacerMinLength: View { @State private var isExpanded = false var body: some View { VStack { HStack { Text("항목") // 최소 20pt 보장 Spacer(minLength: 20) if isExpanded { Text("추가 정보가 표시됩니다") .transition(.slide) } Button(action: { withAnimation { isExpanded.toggle() } }) { Image(systemName: isExpanded ? "chevron.up" : "chevron.down") } } .padding() .background(Color.gray.opacity(0.1)) .cornerRadius(10) } .padding() } }
💡 Pro Tip: Spacer(minLength: 0)를 사용하면 공간이 부족할 때 완전히 사라질 수 있습니다.
📐 Padding 시스템
Padding은 뷰 주변에 여백을 추가하는 가장 기본적인 방법입니다.
다양한 Padding 적용법
struct PaddingVariations: View { var body: some View { VStack(spacing: 20) { // 전체 padding Text("전체 패딩") .padding() // 기본값: 16pt .background(Color.blue.opacity(0.3)) // 특정 방향 padding Text("상단 패딩만") .padding(.top, 30) .background(Color.green.opacity(0.3)) // 수평 padding Text("좌우 패딩") .padding(.horizontal, 40) .background(Color.orange.opacity(0.3)) // 수직 padding Text("상하 패딩") .padding(.vertical, 20) .background(Color.purple.opacity(0.3)) // EdgeInsets로 세밀한 제어 Text("커스텀 패딩") .padding(EdgeInsets( top: 10, leading: 20, bottom: 30, trailing: 40 )) .background(Color.red.opacity(0.3)) } } }
중첩 Padding 활용
struct NestedPadding: View { var body: some View { Text("중첩 패딩 예제") .padding(10) .background(Color.blue) .padding(10) .background(Color.green) .padding(10) .background(Color.orange) .foregroundColor(.white) .font(.headline) } }
🖼 Frame 모디파이어
Frame은 뷰의 크기를 명시적으로 지정하거나 제약을 설정합니다.
고정 크기 vs 유연한 크기
struct FrameTypes: View { var body: some View { VStack(spacing: 20) { // 고정 크기 Text("고정 크기") .frame(width: 200, height: 50) .background(Color.blue) .foregroundColor(.white) // 최대 너비 활용 Text("화면 전체 너비") .frame(maxWidth: .infinity) .padding() .background(Color.green) .foregroundColor(.white) // 최소/최대 제약 Text("유연한 크기") .frame( minWidth: 100, maxWidth: 300, minHeight: 40, maxHeight: 60 ) .background(Color.orange) .foregroundColor(.white) } .padding() } }
Frame alignment 활용
struct FrameAlignment: View { var body: some View { VStack(spacing: 20) { // 왼쪽 상단 정렬 Text("↖") .frame(width: 100, height: 100, alignment: .topLeading) .background(Color.blue.opacity(0.3)) .border(Color.blue) // 중앙 정렬 (기본값) Text("⊕") .frame(width: 100, height: 100, alignment: .center) .background(Color.green.opacity(0.3)) .border(Color.green) // 오른쪽 하단 정렬 Text("↘") .frame(width: 100, height: 100, alignment: .bottomTrailing) .background(Color.orange.opacity(0.3)) .border(Color.orange) } } }
🎯 실습 과제 3.2.1
다음 레이아웃을 구현해보세요:
- 상단: 로고(왼쪽)와 메뉴 버튼(오른쪽)
- 중앙: 메인 콘텐츠 (전체 너비, 수직 중앙)
- 하단: 3개의 탭 버튼 (균등 분배)
- 적절한 padding과 spacing 적용
⚠️ 주의사항: frame(maxWidth: .infinity)를 사용할 때는 부모 뷰의 크기가 명확해야 합니다. 그렇지 않으면 레이아웃이 깨질 수 있습니다.
3.3 스크롤 가능한 컨테이너
학습목표: ScrollView와 List를 활용하여 대량의 데이터를 효율적으로 표시할 수 있다.
📜 ScrollView 구현
ScrollView는 콘텐츠가 화면을 초과할 때 스크롤 가능한 영역을 제공합니다.
기본 ScrollView
struct ScrollViewBasic: View { var body: some View { // 수직 스크롤 (기본값) ScrollView { VStack(spacing: 20) { ForEach(1...20, id: \.self) { index in RoundedRectangle(cornerRadius: 10) .fill(Color.blue.opacity(Double(index) / 20)) .frame(height: 100) .overlay( Text("항목 \(index)") .foregroundColor(.white) .font(.title2) ) } } .padding() } } }
수평 ScrollView
struct HorizontalScrollView: View { let categories = ["전체", "인기", "최신", "추천", "할인", "이벤트"] @State private var selectedCategory = "전체" var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 15) { ForEach(categories, id: \.self) { category in Button(action: { selectedCategory = category }) { Text(category) .padding(.horizontal, 20) .padding(.vertical, 10) .background( selectedCategory == category ? Color.blue : Color.gray.opacity(0.2) ) .foregroundColor( selectedCategory == category ? .white : .primary ) .cornerRadius(20) } } } .padding() } } }
ScrollViewReader로 프로그래매틱 스크롤
struct ScrollViewReaderExample: View { var body: some View { ScrollViewReader { proxy in VStack { // 상단 버튼들 HStack { Button("처음으로") { withAnimation { proxy.scrollTo(1, anchor: .top) } } Button("중간으로") { withAnimation { proxy.scrollTo(50, anchor: .center) } } Button("끝으로") { withAnimation { proxy.scrollTo(100, anchor: .bottom) } } } .padding() ScrollView { VStack(spacing: 10) { ForEach(1...101, id: \.self) { number in Text("항목 \(number)") .frame(maxWidth: .infinity) .padding() .background(Color.blue.opacity(0.1)) .id(number) // ScrollViewReader를 위한 ID } } .padding() } } } } }
📋 List의 강력함
List는 데이터를 효율적으로 표시하는 스크롤 가능한 컨테이너입니다. UITableView와 유사하지만 더 간단합니다.
기본 List 구현
struct ListBasic: View { struct TodoItem: Identifiable { let id = UUID() let title: String var isCompleted: Bool } @State private var todos = [ TodoItem(title: "SwiftUI 공부하기", isCompleted: false), TodoItem(title: "과제 제출하기", isCompleted: true), TodoItem(title: "운동하기", isCompleted: false) ] var body: some View { List { ForEach(todos) { todo in HStack { Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle") .foregroundColor(todo.isCompleted ? .green : .gray) Text(todo.title) .strikethrough(todo.isCompleted) .foregroundColor(todo.isCompleted ? .gray : .primary) Spacer() } .contentShape(Rectangle()) .onTapGesture { if let index = todos.firstIndex(where: { $0.id == todo.id }) { todos[index].isCompleted.toggle() } } } } .listStyle(InsetGroupedListStyle()) } }
Swipe Actions 구현
struct ListSwipeActions: View { @State private var items = ["항목 1", "항목 2", "항목 3", "항목 4"] var body: some View { List { ForEach(items, id: \.self) { item in Text(item) .swipeActions(edge: .trailing) { Button(role: .destructive) { withAnimation { items.removeAll { $0 == item } } } label: { Label("삭제", systemImage: "trash") } Button { // 편집 액션 } label: { Label("편집", systemImage: "pencil") } .tint(.orange) } .swipeActions(edge: .leading) { Button { // 즐겨찾기 액션 } label: { Label("즐겨찾기", systemImage: "star") } .tint(.yellow) } } } } }
Section으로 그룹핑
struct ListWithSections: View { var body: some View { List { Section(header: Text("과일")) { Text("🍎 사과") Text("🍌 바나나") Text("🍊 오렌지") } Section(header: Text("채소"), footer: Text("신선한 채소를 매일 섭취하세요")) { Text("🥕 당근") Text("🥬 양배추") Text("🥒 오이") } Section(header: Text("음료")) { Text("☕ 커피") Text("🧃 주스") Text("🥛 우유") } } .listStyle(GroupedListStyle()) } }
📊 ScrollView vs List
| 특징 | ScrollView | List |
|---|---|---|
| 메모리 효율성 | 모든 뷰 로드 | 보이는 뷰만 로드 |
| 커스터마이징 | 완전 자유 | 제한적 |
| 기본 기능 | 스크롤만 | 스와이프, 편집 모드 등 |
| 사용 시기 | 적은 데이터, 커스텀 레이아웃 | 많은 데이터, 표준 리스트 |
3.4 동적 UI 생성
학습목표: ForEach와 조건부 렌더링을 활용하여 동적이고 반응형 UI를 구현할 수 있다.
🔄 ForEach 완벽 활용
ForEach는 컬렉션 데이터를 반복하여 뷰를 생성하는 핵심 도구입니다.
기본 ForEach 패턴
struct ForEachBasic: View { // Range 사용 var body: some View { VStack { // 1. 단순 범위 ForEach(0..<5) { index in Text("인덱스: \(index)") } Divider() // 2. id 파라미터 사용 ForEach(0..<3, id: \.self) { number in Text("번호: \(number)") } } } }
Identifiable 프로토콜 활용
struct ForEachIdentifiable: View { struct Product: Identifiable { let id = UUID() let name: String let price: Int let emoji: String } let products = [ Product(name: "아이폰", price: 1200000, emoji: "📱"), Product(name: "맥북", price: 2500000, emoji: "💻"), Product(name: "에어팟", price: 300000, emoji: "🎧"), Product(name: "애플워치", price: 500000, emoji: "⌚") ] var body: some View { VStack(alignment: .leading, spacing: 15) { Text("제품 목록") .font(.title) .bold() ForEach(products) { product in HStack { Text(product.emoji) .font(.largeTitle) VStack(alignment: .leading) { Text(product.name) .font(.headline) Text("₩\(product.price)") .font(.caption) .foregroundColor(.gray) } Spacer() Button("구매") { // 구매 액션 } .buttonStyle(.borderedProminent) } .padding() .background(Color.gray.opacity(0.1)) .cornerRadius(10) } } .padding() } }
인덱스와 함께 사용하기
struct ForEachWithIndex: View { let items = ["Swift", "SwiftUI", "Combine", "Core Data"] var body: some View { VStack(alignment: .leading) { // 방법 1: enumerated() 사용 ForEach(Array(items.enumerated()), id: \.offset) { index, item in HStack { Text("\(index + 1).") .font(.headline) .foregroundColor(.blue) Text(item) Spacer() if index == 0 { Text("기초") .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.green) .foregroundColor(.white) .cornerRadius(5) } } .padding(.vertical, 5) } Divider().padding(.vertical) // 방법 2: indices 사용 ForEach(items.indices, id: \.self) { index in Text("[\(index)] \(items[index])") .padding(5) .frame(maxWidth: .infinity, alignment: .leading) .background( index % 2 == 0 ? Color.blue.opacity(0.1) : Color.clear ) } } .padding() } }
🎭 조건부 렌더링
조건에 따라 다른 뷰를 표시하거나 뷰의 속성을 변경합니다.
if-else 문으로 뷰 분기
struct ConditionalRendering: View { @State private var isLoggedIn = false @State private var username = "" @State private var showDetails = false var body: some View { VStack(spacing: 20) { // 1. 단순 조건부 렌더링 if isLoggedIn { Text("환영합니다, \(username)님!") .font(.title) .foregroundColor(.green) Button("로그아웃") { isLoggedIn = false username = "" } .buttonStyle(.bordered) } else { TextField("사용자 이름", text: $username) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button("로그인") { if !username.isEmpty { isLoggedIn = true } } .buttonStyle(.borderedProminent) .disabled(username.isEmpty) } Divider() // 2. 선택적 뷰 표시 Button(showDetails ? "숨기기" : "상세 정보 보기") { showDetails.toggle() } if showDetails { VStack(alignment: .leading) { Text("상세 정보") .font(.headline) Text("여기에 추가 정보가 표시됩니다.") .font(.caption) .foregroundColor(.gray) } .padding() .background(Color.yellow.opacity(0.1)) .cornerRadius(10) .transition(.slide) // 애니메이션 효과 } } .padding() .animation(.easeInOut, value: showDetails) } }
삼항 연산자 활용
struct TernaryOperator: View { @State private var isDarkMode = false @State private var isImportant = false var body: some View { VStack(spacing: 20) { // 색상 조건부 변경 Text("다크모드 테스트") .padding() .foregroundColor(isDarkMode ? .white : .black) .background(isDarkMode ? Color.black : Color.white) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(isDarkMode ? Color.white : Color.black, lineWidth: 2) ) Toggle("다크모드", isOn: $isDarkMode) .padding(.horizontal) // 크기와 스타일 조건부 변경 Text("중요한 메시지") .font(isImportant ? .title : .body) .fontWeight(isImportant ? .bold : .regular) .foregroundColor(isImportant ? .red : .primary) .scaleEffect(isImportant ? 1.2 : 1.0) .animation(.spring(), value: isImportant) Toggle("중요 표시", isOn: $isImportant) .padding(.horizontal) } .padding() } }
Group으로 조건부 모디파이어
struct GroupConditional: View { @State private var cardStyle = 0 // 0: 기본, 1: 그림자, 2: 테두리 var body: some View { VStack { Picker("카드 스타일", selection: $cardStyle) { Text("기본").tag(0) Text("그림자").tag(1) Text("테두리").tag(2) } .pickerStyle(SegmentedPickerStyle()) .padding() Group { VStack { Image(systemName: "swift") .font(.largeTitle) .foregroundColor(.orange) Text("SwiftUI Card") .font(.title2) .bold() Text("동적 스타일 적용") .font(.caption) .foregroundColor(.gray) } .padding() .frame(width: 200, height: 150) .background(Color.white) } // Group에 조건부 모디파이어 적용 .if(cardStyle == 1) { view in view.shadow(radius: 10) } .if(cardStyle == 2) { view in view.overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.blue, lineWidth: 3) ) } .cornerRadius(10) } } } // View Extension for conditional modifier extension View { @ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View { if condition { transform(self) } else { self } } }
🎯 실습 과제 3.4.1
동적 쇼핑 카트 UI를 구현하세요:
- 상품 목록을 ForEach로 표시
- 각 상품에 수량 조절 버튼 추가
- 장바구니가 비어있을 때와 차있을 때 다른 UI 표시
- 총 금액 자동 계산 및 표시
- 할인 쿠폰 적용 시 조건부 스타일 변경
3.5 종합 프로젝트: 프로필 카드 앱
학습목표: 이번 주에 학습한 모든 레이아웃 기술을 종합하여 실제 앱을 구현할 수 있다.
🎯 프로젝트 개요
소셜 네트워크 스타일의 프로필 카드 앱을 만들어봅시다. 스택, 스크롤뷰, 리스트, 동적 UI를 모두 활용합니다.
최종 완성 코드
import SwiftUI struct ProfileCardApp: View { // MARK: - Models struct User: Identifiable { let id = UUID() let name: String let title: String let profileImage: String let bio: String let skills: [String] let projects: [Project] let isFollowing: Bool } struct Project: Identifiable { let id = UUID() let name: String let description: String let icon: String let color: Color } // MARK: - State @State private var selectedTab = "profile" @State private var showingSettings = false @State private var searchText = "" let users = [ User( name: "김철수", title: "iOS Developer", profileImage: "person.circle.fill", bio: "3년차 iOS 개발자입니다. SwiftUI를 사랑합니다.", skills: ["Swift", "SwiftUI", "UIKit", "Combine"], projects: [ Project(name: "날씨 앱", description: "실시간 날씨 정보", icon: "cloud.sun.fill", color: .blue), Project(name: "Todo 앱", description: "할 일 관리", icon: "checkmark.circle.fill", color: .green), Project(name: "채팅 앱", description: "실시간 메시징", icon: "message.fill", color: .purple) ], isFollowing: false ), // 추가 사용자 데이터... ] // MARK: - Body var body: some View { NavigationView { ScrollView { VStack(spacing: 0) { // Header headerView // Tab Selection tabSelectionView // Content based on selected tab if selectedTab == "profile" { profileContentView } else if selectedTab == "projects" { projectsContentView } else { skillsContentView } } } .navigationBarHidden(true) } } // MARK: - Header View private var headerView: some View { ZStack { // Background gradient LinearGradient( colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing ) .frame(height: 200) VStack { HStack { Text("프로필") .font(.largeTitle) .bold() .foregroundColor(.white) Spacer() Button(action: { showingSettings = true }) { Image(systemName: "gearshape.fill") .font(.title2) .foregroundColor(.white) } } .padding() // Profile Image Image(systemName: users[0].profileImage) .font(.system(size: 80)) .foregroundColor(.white) .background( Circle() .fill(Color.white.opacity(0.3)) .frame(width: 100, height: 100) ) } } } // MARK: - Tab Selection View private var tabSelectionView: some View { HStack(spacing: 0) { tabButton(title: "프로필", id: "profile") tabButton(title: "프로젝트", id: "projects") tabButton(title: "스킬", id: "skills") } .background(Color.gray.opacity(0.1)) } private func tabButton(title: String, id: String) -> some View { Button(action: { selectedTab = id }) { VStack { Text(title) .fontWeight(selectedTab == id ? .bold : .regular) .foregroundColor(selectedTab == id ? .blue : .gray) Rectangle() .fill(selectedTab == id ? Color.blue : Color.clear) .frame(height: 3) } .frame(maxWidth: .infinity) } .buttonStyle(PlainButtonStyle()) } // MARK: - Profile Content private var profileContentView: some View { VStack(alignment: .leading, spacing: 20) { // User Info VStack(alignment: .leading, spacing: 10) { HStack { VStack(alignment: .leading) { Text(users[0].name) .font(.title) .bold() Text(users[0].title) .font(.subheadline) .foregroundColor(.gray) } Spacer() Button(action: { // Follow action }) { Text(users[0].isFollowing ? "팔로잉" : "팔로우") .padding(.horizontal, 20) .padding(.vertical, 8) .background(users[0].isFollowing ? Color.gray : Color.blue) .foregroundColor(.white) .cornerRadius(20) } } Text(users[0].bio) .font(.body) .padding(.top, 5) } .padding() // Stats HStack { statView(value: "1.2K", title: "팔로워") Spacer() statView(value: "342", title: "팔로잉") Spacer() statView(value: "28", title: "프로젝트") } .padding() .background(Color.gray.opacity(0.05)) } } private func statView(value: String, title: String) -> some View { VStack { Text(value) .font(.title2) .bold() Text(title) .font(.caption) .foregroundColor(.gray) } } // MARK: - Projects Content private var projectsContentView: some View { VStack(alignment: .leading, spacing: 15) { Text("프로젝트") .font(.title2) .bold() .padding(.horizontal) .padding(.top) ForEach(users[0].projects) { project in HStack { Image(systemName: project.icon) .font(.title2) .foregroundColor(.white) .frame(width: 50, height: 50) .background(project.color) .cornerRadius(10) VStack(alignment: .leading) { Text(project.name) .font(.headline) Text(project.description) .font(.caption) .foregroundColor(.gray) } Spacer() Image(systemName: "chevron.right") .foregroundColor(.gray) } .padding() .background(Color.gray.opacity(0.05)) .cornerRadius(10) .padding(.horizontal) } } } // MARK: - Skills Content private var skillsContentView: some View { VStack(alignment: .leading, spacing: 15) { Text("기술 스택") .font(.title2) .bold() .padding(.horizontal) .padding(.top) // Skills Grid LazyVGrid( columns: [ GridItem(.flexible()), GridItem(.flexible()) ], spacing: 15 ) { ForEach(users[0].skills, id: \.self) { skill in HStack { Image(systemName: "checkmark.seal.fill") .foregroundColor(.green) Text(skill) .font(.body) Spacer() } .padding() .background(Color.green.opacity(0.1)) .cornerRadius(10) } } .padding(.horizontal) // Skill Progress VStack(alignment: .leading, spacing: 10) { Text("숙련도") .font(.headline) .padding(.top) skillProgressView(skill: "Swift", progress: 0.9) skillProgressView(skill: "SwiftUI", progress: 0.85) skillProgressView(skill: "UIKit", progress: 0.75) skillProgressView(skill: "Combine", progress: 0.6) } .padding() } } private func skillProgressView(skill: String, progress: Double) -> some View { VStack(alignment: .leading, spacing: 5) { HStack { Text(skill) .font(.subheadline) Spacer() Text("\(Int(progress * 100))%") .font(.caption) .foregroundColor(.gray) } GeometryReader { geometry in ZStack(alignment: .leading) { Rectangle() .fill(Color.gray.opacity(0.2)) .frame(height: 8) .cornerRadius(4) Rectangle() .fill( LinearGradient( colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing ) ) .frame(width: geometry.size.width * progress, height: 8) .cornerRadius(4) .animation(.spring(), value: progress) } } .frame(height: 8) } } }
💡 학습 포인트:
- ZStack으로 배경 그라데이션 구현
- HStack/VStack 조합으로 복잡한 레이아웃 구성
- ScrollView로 전체 콘텐츠 스크롤
- ForEach로 동적 리스트 생성
- 조건부 렌더링으로 탭 전환
- GeometryReader로 프로그레스 바 구현
📝 학습 정리
이번 주 핵심 개념 체크리스트
- ✅ HStack, VStack, ZStack의 차이점과 활용법
- ✅ Spacer를 사용한 공간 분배
- ✅ Padding과 Frame으로 정교한 레이아웃
- ✅ ScrollView와 List의 적절한 선택
- ✅ ForEach로 동적 UI 생성
- ✅ 조건부 렌더링으로 상태 기반 UI
- ✅ 실제 앱에서의 종합적 활용
🚀 다음 주 예습
4주차에는 상태 관리와 MVVM 패턴을 학습합니다:
- @State와 @Binding의 차이점
- ObservableObject와 @Published
- MVVM 아키텍처 패턴 이해
- ViewModel 설계 및 구현
이번 주에 학습한 레이아웃 시스템은 앞으로 만들 모든 앱의 기초가 됩니다. 충분히 연습하세요!
'강의 > iOS개발 강의' 카테고리의 다른 글
| 상태 관리와 MVVM 기초 (0) | 2025.09.30 |
|---|---|
| SwiftUI 컴포넌트 가이드 (0) | 2025.09.30 |
| Week4_가위바위보 게임 (0) | 2025.09.23 |
| SwitfUI - Part 1 (0) | 2025.09.16 |
| 2_Swift 기초 문법 완벽 가이드 (0) | 2025.09.09 |