SwiftUI 애니메이션 마스터: matchedGeometryEffect와 트랜지션

모바일 개발

SwiftUI애니메이션matchedGeometryEffectiOSUI 디자인

이 글은 누구를 위한 것인가

  • SwiftUI 기본 애니메이션은 알지만 고급 효과가 필요한 iOS 개발자
  • matchedGeometryEffect로 앱스토어 같은 카드 확장 효과를 만들고 싶은 팀
  • 커스텀 트랜지션을 구현하고 싶은 SwiftUI 개발자

들어가며

앱스토어 Today 탭에서 카드를 탭하면 전체 화면으로 부드럽게 펼쳐진다. 이것이 matchedGeometryEffect다. SwiftUI 애니메이션의 핵심 패턴을 모아 정리했다.

이 글은 bluefoxdev.kr의 SwiftUI 고급 UI 가이드 를 참고하여 작성했습니다.


1. SwiftUI 애니메이션 개념

[애니메이션 트리거 방법]

암묵적 (Implicit):
  .animation(.spring, value: isExpanded)
  value가 변경될 때 자동 애니메이션

명시적 (Explicit):
  withAnimation(.spring) { isExpanded.toggle() }
  코드 블록 내 상태 변경 모두 애니메이션

[애니메이션 커브]
  .linear(duration: 0.3)
  .easeIn / .easeOut / .easeInOut
  .spring(response: 0.5, dampingFraction: 0.7)
  .bouncy (iOS 17+)
  .smooth (iOS 17+)

[matchedGeometryEffect 원리]
  두 뷰에 동일한 namespace/id 설정
  한 뷰가 사라질 때 다른 뷰로 부드럽게 전환
  위치, 크기 모두 보간

[트랜지션 타입]
  .slide → 슬라이드인/아웃
  .opacity → 페이드
  .scale → 확대/축소
  .move(edge: .bottom) → 방향 슬라이드
  .asymmetric → 진입/퇴장 다르게
  커스텀 ViewModifier로 직접 정의

2. 고급 애니메이션 구현

import SwiftUI

// 1. matchedGeometryEffect 히어로 애니메이션
struct CardHeroAnimation: View {
    @Namespace private var heroNamespace
    @State private var selectedCard: Card?
    
    let cards: [Card] = Card.samples
    
    var body: some View {
        ZStack {
            // 카드 그리드
            if selectedCard == nil {
                ScrollView {
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))]) {
                        ForEach(cards) { card in
                            CardThumbnail(card: card)
                                .matchedGeometryEffect(
                                    id: card.id,
                                    in: heroNamespace
                                )
                                .onTapGesture {
                                    withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                                        selectedCard = card
                                    }
                                }
                        }
                    }
                }
            }
            
            // 선택된 카드 상세
            if let card = selectedCard {
                CardDetail(card: card)
                    .matchedGeometryEffect(
                        id: card.id,
                        in: heroNamespace
                    )
                    .onTapGesture {
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                            selectedCard = nil
                        }
                    }
                    .zIndex(1)
            }
        }
    }
}

// 2. 커스텀 트랜지션
struct SlideFromBottomTransition: ViewModifier {
    let isActive: Bool
    
    func body(content: Content) -> some View {
        content
            .offset(y: isActive ? 0 : UIScreen.main.bounds.height)
            .opacity(isActive ? 1 : 0)
    }
}

extension AnyTransition {
    static var slideFromBottom: AnyTransition {
        .modifier(
            active: SlideFromBottomTransition(isActive: false),
            identity: SlideFromBottomTransition(isActive: true),
        )
    }
}

struct CustomTransitionView: View {
    @State private var showSheet = false
    
    var body: some View {
        ZStack {
            Button("열기") { withAnimation(.spring) { showSheet = true } }
            
            if showSheet {
                Color.white
                    .edgesIgnoringSafeArea(.all)
                    .transition(.slideFromBottom)
                    .overlay(
                        Button("닫기") { withAnimation(.spring) { showSheet = false } }
                    )
            }
        }
    }
}

// 3. PhaseAnimator (iOS 17+): 단계별 애니메이션
struct PulsingButton: View {
    @State private var isPulsing = false
    
    var body: some View {
        PhaseAnimator([false, true], trigger: isPulsing) { phase in
            Circle()
                .fill(.red)
                .frame(width: phase ? 80 : 60)
                .opacity(phase ? 0.6 : 1.0)
        } animation: { phase in
            phase ? .easeOut(duration: 0.6) : .easeIn(duration: 0.3)
        }
        .onTapGesture { isPulsing.toggle() }
    }
}

// 4. KeyframeAnimator (iOS 17+): 키프레임 애니메이션
struct BounceAnimation: View {
    @State private var animate = false
    
    var body: some View {
        KeyframeAnimator(
            initialValue: AnimationValues(),
            trigger: animate,
        ) { values in
            Image(systemName: "star.fill")
                .font(.system(size: 60))
                .scaleEffect(values.scale)
                .offset(y: values.offsetY)
                .rotationEffect(.degrees(values.rotation))
        } keyframes: { _ in
            KeyframeTrack(\.scale) {
                LinearKeyframe(1.0, duration: 0.1)
                SpringKeyframe(1.5, duration: 0.3, spring: .bouncy)
                SpringKeyframe(1.0, spring: .snappy)
            }
            KeyframeTrack(\.offsetY) {
                LinearKeyframe(0, duration: 0.1)
                CubicKeyframe(-50, duration: 0.3)
                CubicKeyframe(0, duration: 0.3)
            }
            KeyframeTrack(\.rotation) {
                LinearKeyframe(0, duration: 0.3)
                CubicKeyframe(20, duration: 0.2)
                CubicKeyframe(-20, duration: 0.2)
                CubicKeyframe(0, duration: 0.2)
            }
        }
        .onTapGesture { animate.toggle() }
    }
}

struct AnimationValues {
    var scale: Double = 1.0
    var offsetY: Double = 0
    var rotation: Double = 0
}

// 5. 로딩 스켈레톤 애니메이션
struct ShimmerEffect: ViewModifier {
    @State private var phase: CGFloat = 0
    
    func body(content: Content) -> some View {
        content
            .overlay(
                LinearGradient(
                    gradient: Gradient(colors: [
                        .clear, .white.opacity(0.6), .clear
                    ]),
                    startPoint: .leading,
                    endPoint: .trailing,
                )
                .offset(x: phase)
            )
            .clipped()
            .onAppear {
                withAnimation(
                    .linear(duration: 1.5).repeatForever(autoreverses: false)
                ) {
                    phase = 400
                }
            }
    }
}

extension View {
    func shimmer() -> some View { modifier(ShimmerEffect()) }
}

struct Card: Identifiable {
    let id: String
    let title: String
    let color: Color
    
    static let samples = [
        Card(id: "1", title: "여행", color: .blue),
        Card(id: "2", title: "음식", color: .orange),
    ]
}

struct CardThumbnail: View {
    let card: Card
    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(card.color)
            .frame(height: 200)
            .overlay(Text(card.title).foregroundColor(.white).bold())
    }
}

struct CardDetail: View {
    let card: Card
    var body: some View {
        RoundedRectangle(cornerRadius: 0)
            .fill(card.color)
            .ignoresSafeArea()
            .overlay(Text(card.title).foregroundColor(.white).font(.largeTitle))
    }
}

마무리

matchedGeometryEffect는 두 뷰가 "같은 것"임을 SwiftUI에게 알려주는 힌트다. 단 하나의 namespace ID만 사용하고, 두 뷰가 동시에 보이면 안 된다(한 번에 하나만 보여야 함). 애니메이션 성능 최적화: drawingGroup()으로 Metal 렌더링, equatable()로 불필요한 재계산 방지.