이 글은 누구를 위한 것인가
- 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()로 불필요한 재계산 방지.