[Swift 입문] 19편 — 커스텀 뷰와 애니메이션

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [18편] 리스트·내비게이션·시트

재사용 가능한 뷰 컴포넌트

앱을 만들다 보면 같은 스타일의 버튼이나 카드 UI를 여러 곳에서 사용하게 됩니다. 매번 같은 수정자를 반복 작성하는 대신, 재사용 가능한 커스텀 뷰를 만들면 코드가 훨씬 깔끔해집니다.


커스텀 뷰 컴포넌트 만들기

기본 패턴

// 재사용 가능한 카드 뷰
struct CardView: View {
    let title: String
    let subtitle: String
    let color: Color

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)
                .foregroundColor(.white)
            Text(subtitle)
                .font(.subheadline)
                .foregroundColor(.white.opacity(0.8))
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(color)
        .cornerRadius(12)
    }
}

// 사용 예시
struct ContentView: View {
    var body: some View {
        VStack(spacing: 12) {
            CardView(title: "Swift", subtitle: "Apple의 시스템 언어", color: .orange)
            CardView(title: "SwiftUI", subtitle: "선언형 UI 프레임워크", color: .blue)
            CardView(title: "Xcode", subtitle: "Apple 공식 IDE", color: .purple)
        }
        .padding()
    }
}

뷰를 파라미터로 받기 (ViewBuilder)

내용이 다양한 컨테이너 뷰를 만들 때는 @ViewBuilder를 사용합니다.

struct PanelView<Content: View>: View {
    let title: String
    @ViewBuilder let content: Content

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(title)
                .font(.headline)
                .padding(.horizontal)
                .padding(.top)

            Divider()

            content
                .padding([.horizontal, .bottom])
        }
        .background(Color(.systemBackground))
        .cornerRadius(16)
        .shadow(color: .black.opacity(0.08), radius: 8, y: 2)
    }
}

// 사용 예시 — 어떤 뷰든 들어갈 수 있음
struct ExampleView: View {
    var body: some View {
        VStack(spacing: 16) {
            PanelView(title: "프로필") {
                HStack {
                    Image(systemName: "person.circle.fill")
                        .font(.largeTitle)
                    Text("홍길동")
                        .font(.title2)
                }
            }

            PanelView(title: "통계") {
                HStack(spacing: 24) {
                    StatItem(label: "게시글", value: 42)
                    StatItem(label: "팔로워", value: 1200)
                    StatItem(label: "팔로잉", value: 300)
                }
            }
        }
        .padding()
    }
}

커스텀 수정자(ViewModifier)

자주 쓰는 수정자 조합을 하나로 묶을 수 있습니다.

struct CardBackground: ViewModifier {
    var color: Color = .white

    func body(content: Content) -> some View {
        content
            .padding()
            .background(color)
            .cornerRadius(12)
            .shadow(color: .black.opacity(0.1), radius: 6, y: 2)
    }
}

// View 확장으로 편하게 사용
extension View {
    func cardStyle(color: Color = .white) -> some View {
        modifier(CardBackground(color: color))
    }
}

// 이제 이렇게 사용 가능
struct UsageView: View {
    var body: some View {
        VStack {
            Text("안녕하세요")
                .cardStyle()

            Image(systemName: "star.fill")
                .font(.largeTitle)
                .cardStyle(color: Color.yellow.opacity(0.2))
        }
    }
}

애니메이션 기초

SwiftUI 애니메이션은 매우 간단합니다. 상태가 바뀔 때 withAnimation으로 감싸거나 .animation 수정자를 추가하면 됩니다.

기본 애니메이션

struct BasicAnimation: View {
    @State private var isExpanded = false

    var body: some View {
        VStack(spacing: 20) {
            RoundedRectangle(cornerRadius: 12)
                .fill(Color.blue)
                .frame(
                    width: isExpanded ? 300 : 100,
                    height: isExpanded ? 200 : 100
                )
                .animation(.spring(response: 0.4, dampingFraction: 0.6), value: isExpanded)

            Button(isExpanded ? "줄이기" : "늘리기") {
                isExpanded.toggle()
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

.animation(_:value:) 수정자는 value가 바뀔 때마다 해당 수정자가 적용된 뷰를 애니메이션합니다.

주요 애니메이션 종류

// 선형 — 일정한 속도
.animation(.linear(duration: 0.3), value: isExpanded)

// 이징 — 천천히 시작해 빠르게 (기본)
.animation(.easeInOut(duration: 0.3), value: isExpanded)

// 스프링 — 탄성 있는 움직임
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: isExpanded)

// 바운스 — iOS 17+
.animation(.bouncy, value: isExpanded)

// 딜레이
.animation(.easeOut.delay(0.1), value: isExpanded)

withAnimation

struct WithAnimationExample: View {
    @State private var offset: CGFloat = 0
    @State private var opacity = 1.0

    var body: some View {
        VStack(spacing: 30) {
            Circle()
                .fill(Color.orange)
                .frame(width: 80)
                .offset(x: offset)
                .opacity(opacity)

            Button("움직이기") {
                withAnimation(.spring(response: 0.5, dampingFraction: 0.5)) {
                    offset = offset == 0 ? 100 : 0
                    opacity = opacity == 1.0 ? 0.3 : 1.0
                }
            }
            .buttonStyle(.bordered)
        }
    }
}

withAnimation 클로저 안에서 상태를 바꾸면 그 상태 변화에 의한 UI 업데이트 전체에 애니메이션이 적용됩니다.


전환(Transition)

뷰가 나타나거나 사라질 때 적용할 전환 효과입니다.

struct TransitionExample: View {
    @State private var showCard = false

    var body: some View {
        VStack(spacing: 20) {
            Button(showCard ? "카드 숨기기" : "카드 보이기") {
                withAnimation(.spring()) {
                    showCard.toggle()
                }
            }
            .buttonStyle(.borderedProminent)

            if showCard {
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color.blue.gradient)
                    .frame(height: 120)
                    .overlay(
                        Text("✨ 카드!")
                            .font(.title)
                            .foregroundColor(.white)
                    )
                    .transition(.asymmetric(
                        insertion: .move(edge: .top).combined(with: .opacity),
                        removal: .scale.combined(with: .opacity)
                    ))
            }
        }
        .padding()
    }
}

주요 전환 종류

  • .opacity: 서서히 나타나고 사라짐
  • .scale: 크기가 변하며 나타나고 사라짐
  • .slide: 옆에서 밀려 들어옴
  • .move(edge:): 특정 방향에서 이동
  • .combined(with:): 두 전환 조합
  • .asymmetric: 나타날 때와 사라질 때 다른 효과

matchedGeometryEffect — 히어로 애니메이션

두 뷰 사이에 자연스러운 전환을 만드는 강력한 도구입니다. 목록에서 상세 화면으로 확장되는 애니메이션을 구현할 때 유용합니다.

struct HeroAnimation: View {
    @State private var isExpanded = false
    @Namespace private var animation

    var body: some View {
        if isExpanded {
            // 확장된 상태
            VStack {
                RoundedRectangle(cornerRadius: 20)
                    .fill(Color.blue.gradient)
                    .matchedGeometryEffect(id: "card", in: animation)
                    .frame(height: 300)
                    .onTapGesture {
                        withAnimation(.spring()) { isExpanded = false }
                    }
                    .overlay(
                        Text("상세 정보")
                            .font(.title)
                            .foregroundColor(.white)
                    )
            }
            .padding()
        } else {
            // 축소된 상태 (목록 아이템)
            HStack {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.blue.gradient)
                    .matchedGeometryEffect(id: "card", in: animation)
                    .frame(width: 60, height: 60)

                Text("탭해서 확장")
                    .font(.headline)
                Spacer()
            }
            .padding()
            .onTapGesture {
                withAnimation(.spring()) { isExpanded = true }
            }
        }
    }
}

같은 idnamespace를 가진 두 뷰 사이를 SwiftUI가 자동으로 보간(interpolate)해 매끄러운 전환을 만들어줍니다.


그라디언트와 도형

struct ShapeExamples: View {
    var body: some View {
        VStack(spacing: 16) {
            // 그라디언트
            LinearGradient(
                colors: [.blue, .purple],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .frame(height: 100)
            .cornerRadius(16)

            // 커스텀 도형
            HStack(spacing: 12) {
                Circle()
                    .fill(Color.orange.gradient)
                    .frame(width: 60)

                RoundedRectangle(cornerRadius: 8)
                    .fill(Color.green.gradient)
                    .frame(width: 80, height: 60)

                Capsule()
                    .fill(Color.pink.gradient)
                    .frame(width: 80, height: 40)
            }
        }
        .padding()
    }
}

커스텀 도형 (Shape 프로토콜)

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.midX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}

// 사용
Triangle()
    .fill(Color.teal.gradient)
    .frame(width: 100, height: 80)
    .animation(.spring(), value: someState)

Shape 프로토콜을 구현하면 SwiftUI의 모든 수정자와 애니메이션을 그대로 사용할 수 있습니다.


핵심 요약

  • 커스텀 뷰: View 프로토콜을 채택한 구조체로 재사용 가능한 컴포넌트 제작
  • @ViewBuilder: 뷰를 파라미터로 받는 제네릭 컨테이너 뷰 구현
  • ViewModifier: 반복되는 수정자 묶음을 재사용 가능한 단위로 추출
  • .animation(_:value:): 특정 값 변화에 애니메이션 적용
  • withAnimation: 블록 안 상태 변화 전체에 애니메이션 적용
  • .transition: 뷰가 나타나고 사라질 때의 효과
  • matchedGeometryEffect: 두 뷰 간 히어로 전환 효과
  • Shape 프로토콜: 커스텀 도형 제작, 애니메이션 지원

3부 SwiftUI가 완결됩니다. 다음 편부터는 4부 macOS 앱 개발로 넘어가 NSWindow, MenuBarExtra, AppKit 통합 등 macOS 특화 기능을 다룹니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다