SwiftUI Study – matchedGeometryEffect와 transition을 활용한 자연스러운 화면 전환·요소 이동 애니메이션 패턴
Dev Study/SwiftUI 2025. 12. 12. 10:07반응형
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 등을 올바르게 사용하면
별도의 커스텀 애니메이션 엔진 없이도 고급스러운 인터랙션을 구현할 수 있다. - 이 팁은 상태 관리/레이아웃/네비게이션과는 별도로,
실제 사용자 경험을 크게 개선하는 “마무리 단계”에서 특히 빛을 발한다.
반응형


