반응형

SwiftUI Study – matchedGeometryEffect와 transition을 활용한 자연스러운 화면 전환·요소 이동 애니메이션 패턴

 

1. 왜 중요한가 (문제 배경)

SwiftUI는 선언적 UI에 맞춘 강력한 애니메이션 시스템을 제공한다.
그중에서도 matchedGeometryEffect와 transition 은 다음과 같은 상황에서 핵심 역할을 한다.

  • 리스트 → 상세 화면 전환 시, 셀이 부드럽게 확대되며 이어지는 효과
  • 탭 전환 시, 선택 인디케이터가 연속적으로 이동하는 효과
  • 카드 레이아웃에서 특정 카드가 전면으로 나오는 focus 애니메이션
  • 필터/정렬 변경 시 아이템들이 자연스럽게 위치를 재배치하는 모션

하지만 실무에서는 다음과 같은 문제가 자주 발생한다.

  • matchedGeometryEffect를 적용했는데 전혀 움직이지 않거나 순간이동처럼 보임
  • transition을 중복 적용하여 깜빡이거나 사라지는 애니메이션
  • 동일한 namespace/matched id를 잘못 사용해 전혀 의도와 다른 요소가 움직이는 버그
  • 애니메이션과 상태 변경 타이밍이 엇갈려 레이아웃이 튀는 현상

이 팁에서는 애니메이션 효과 자체를 다루며, 이전 팁들의 상태 관리/레이아웃/네비게이션 주제와는 별도로,
실제 앱에서 “보여지는 느낌”을 개선하는 고급 패턴에 집중한다.

 

2. 잘못된 패턴 예시

❌ 예시 1: 서로 다른 뷰 계층에서 matchedGeometryEffect를 쓸 때 id/namespace 불일치

struct WrongMatchedView: View {
    let namespace: Namespace.ID

    var body: some View {
        VStack {
            if isExpanded {
                RoundedRectangle(cornerRadius: 16)
                    .matchedGeometryEffect(id: "card", in: namespace)
                    .frame(height: 200)
            } else {
                RoundedRectangle(cornerRadius: 16)
                    .matchedGeometryEffect(id: "wrongId", in: namespace)  // ❌ id 불일치
                    .frame(height: 80)
            }
        }
    }
}

문제점

  • id가 다르면 SwiftUI는 “같은 요소”로 인식하지 않는다.
  • 애니메이션은 전혀 일어나지 않고, 두 개의 독립적인 뷰로 처리된다.

❌ 예시 2: 서로 다른 namespace 사용

@Namespace var ns1
@Namespace var ns2

if isExpanded {
    RoundedRectangle(cornerRadius: 16)
        .matchedGeometryEffect(id: "card", in: ns1)
} else {
    RoundedRectangle(cornerRadius: 16)
        .matchedGeometryEffect(id: "card", in: ns2)  // ❌ namespace가 다름
}

문제점

  • namespace가 다르면 서로 전혀 관련 없는 뷰로 간주
  • 애니메이션이 아니라 순간 전환처럼 보인다.

❌ 예시 3: transition과 opacity 변경을 동시에 중복 적용

if showDetail {
    DetailView()
        .transition(.opacity)       // ❌
        .opacity(showDetail ? 1 : 0)
}

문제점

  • transition과 opacity가 동시에 적용되면서
    애니메이션 타이밍이 꼬이거나 “두 번 페이드”되는 느낌을 줄 수 있다.

❌ 예시 4: 상태 변경과 애니메이션 트리거 타이밍이 엇갈리는 경우

withAnimation {
    isExpanded.toggle()   // ❌ 내부에서 다른 상태 변경이 연쇄적으로 일어나면 레이아웃이 튈 수 있음
}

문제점

  • 하나의 withAnimation 블록에서 너무 많은 상태 변경이 일어나면,
    어떤 변화가 어떤 애니메이션과 연결되었는지 추적하기 어렵다.

 

3. 올바른 패턴 예시

✅ 예시 1: 기본적인 카드 확장/축소 matchedGeometryEffect

struct CardListView: View {
    @Namespace private var namespace
    @State private var selectedID: Int?

    let cards: [Int] = Array(0..<5)

    var body: some View {
        ZStack {
            ScrollView {
                VStack(spacing: 16) {
                    ForEach(cards, id: \.self) { id in
                        if selectedID == id {
                            Color.clear.frame(height: 80)   // 자리 유지용
                        } else {
                            CardRow(id: id)
                                .matchedGeometryEffect(id: id, in: namespace)
                                .onTapGesture {
                                    withAnimation(.spring()) {
                                        selectedID = id
                                    }
                                }
                        }
                    }
                }
                .padding()
            }

            if let selectedID {
                CardDetail(id: selectedID)
                    .matchedGeometryEffect(id: selectedID, in: namespace)
                    .onTapGesture {
                        withAnimation(.spring()) {
                            self.selectedID = nil
                        }
                    }
                    .zIndex(1)
            }
        }
    }
}

포인트

  • 리스트에 있는 카드와 상세 카드가 동일 id + 동일 namespace 를 공유
  • selected 상태에 따라 하나는 사라지고, 다른 하나가 나타나면서 부드럽게 이어짐
  • 자리 유지용 Color.clear 로 레이아웃 튐을 최소화

✅ 예시 2: 탭 인디케이터에 matchedGeometryEffect 적용

struct MatchedTabBar: View {
    @Namespace private var ns
    @State private var selection: Int = 0

    var body: some View {
        HStack {
            ForEach(0..<3) { index in
                VStack {
                    Button(action: { withAnimation(.easeInOut) { selection = index } }) {
                        Text("탭 \(index)")
                            .foregroundColor(selection == index ? .blue : .gray)
                    }

                    if selection == index {
                        Capsule()
                            .frame(height: 3)
                            .matchedGeometryEffect(id: "underline", in: ns)
                    } else {
                        Color.clear.frame(height: 3)
                    }
                }
            }
        }
    }
}

포인트

  • 선택된 탭 아래의 Capsule만 공통 id "underline" 으로 matched
  • selection 변경 시 인디케이터가 좌우로 자연스럽게 이동

✅ 예시 3: transition을 활용한 뷰 출입 애니메이션 분리

struct FadeScaleTransitionView: View {
    @State private var show = false

    var body: some View {
        VStack {
            Button("토글") {
                withAnimation(.easeInOut) {
                    show.toggle()
                }
            }

            if show {
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color.blue)
                    .frame(height: 120)
                    .transition(.asymmetric(
                        insertion: .scale.combined(with: .opacity),
                        removal: .opacity
                    ))
            }
        }
    }
}

포인트

  • insertion과 removal을 분리해 좀 더 자연스러운 UX 구성
  • scale+opacity를 결합해 살짝 튀어나오는 느낌 구현

✅ 예시 4: 애니메이션 범위를 명확히 나누기

withAnimation(.spring()) {
    isExpanded.toggle()
}

withAnimation(.easeOut(duration: 0.2)) {
    isHighlighted = isExpanded
}

포인트

  • 서로 다른 성격의 상태 변화에 다른 애니메이션을 부여
  • “어떤 상태가 어느 애니메이션과 연결되는지” 코드를 통해 명확히 알 수 있다.

 

4. 실전 적용 팁

✔ 팁 1 – matchedGeometryEffect는 “id + namespace 세트”를 항상 함께 생각하기

  • 같은 namespace, 같은 id 조합이 아니면 애니메이션이 일어나지 않는다.
  • 화면 구조가 바뀌어도 id/namespace는 바뀌지 않도록 설계하는 것이 중요하다.

✔ 팁 2 – 자리 유지용 빈 뷰를 적극 활용

  • 리스트/그리드에서 특정 항목이 확대·이동할 때,
    원래 자리에는 Color.clear.frame(...) 등을 두어 레이아웃 튐을 최소화할 수 있다.

✔ 팁 3 – transition과 opacity/offset 직접 변경을 중복 적용하지 않기

  • 같은 속성을 두 군데에서 동시에 건드리면 예측하기 힘든 모션이 나온다.
  • “출입 애니메이션은 transition, 내부 상태 변경은 animation(value:)” 정도로 역할을 나누면 좋다.

✔ 팁 4 – withAnimation 블록 안에 너무 많은 상태 변경을 넣지 말 것

  • 한 블록에는 “한 가지 의미의 변화”만 담는 것이 디버깅과 유지보수에 유리하다.

✔ 팁 5 – 애니메이션은 실제 디바이스에서 자주 확인

  • Xcode Preview와 실기기에서 체감이 다를 수 있다.
  • 특히 spring 파라미터, duration, delay는 기기마다 느낌이 달라질 수 있으므로 반복 점검이 필요하다.

 

5. 정리

  • matchedGeometryEffect와 transition은 SwiftUI에서 “보여지는 완성도”를 한 단계 올려주는 도구다.
  • id + namespace 일관성, 자리 유지, 상태/애니메이션 분리, 비대칭 transition 등을 올바르게 사용하면
    별도의 커스텀 애니메이션 엔진 없이도 고급스러운 인터랙션을 구현할 수 있다.
  • 이 팁은 상태 관리/레이아웃/네비게이션과는 별도로,
    실제 사용자 경험을 크게 개선하는 “마무리 단계”에서 특히 빛을 발한다.
반응형
Posted by 까칠코더
,