챌린지 2-4 · 6 Topics

✨ 움직이는 UI 만들기

앱이 너무 정적으로 보인다

withAnimation .animation() .transition() matchedGeometryEffect Custom ViewModifier Gesture
시작 전에 던져볼 질문
01
애니메이션이 없는 앱과 있는 앱의 사용자 경험 차이는 무엇인가?
검색 → iOS animation UX importance user perception
02
Spring 애니메이션의 stiffness와 damping은 각각 무엇을 제어하는가?
검색 → SwiftUI spring animation stiffness damping response
03
matchedGeometryEffect는 어떤 원리로 Hero 애니메이션을 구현하는가?
검색 → SwiftUI matchedGeometryEffect hero animation namespace
Topic 01

withAnimation & .animation() — 상태 변화를 부드럽게

@State 변화를 애니메이션으로 감싸면 SwiftUI가 변화 전후를 보간(interpolate)해 부드럽게 만든다
Swift
struct AnimationDemo: View {
    @State private var isExpanded = false
    @State private var rotation: Double = 0
    @State private var scale: CGFloat = 1.0

    var body: some View {
        VStack(spacing: 32) {

            // withAnimation — 클로저 내 모든 상태 변화를 애니메이션
            RoundedRectangle(cornerRadius: 16)
                .fill(Color.orange)
                .frame(
                    width: isExpanded ? 280 : 120,
                    height: isExpanded ? 160 : 60
                )
                .onTapGesture {
                    withAnimation(.spring(response: 0.4,
                                         dampingFraction: 0.7)) {
                        isExpanded.toggle()
                    }
                }

            // .animation() 뷰 모디파이어 — 특정 값이 변할 때
            Image(systemName: "star.fill")
                .font(.system(size: 40))
                .foregroundStyle(.orange)
                .rotationEffect(.degrees(rotation))
                .scaleEffect(scale)
                .animation(.easeInOut(duration: 0.3), value: rotation)
                .animation(.spring(), value: scale)
                .onTapGesture {
                    rotation += 45
                    scale = scale == 1.0 ? 1.5 : 1.0
                }

            // 다양한 애니메이션 커브
            Button("easeIn") {
                withAnimation(.easeIn(duration: 0.5)) { isExpanded.toggle() }
            }
            Button("spring") {
                withAnimation(.spring(
                    response: 0.5,
                    dampingFraction: 0.6,
                    blendDuration: 0
                )) { isExpanded.toggle() }
            }
            Button("delay + repeat") {
                withAnimation(
                    .easeInOut(duration: 0.3)
                    .delay(0.1)
                    .repeatCount(3, autoreverses: true)
                ) { scale = 1.3 }
            }
        }
    }
}
생각해보기
  • withAnimation.animation(value:)의 적용 범위 차이는?
  • .spring(response:dampingFraction:)에서 dampingFraction이 1.0에 가까울수록 어떻게 달라지는가?
  • 애니메이션이 완료된 후 콜백을 받는 방법은? (withAnimation(completion:) iOS 17+)
withAnimation
클로저 내에서 변경되는 모든 @State를 애니메이션으로 감쌈. 여러 상태 동시 애니메이션에 적합
.animation(value:)
특정 값이 변할 때만 해당 뷰에 애니메이션 적용. 더 세밀한 제어 가능. value가 Equatable이어야 함
Animation 커브
.easeIn/.easeOut/.easeInOut: 일정한 가속/감속 | .spring: 물리 기반 탄성 | .linear: 등속
Topic 02

.transition() — 뷰 등장/퇴장 애니메이션

View가 뷰 계층에 추가되거나 제거될 때의 애니메이션을 정의한다
Swift
struct TransitionDemo: View {
    @State private var showBanner = false
    @State private var showCard = false
    @State private var showModal = false

    var body: some View {
        VStack(spacing: 20) {

            // 기본 transition
            Button("배너 토글") {
                withAnimation(.spring()) {
                    showBanner.toggle()
                }
            }
            if showBanner {
                Text("🎉 할인 이벤트 진행 중!")
                    .padding()
                    .background(Color.orange.opacity(0.15))
                    .cornerRadius(12)
                    .transition(.slide)       // 옆에서 밀려 들어옴
            }

            // .scale + .opacity 조합
            Button("카드 토글") {
                withAnimation(.easeOut(duration: 0.3)) {
                    showCard.toggle()
                }
            }
            if showCard {
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color.orange.opacity(0.2))
                    .frame(height: 100)
                    .transition(.scale.combined(with: .opacity))
            }

            // asymmetric — 등장/퇴장 다르게
            Button("모달 토글") {
                withAnimation(.spring(response: 0.45)) {
                    showModal.toggle()
                }
            }
            if showModal {
                Text("아래서 올라오고 위로 사라져요")
                    .padding()
                    .background(Color.orange)
                    .foregroundStyle(.white)
                    .cornerRadius(12)
                    .transition(.asymmetric(
                        insertion: .move(edge: .bottom)
                            .combined(with: .opacity),
                        removal: .move(edge: .top)
                            .combined(with: .opacity)
                    ))
            }
        }
        .padding()
    }
}

// AnyTransition 확장 — 커스텀 트랜지션
extension AnyTransition {
    static var popUp: AnyTransition {
        .scale(scale: 0.1, anchor: .bottom)
        .combined(with: .opacity)
    }
}
생각해보기
  • .transition()이 작동하려면 반드시 withAnimation이 필요한 이유는?
  • .move(edge: .bottom).slide의 차이는?
  • List 내 항목 추가/삭제에 transition을 적용하려면 어떻게 하는가?
내장 Transition
.opacity · .scale · .slide · .move(edge:) · .push(from:) — 조합 가능
.combined(with:)
두 transition을 동시에 적용. .scale.combined(with: .opacity)처럼 체이닝
.asymmetric
등장(insertion)과 퇴장(removal)에 서로 다른 transition 적용. 방향성 있는 UX 연출
Topic 03

matchedGeometryEffect — Hero 애니메이션

두 뷰 사이에 연속성을 부여해 목록 아이템 → 상세 카드처럼 자연스럽게 확장되는 애니메이션
Swift
struct HeroAnimationView: View {
    @Namespace private var heroNS
    @State private var selectedCard: Int? = nil

    let cards = [1, 2, 3]

    var body: some View {
        ZStack {
            // 목록 — 작은 카드
            if selectedCard == nil {
                VStack(spacing: 12) {
                    ForEach(cards, id: \.self) { id in
                        RoundedRectangle(cornerRadius: 16)
                            .fill(Color.orange.opacity(0.15))
                            .frame(height: 80)
                            .overlay(
                                Text("카드 \(id)")
                                    .font(.headline)
                            )
                            // 이 뷰에 heroNS + "card\(id)" 지정
                            .matchedGeometryEffect(
                                id: "card\(id)",
                                in: heroNS
                            )
                            .onTapGesture {
                                withAnimation(.spring(
                                    response: 0.45,
                                    dampingFraction: 0.8
                                )) {
                                    selectedCard = id
                                }
                            }
                    }
                }
                .padding()
            }

            // 상세 — 펼쳐진 카드
            if let id = selectedCard {
                RoundedRectangle(cornerRadius: 24)
                    .fill(Color.orange.opacity(0.25))
                    .ignoresSafeArea()
                    .overlay(
                        VStack {
                            Text("카드 \(id) 상세")
                                .font(.largeTitle.bold())
                            Button("닫기") {
                                withAnimation(.spring(
                                    response: 0.45,
                                    dampingFraction: 0.8
                                )) {
                                    selectedCard = nil
                                }
                            }
                            .padding(.top)
                        }
                    )
                    // 같은 id + namespace → SwiftUI가 보간 처리
                    .matchedGeometryEffect(
                        id: "card\(id)",
                        in: heroNS
                    )
            }
        }
    }
}

// @Namespace: 두 뷰를 연결하는 ID 공간
// matchedGeometryEffect는 같은 namespace의
// 같은 id를 가진 뷰가 교체될 때 위치/크기를 보간
생각해보기
  • 두 뷰가 동시에 화면에 존재하면 matchedGeometryEffect는 어떻게 동작하는가? (isSource 파라미터)
  • @Namespace를 여러 개 만들 수 있는가? 언제 분리해야 하는가?
  • NavigationStack의 페이지 전환에 Hero 애니메이션을 적용하는 iOS 18의 새 API는?
@Namespace
뷰 간 공유되는 고유 ID 공간. 같은 namespace + 같은 id를 가진 뷰들이 애니메이션으로 연결됨
보간 요소
위치(position), 크기(size), 모서리(cornerRadius)를 자동 보간. properties 파라미터로 조절 가능
ZStack + if/else 패턴
목록 뷰와 상세 뷰를 ZStack 내에서 if/else로 교체. 두 뷰가 동시에 렌더링되지 않아야 자연스러움
Topic 04

Custom ViewModifier — 재사용 가능한 스타일

반복되는 modifier 조합을 하나의 타입으로 캡슐화 — Protocol + Extension 패턴의 UI 버전
Swift
// 카드 스타일 modifier
struct CardModifier: ViewModifier {
    var color: Color = .orange

    func body(content: Content) -> some View {
        content
            .padding(20)
            .background(color.opacity(0.1))
            .overlay(
                RoundedRectangle(cornerRadius: 16)
                    .stroke(color.opacity(0.3), lineWidth: 1)
            )
            .cornerRadius(16)
            .shadow(color: color.opacity(0.15),
                    radius: 8, x: 0, y: 4)
    }
}

// Shimmer 로딩 효과 modifier
struct ShimmerModifier: ViewModifier {
    @State private var phase: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .overlay(
                LinearGradient(
                    gradient: Gradient(colors: [
                        .clear,
                        .white.opacity(0.5),
                        .clear
                    ]),
                    startPoint: .init(x: phase - 0.3, y: 0),
                    endPoint: .init(x: phase + 0.3, y: 0)
                )
                .blendMode(.screen)
            )
            .onAppear {
                withAnimation(
                    .linear(duration: 1.5)
                    .repeatForever(autoreverses: false)
                ) {
                    phase = 1.3
                }
            }
    }
}

// Pressable 효과 modifier
struct PressableModifier: ViewModifier {
    @State private var isPressed = false

    func body(content: Content) -> some View {
        content
            .scaleEffect(isPressed ? 0.95 : 1.0)
            .opacity(isPressed ? 0.8 : 1.0)
            .animation(.easeOut(duration: 0.1), value: isPressed)
            .simultaneousGesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { _ in isPressed = true }
                    .onEnded   { _ in isPressed = false }
            )
    }
}

// View Extension으로 편리하게 사용
extension View {
    func cardStyle(color: Color = .orange) -> some View {
        modifier(CardModifier(color: color))
    }
    func shimmer() -> some View {
        modifier(ShimmerModifier())
    }
    func pressable() -> some View {
        modifier(PressableModifier())
    }
}

// 사용
Text("안녕하세요").cardStyle()
Rectangle().shimmer()
Button("탭!") { }.pressable()
생각해보기
  • ViewModifier와 custom View를 만드는 것의 차이는? 언제 어느 쪽을 선택하는가?
  • ShimmerModifier가 @State를 갖는 이유는? 각 뷰 인스턴스마다 독립적으로 동작하는가?
  • 여러 ViewModifier를 체이닝할 때 순서가 중요한가?
ViewModifier 프로토콜
body(content:)를 구현. content는 기존 뷰. 여기에 modifier를 추가해 새 뷰를 반환
View Extension 패턴
extension View { func myStyle() → some View }로 SwiftUI 내장 modifier처럼 체이닝 가능하게 만들기
@State in Modifier
ViewModifier도 @State를 가질 수 있다. modifier가 적용된 각 뷰에 독립적인 상태 인스턴스
Topic 05

Gesture — 탭·드래그·스와이프

사용자의 손가락 움직임을 감지하고 그에 반응하는 인터랙티브한 UI 만들기
Swift
struct GestureDemo: View {
    @State private var offset: CGSize = .zero
    @State private var isDragging = false
    @State private var tapCount = 0
    @State private var isLongPressed = false

    var body: some View {
        VStack(spacing: 40) {

            // TapGesture
            Text("탭 횟수: \(tapCount)")
                .padding()
                .background(Color.orange.opacity(0.15))
                .cornerRadius(12)
                .onTapGesture(count: 2) {   // 더블탭
                    tapCount += 1
                }

            // DragGesture — 드래그로 이동
            Circle()
                .fill(isDragging ? Color.orange : Color.orange.opacity(0.5))
                .frame(width: 80, height: 80)
                .offset(offset)
                .scaleEffect(isDragging ? 1.1 : 1.0)
                .animation(.spring(), value: isDragging)
                .gesture(
                    DragGesture()
                        .onChanged { value in
                            offset = value.translation
                            isDragging = true
                        }
                        .onEnded { _ in
                            withAnimation(.spring()) {
                                offset = .zero  // 제자리 복귀
                                isDragging = false
                            }
                        }
                )

            // LongPressGesture
            RoundedRectangle(cornerRadius: 16)
                .fill(isLongPressed ? Color.orange : Color.gray.opacity(0.2))
                .frame(height: 60)
                .overlay(
                    Text(isLongPressed ? "꾹 누르는 중..." : "꾹 눌러보세요")
                )
                .gesture(
                    LongPressGesture(minimumDuration: 0.5)
                        .onChanged { _ in
                            withAnimation { isLongPressed = true }
                        }
                        .onEnded { _ in
                            withAnimation { isLongPressed = false }
                        }
                )

            // simultaneousGesture — 여러 제스처 동시 인식
            Text("동시 탭 + 드래그")
                .onTapGesture { print("탭!") }
                .simultaneousGesture(
                    DragGesture().onChanged { _ in print("드래그!") }
                )
        }
        .padding()
    }
}
생각해보기
  • .gesture().onTapGesture()의 차이는? 제스처 우선순위는 어떻게 결정되는가?
  • DragGesturevalue.translationvalue.predictedEndTranslation의 차이는?
  • 스와이프로 항목 삭제(swipe to delete)를 직접 구현한다면 어떤 gesture를 사용하는가?
주요 Gesture 타입
TapGesture · DragGesture · LongPressGesture · MagnificationGesture · RotationGesture
onChanged / onEnded
onChanged: 제스처 진행 중 매 프레임 호출 | onEnded: 손가락을 뗐을 때 한 번 호출
simultaneousGesture
부모-자식 뷰의 제스처 충돌 없이 동시 인식. .highPriorityGesture로 우선순위 제어 가능
Topic 06

Path & Shape — 커스텀 도형

기본 도형으로 표현할 수 없는 것들 — 웨이브, 진행 인디케이터, 말풍선 등을 직접 그린다
Swift
// Custom Shape — Shape 프로토콜 채택
struct Wave: Shape {
    var amplitude: CGFloat = 10
    var frequency: CGFloat = 2

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: 0, y: rect.midY))

        let wavelength = rect.width / frequency
        for x in stride(from: 0, to: rect.width, by: 1) {
            let y = rect.midY + amplitude *
                sin(2 * .pi * x / wavelength)
            path.addLine(to: CGPoint(x: x, y: y))
        }
        return path
    }
}

// 원형 진행 인디케이터
struct ArcProgressShape: Shape {
    var progress: Double   // 0.0 ~ 1.0
    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func path(in rect: CGRect) -> Path {
        var path = Path()
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2 - 8

        path.addArc(
            center: center,
            radius: radius,
            startAngle: .degrees(-90),
            endAngle: .degrees(-90 + 360 * progress),
            clockwise: false
        )
        return path
    }
}

// 실제 사용
struct ProgressRing: View {
    @State private var progress = 0.0

    var body: some View {
        ZStack {
            Circle()
                .stroke(Color.orange.opacity(0.2),
                        lineWidth: 12)
            ArcProgressShape(progress: progress)
                .stroke(
                    Color.orange,
                    style: StrokeStyle(
                        lineWidth: 12,
                        lineCap: .round
                    )
                )
                .animation(.easeInOut(duration: 1.5),
                           value: progress)
            Text("\(Int(progress * 100))%")
                .font(.title2.bold())
        }
        .frame(width: 140, height: 140)
        .onAppear { progress = 0.75 }
    }
}
생각해보기
  • animatableData를 구현하면 Shape의 어떤 값을 애니메이션으로 만들 수 있는가?
  • Path.addArc에서 각도 계산 시 SwiftUI 좌표계의 방향은 수학과 어떻게 다른가?
  • ShapeCanvas는 어떻게 다른가? 복잡한 그래픽에서 성능 차이가 있는가?
Shape 프로토콜
path(in rect:)를 구현. rect는 뷰가 차지하는 공간. Path로 원하는 형태를 그려 반환
animatableData
Shape의 어떤 프로퍼티를 애니메이션 대상으로 할지 지정. SwiftUI가 시작값→끝값을 보간해 매 프레임 호출
StrokeStyle
lineWidth, lineCap(.round/.square), dash 패턴 등 선 스타일 세부 조정. .stroke() 대신 .stroke(style:) 사용
withAnimation
탭해서 확장 →
.spring()
.easeInOut
withAnimation(
  .spring(
    response: 0.4)
) { isExpanded.toggle() }
.transition()
.slide
← 옆에서 등장
.scale + .opacity
⊙ 스케일 + 페이드
.asymmetric
↑ 아래서 · 위로 ↑
matchedGeometry
카드 1
카드 2
↕ tap
카드 1 상세
Hero 확장
ViewModifier
CardModifier
패딩 + 배경 + 테두리 + 그림자
ShimmerModifier
Text("...").cardStyle()
.shimmer().pressable()
Gesture
TapGesture(count: 2)
더블탭 👆👆
DragGesture → offset
⬤ →→
LongPressGesture(0.5s)
꾹 👆 ...
Path & Shape
75%
ArcProgressShape
animatableData
Homework

챌린지 2-4를 마쳤다면

필수
카드 플립 애니메이션
카드를 탭하면 앞/뒤가 뒤집히는 애니메이션 구현. rotation3DEffectwithAnimation 활용. 앞면과 뒷면이 각각 다른 내용을 보여야 함
필수
드래그로 카드 스와이프 UI
DragGesture로 카드를 좌/우로 드래그. 일정 거리 이상 드래그 시 카드 날아가고 다음 카드 등장. 원점 복귀 스프링 애니메이션 포함
도전
목록 → 상세 Hero 트랜지션
matchedGeometryEffect로 그리드 목록의 썸네일이 상세 화면의 헤더 이미지로 자연스럽게 확장되는 애니메이션 구현
완료 체크리스트
withAnimation과 .animation(value:)를 상황에 맞게 사용할 수 있다
.transition()으로 뷰 등장/퇴장 애니메이션을 커스터마이즈할 수 있다
matchedGeometryEffect로 Hero 애니메이션을 구현할 수 있다
ViewModifier를 만들고 View Extension으로 편리하게 사용할 수 있다
DragGesture로 인터랙티브한 드래그 UI를 구현할 수 있다
← 이전 챌린지 2-3 재사용 가능한 코드 짜기
코멘트
후원하기
콘텐츠가 도움이 됐다면
커피 한 잔의 후원을 부탁드려요 :)
카카오페이 QR
이현호
카카오페이
Stage 2 완료 — 다음 단계
추천
📊 Swift 자료구조 & 알고리즘
Array가 왜 느린지, Set은 왜 빠른지 — 코드의 성능을 이해하는 다음 단계
시작하기 →
카카오톡 QR
카카오톡 오픈채팅 💬
학습 질문 · 코드 리뷰 · 링크로 참여