matchedGeometryEffect를 통한 자연스러운 애니메이션 표현 방식

이번에 회사에 새로 입사하게 되어 온보딩 과정을 거치면서 matchedGeometryEffect라는 개념에 대해서 마주하게 되었다. SwiftUI에서 특정 항목에 대한 값 변동으로 인해 diffing을 거쳐 뷰가 변경되는 과정에 대해서는 이해하고 있었고, 애니메이션을 적용했을때 어떤 방식으로 이뤄지는지에 대해 이해는 했지만 조금 더 부드럽게 애니메이션을 구현하고 싶은 순간들이 있었는데 이부분에서 해법을 찾을 수 있는 matchedGeometryEffect를 알게 되었따!

 

예를 들어 탭을 선택한다고 했을떄 하나의 하단 막대바가 자연스럽게 좌우로 끊김없이 진행되고 싶을때 기존처럼 그냥 SwiftUI를 통해 조건문을 넣어 랜더링을 하게되면 상태값에 따라 뷰가 다시 그려지기 때문에 자연스럽게 이어지는게 아니라 이전 탭 하단의 막대가 사라지고 새로운 탭 하단의 막대가 생기는 방식으로 애니메이션이 구현되게 된다.

 

원하는 방식처럼 하나의 애니메이션으로 동작하기 위해서 matchedGeometryEffect가 역할을 해주게 되는데 이 방식은 서로 다른 뷰 계층에 속한 각각의 지오메트리를 연결하고, 전환해주는 메커니즘을 제공한다. 먼저 공식문서를 통해 자세한 내용을 살펴보면

 

id: Hashable 는 매칭될 지오메트리의 고유 식별자이다. 얘가 이제 SwiftUI에서 어떤 뷰와 어떤 뷰를 연결하는지 식별하는데 사용되는 이름표 같은 형태! id 는 반드시 Hashable 프로토콜을 준수해야 하며, 일반적으로 String이나 항목의 UUID 등이 사용된다.

 

namespace: Namespace.ID 는 지오메트리가 공유되는 공간! 또는 그룹을 정의하는 Namespace영역을 정의한다. id가 동일하더라도 namespace가 다르면 뷰는 절대로 매칭되지 않게 된다!

 

이 namespace는 @Namespace 프로퍼티 래퍼를 사용해서 뷰의 상위 계층에서 생성하고, 이 값을 매칭이 필요한 모든 하위 뷰에 전달해야 한다!

struct MyParentView: View {
    // 1. Namespace를 생성
    @Namespace private var heroAnimationNamespace

    @State private var isDetailViewPresented: Bool = false

    var body: some View {
        if isDetailViewPresented {
            DetailView(namespace: heroAnimationNamespace)
        } else {
            ListView(namespace: heroAnimationNamespace)
        }
    }
}

자세한 작동 원리는 matchedGeometryEffect가 A뷰가 B뷰로 이동한다라고 생각할 수 있는데 이건 아니고, SwiftUI는 값이 변하지 않는 struct 값이기 때문에 if문이나 @State 변경에 따라 뷰 A가 사라지고 뷰 B가 나타날 때, 뷰 A는 실제로 없어지고 B는 생성 된다! (뷰의 동일성 원칙에 따라)

 

matchedGeometryEffect는 이 과정에 개입해서 상태 변경이 감지되게 되면 지오메트리를 식별해 동일한 namespace와 id를 가진 사라질 뷰, 나타나는 뷰를 식별하게 된다. 그 다음 사라지는 뷰의 Source 최종 지오메트리를 캡처하고, 나타나게 될 뷰의 Source 최종 지오메트리를 캡처한다. 그 다음 캡처된 시작과 종료 프레임으로 전환되는 중간 과정을 보간(interpolate)을 하는 애니메이션을 생성한다.

 

그 다음 새로 생성된 뷰는 자신의 최종 프레임이 아닌, 애니메이션 트랜잭션 동안 보간된 프레임을 기준으로 렌더링 된다!

 

결과적으로 사용자는 뷰가 없어지고 새로 생기는 듯한 느낌을 받는 것이 아니라, 하나의 뷰가 부드럽게 변경되는 즉 이동하는 것 처럼 느껴지게 된다.

 

이러한 작동 원리를 통해 matchedGeometryEffect가 뷰 계정 구조와 관계없이 작동하는 이유인 것!!! 자세하게 더 들어가데 되면 세부 값을 조정하는 것으로 뷰의 보간 위치를 변경해 더욱 세부적인 애니메이션 적용이 가능해 지게 된다.

struct ListView: View {
    let namespace: Namespace.ID
    let item: Item
    
    var body: some View {
        Image(item.imageName)
            .matchedGeometryEffect(id: item.id, in: namespace) // 👈 (1)
            .frame(width: 100, height: 100)
    }
}

// DetailView.swift
struct DetailView: View {
    let namespace: Namespace.ID
    let item: Item
    
    var body: some View {
        Image(item.imageName)
            .matchedGeometryEffect(id: item.id, in: namespace) // 👈 (2)
            .frame(height: 300)
    }
} 

부모뷰가 @State 를 변경하며 ListView를 DetailView로 전환할 때 현재 둘은 같은 namespace와 id를 가지고 있기 때문에 지오메트리 전환이 자연스럽게 발생할 수 있게 된다.

struct CustomSegmentedControl: View {
    @State private var selectedTab: String = "Home"
    @Namespace private var tabNamespace
    let tabs = ["Home", "Search", "Profile"]

    var body: some View {
        HStack {
            ForEach(tabs, id: \\.self) { tab in
                Text(tab)
                    .padding()
                    .background {
                        if selectedTab == tab {
                            // (A) 선택된 탭 배경에 인디케이터 배치
                            Capsule()
                                .fill(Color.blue)
                                .matchedGeometryEffect(id: "indicator", in: tabNamespace)
                        }
                    }
                    .onTapGesture {
                        withAnimation(.spring()) {
                            selectedTab = tab // 👈 (B) 상태 변경
                        }
                    }
            }
        }
    }
}

어찌보면 각 탭별로 해당하는 인디케이터를 배치하는 방식에서 아주 자연스럽게 따라다니는 듯한 느낌을 줄 수 있는 방식이 가장 자주, 대표적으로 사용할 수 있는 예시가 아닐까 싶다!

 

새로운 방식을 통해 더 자연스러운 애니메이션 효과 적용이 가능할 것 같아 앞으로도 자주 사용하게 될 것 같다. 너무 남발하게 되면 뷰 성능에 영향을 줄 수 있으니 이 점 인식하면서 사용해야지..!