ROONTAMS
ROONTAMS
ROONTAMS
전체 방문자
오늘
어제
  • 분류 전체보기 (13)
    • Unity : 개발 (0)
    • 강의 (12)
      • iOS개발 강의 (6)
      • React 강의 (1)
      • 컴퓨터 구조 (5)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

인기 글

태그

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
ROONTAMS

ROONTAMS

강의/iOS개발 강의

SwiftUI - Part2

2025. 9. 16. 00:18
SwiftUI 레이아웃 시스템 완벽 가이드

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 설계 및 구현

이번 주에 학습한 레이아웃 시스템은 앞으로 만들 모든 앱의 기초가 됩니다. 충분히 연습하세요!

소프트웨어스튜디오2 - iOS 개발 | 3주차: SwiftUI 레이아웃 시스템

© 2025 동서대학교 소프트웨어학과

'강의 > 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
    '강의/iOS개발 강의' 카테고리의 다른 글
    • SwiftUI 컴포넌트 가이드
    • Week4_가위바위보 게임
    • SwitfUI - Part 1
    • 2_Swift 기초 문법 완벽 가이드
    ROONTAMS
    ROONTAMS

    티스토리툴바