개요
최근 SwiftUI의 Animation 관련하여 공부를 하다가, @Namespace와 .matchedGeometryEffect(id:in:)에 대해 알게 되었다
이들은 SwiftUI에서 애니메이션 효과를 만들 때 사용되는 강력한 도구로, 서로 다른 뷰 사이에서 부드러운 전환 애니메이션을 만들 수 있다
뭔가 유용한 것 같은데 어떻게 활용될지는 잘 몰라서 그 당시 그냥 간단한 예시로만 정리했었는데,
최근에 유튜브를 보다가 관련 영상이 떠서 보다가 글로 다시 정리하게 되었다
@Namespace
- SwiftUI에서 고유한 네임스페이스를 생성하는 프로퍼티 래퍼다
- 주로 .matchedGeometryEffect(id:in:)와 함께 사용되어 애니메이션의 컨텍스트를 제공한다
- 리팩터링해서 뷰에서 따로 인자로 사용하려면 Namespace.ID를 타입으로 사용하면 된다
.matchedGeometryEffect(id:in:)
- 서로 다른 뷰 사이에서 크기와 위치를 일치시키는 수정자다
- id에 고유 식별자를 전달해서 어떤 뷰들을 매핑시켜야 하는지 지정한다
- in에 @Namespace 프로퍼티 래퍼가 사용된 변수를 전달해서 동일한 네임스페이스 내에서 작동하게 한다
- 뷰가 나타나거나 사라질 때 부드러운 전환 애니메이션을 만든다
기본예시
기본적으로 뷰에 대해 특정 상태일 때와 아닐 때를 조건문(if-else문)을 통해 구분하여,
같은 @Namespace를 부여하고 .matchedGeometryEffect(id:in:)를 사용한다
우선은 두 요소없이 그냥 코드를 작성해 보면 다음과 같다
struct ExampleView: View {
@State var show = false
var body: some View {
ZStack{
if !show {
VStack {
Text("Title")
.font(.largeTitle)
Text("subtitle")
Spacer()
}
} else {
VStack {
Spacer()
Text("subtitle")
Text("Title")
.font(.largeTitle)
}
}
}
.onTapGesture {
withAnimation {
show.toggle()
}
}
}
}
@Namespace를 통해 애니메이션에 관련된 관리되는 값들을 관리하며, 이를 .matchedGeometryEffect(id:in:)에서 이용한다
그러면 그냥 랜더링되던 이전 예시코드는 두 뷰 간 애니메이션 효과가 적용되게 된다
struct ExampleView: View {
@Namespace var namespace
@State var show = false
var body: some View {
ZStack{
if !show {
VStack {
Text("Title")
.font(.largeTitle)
.matchedGeometryEffect(id: "title", in: namespace)
Text("subtitle")
.matchedGeometryEffect(id: "subtitle", in: namespace)
Spacer()
}
} else {
VStack {
Spacer()
Text("subtitle")
.matchedGeometryEffect(id: "subtitle", in: namespace)
Text("Title")
.font(.largeTitle)
.matchedGeometryEffect(id: "title", in: namespace)
}
}
}
.onTapGesture {
withAnimation {
show.toggle()
}
}
}
}
실전예시
기본구조에서는 Text 뷰에 대해서만 단순히 .matchedGeometryEffect를 적용했지만, 실제로는 모든 뷰 요소들에 적용하여 활용할 수 있다
또한 다음 예시처럼 .background 및 .mask 내의 뷰에 적용하여 다양한 효과를 구현할 수 있다
struct MatchedView: View {
@Namespace var namespace
@State var show = false
var body: some View {
ZStack {
BackgroundView()
if !show {
VStack {
Spacer()
/// VSTACK
VStack(alignment: .leading, spacing: 12) {
Text("MacOS 15")
.font(.largeTitle)
.bold()
.matchedGeometryEffect(id: "title", in: namespace)
Text("Sequoia")
.font(.title3)
.matchedGeometryEffect(id: "subtitle", in: namespace)
Text("작업 공간을 원하는 레이아웃으로 바로 간편히 정리하고, 집중하려는 일에 꼭 맞게 웹페이지를 브라우징하고...")
.font(.footnote)
.matchedGeometryEffect(id: "text", in: namespace)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 30)
.fill(.ultraThinMaterial)
.blur(radius: 30)
/// (1)
.matchedGeometryEffect(id: "blur", in: namespace)
}
}
.padding()
.foregroundColor(.white)
.background{
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
/// (2)
.matchedGeometryEffect(id: "background", in: namespace)
}
.mask {
RoundedRectangle(cornerRadius: 30)
/// (3)
.matchedGeometryEffect(id: "mask", in: namespace)
}
.frame(height: 300)
.padding()
} else{
ScrollView{
VStack {
Spacer()
}
.frame(maxWidth: .infinity)
.frame(height: 500)
.foregroundColor(.black)
.background {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
/// (2)
.matchedGeometryEffect(id: "background", in: namespace)
}
.mask {
RoundedRectangle(cornerRadius: 30)
/// (3)
.matchedGeometryEffect(id: "mask", in: namespace)
}
.overlay {
/// VSTACK
VStack(alignment: .leading, spacing: 12) {
Text("작업 공간을 원하는 레이아웃으로 바로 간편히 정리하고, 집중하려는 일에 꼭 맞게 웹페이지를 브라우징하고...")
.font(.footnote)
.matchedGeometryEffect(id: "text", in: namespace)
Text("Sequoia")
.font(.title3)
.matchedGeometryEffect(id: "subtitle", in: namespace)
Text("MacOS 15")
.font(.largeTitle)
.bold()
.matchedGeometryEffect(id: "title", in: namespace)
Divider()
HStack {
Image(systemName: "applelogo")
Text("올가을 출시 예정")
}
.foregroundColor(.secondary)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 30)
.fill(.ultraThinMaterial)
/// (1)
.matchedGeometryEffect(id: "blur", in: namespace)
}
.offset(y: 250)
.padding()
}
}
}
}
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
show.toggle()
}
}
}
}
코드가 길어서 보기가 귀찮지만, .background와 .mask에 적용된 .matchedGeometryEffect를 중점적으로 보면 다음과 같다
우선 show가 false인 상태에선, VSTACK이 Image("bg")를 배경으로 가지고 있고, 이를 mask를 통해 둥근 모서리를 가진 형태로 존재하고 있다
하지만 show가 true가 되면, VSTACK은 Image("bg")를 배경으로 가지고 있고, 이를 mask를 통해 둥근 모서리를 가진 형태에 overlay 되어 배치가 변한다
이때 각각의 뷰 요소들에 .matchedGeometryEffect를 적용시켜서 상태 전환 시 애니메이션을 만들었다
리팩토링
기본구조를 바탕으로 CompactView와 ExpandedView로 나누어 코드를 작성하면 다음과 같다
이때 각 뷰에는 @Namespace를 전달하여, .matchedGeometryEffect가 적용될 수 있도록 한다
@Namespace인 변수는 Namespace.ID으로 타입을 지정하여 인자로 사용한다
struct MatchedView: View {
@Namespace var namespace
@State var show = false
var body: some View {
ZStack {
BackgroundView()
if !show {
CompactView(namespace: namespace)
} else {
ExpandedView(namespace: namespace)
}
}
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
show.toggle()
}
}
}
}
struct CompactView: View {
var namespace: Namespace.ID
var body: some View {
VStack {
Spacer()
VStack(alignment: .leading, spacing: 12) {
Text("MacOS 15")
.font(.largeTitle)
.bold()
.matchedGeometryEffect(id: "title", in: namespace)
Text("Sequoia")
.font(.title3)
.matchedGeometryEffect(id: "subtitle", in: namespace)
Text("작업 공간을 원하는 레이아웃으로 바로 간편히 정리하고, 집중하려는 일에 꼭 맞게 웹페이지를 브라우징하고...")
.font(.footnote)
.matchedGeometryEffect(id: "text", in: namespace)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 30)
.fill(.ultraThinMaterial)
.blur(radius: 30)
.matchedGeometryEffect(id: "blur", in: namespace)
}
}
.padding()
.foregroundColor(.white)
.background{
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.matchedGeometryEffect(id: "background", in: namespace)
}
.mask {
RoundedRectangle(cornerRadius: 30)
.matchedGeometryEffect(id: "mask", in: namespace)
}
.frame(height: 300)
.padding()
}
}
struct ExpandedView: View {
var namespace: Namespace.ID
var body: some View {
ScrollView {
VStack {
Spacer()
}
.frame(maxWidth: .infinity)
.frame(height: 500)
.foregroundColor(.black)
.background {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.matchedGeometryEffect(id: "background", in: namespace)
}
.mask {
RoundedRectangle(cornerRadius: 30)
.matchedGeometryEffect(id: "mask", in: namespace)
}
.overlay {
VStack(alignment: .leading, spacing: 12) {
Text("작업 공간을 원하는 레이아웃으로 바로 간편히 정리하고, 집중하려는 일에 꼭 맞게 웹페이지를 브라우징하고...")
.font(.footnote)
.matchedGeometryEffect(id: "text", in: namespace)
Text("Sequoia")
.font(.title3)
.matchedGeometryEffect(id: "subtitle", in: namespace)
Text("MacOS 15")
.font(.largeTitle)
.bold()
.matchedGeometryEffect(id: "title", in: namespace)
Divider()
HStack {
Image(systemName: "applelogo")
Text("올가을 출시 예정")
}
.foregroundColor(.secondary)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 30)
.fill(.ultraThinMaterial)
.matchedGeometryEffect(id: "blur", in: namespace)
}
.offset(y: 250)
.padding()
}
}
}
}
마무리
해당 유튜브 영상 덕분에 @Namespace, .matchedGeometryEffect 뿐만 아니라 .mask, .blur 같은 수정자에 대해서도 배울 수 있어서 좋았다
언젠가 이런 고오급효과를 적용한 앱을 만들어야 할 때, 유용할 것 같다
'iOS+ > SwiftUI' 카테고리의 다른 글
EnvironmentValues (0) | 2025.03.09 |
---|