seong_hye, the developer

SwiftUI) Transition, Animation에 대해 알아보기 본문

IOS/SwiftUI

SwiftUI) Transition, Animation에 대해 알아보기

seong_hye 2024. 10. 16.

 

📘 Transition

뷰의 존재 유무의 변화를 애니메이션

뷰를 이동할 때의 개념보다는 뷰를 보여지게 하거나 사라지게 하는 애니메이션의 개념

.transition(_:)으로 선언하여 사용 가능

들어가는 인수는 AnyTransition

트랜지션이 실행되려면 상태 변경을 애니메이션으로 감싸야 함

withAnimation { ... } 또는 .animation


🔍 AnyTransition

.opacity 페이드 인 / 아웃
.scale 확대 / 축소로 등장 / 퇴장
.move(edge: .top/.bottom/.leading/.trailing) 가장자리에서 슬라이드
.slide 부모의 leading -> trailing로 슬라이드 (언어 방향성 영향)
.identity 트랜지션 없음 (디버그, 조건부 조합에 사용)

🔹결합 및 비대칭 기능도 존재

// 결합: 이동 + 페이드
.transition(.move(edge: .bottom).combined(with: .opacity))

// 비대칭: 등장/퇴장 서로 다르게
.transition(.asymmetric(
	insertion: .move(edge: .bottom).combined(with: .opacity),
    removal: .scale.combined(with: .opacity)
))

🔹커스텀 트랜지션 (Modifier 기반)

extension AnyTransition {
	static var blurScale: AnyTransition {
    	.modifier(
        	active: BlueScale(blur: 8, scale: 0.85, opacity: 0),
            identity: BlurScale(blue: 0, scale: 1.0, opacity: 1)
        )
    }
}

struct BlurScale: ViewModifier {
	let blur: CGFloat
    let scale: CGFloat
    let opacity: Double
    func body(content: Content) -> some View {
    	content	
        	.blur(radius: blur)
            .scaleEffect(scale)
            .opacity(opacity)
    }
}

if show {
	ContentBox().transition(.blurScale)
}

Button("Change") {
	withAnimation(.spring()) { show.toggle() }
}

🔹예시 모음

아래에서 등장, 작아지며 사라짐

VStack {
        ZStack {
            if show {
                Rectangle()
                    .foregroundColor(.blue)
                    .cornerRadius(20)
                    .frame(width: 200, height: 300)
                    .transition(.asymmetric(
                        insertion: .move(edge: .bottom).combined(with: .opacity),
                        removal:   .scale(scale: 0.9).combined(with: .opacity)
                    ))
            }
        }
            
        Button ("change") {
            withAnimation(.snappy) { show.toggle()}
        }
    }
}


 

✅ 토스트 / 배너

ZStack {
    if showToast {
        Text("저장 완료!")
            .padding().background(.black.opacity(0.8)).foregroundColor(.white)
            .clipShape(Capsule())
            .transition(.move(edge: .top).combined(with: .opacity))
            .zIndex(1)
    }
    
    VStack {
        Button ("change") {
            trigger.toggle()
       }
    }
    .onChange(of: trigger) { _ in
        withAnimation(.snappy) {
            showToast = true
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
            withAnimation(.snappy) { showToast = false }
        }
    }
}


 

✅ 클릭 시 확대되는 기능

@Namespace private var ns
@State private var showDetail = false

ZStack(alignment: .topLeading) {
    if !showDetail {
        HStack(spacing: 16) {
            RoundedRectangle(cornerRadius: 16)
                .fill(.pink)
                .matchedGeometryEffect(id: "card", in: ns)
                .frame(width: 120, height: 80)
            Text("Preview")
                .frame(width: 100, alignment: .leading)
                .matchedGeometryEffect(id: "title", in: ns)
        }
        .padding()
        .onTapGesture {
            withAnimation(.bouncy) { showDetail = true }
        }
        .zIndex(0)
    } else {
        VStack(alignment: .leading, spacing: 12) {
            RoundedRectangle(cornerRadius: 24)
                .fill(.pink)
                .matchedGeometryEffect(id: "card", in: ns)
                .frame(height: 260)

            Text("Preview")
                .frame(width: 140, alignment: .leading)
                .font(.title.bold())
                .matchedGeometryEffect(id: "title", in: ns)

            Text("상세 설명 ...")
                .foregroundStyle(.secondary)
        }
        .padding()
        .transition(.opacity) // 주변 요소는 페이드
        .onTapGesture {
            withAnimation(.bouncy) { showDetail = false }
        }
        .zIndex(1) // 상세가 위에
    }
}
.ignoresSafeArea(edges: .bottom)


📘 Animation

이미 화면에 존재하는 뷰의 속성 변화를 부드럽게 만드는 것


🔹 명시적(Explicit) - withAnimation

상태를 바꾸는 순간에만 애니메이션이 적용됨

@State private var on = false

var body: some View {
	VStack(spacing: 24) {
    	Circle()
        	.fill(on ? .mint : .pint)
            .frame(width: on ? 180 : 80, height: on ? 180 : 80)
            .offset(y: on ? -80 : 0)
            
        Button("Change") {
        	withAnimation(.spring(response: 0.35), dampingFraction: 0.85)) {
            	on.toggle()
            }
        }
    }
}

🔹암시적(Implicit) - .animation(_:value:) (iOS 15+ 권장)

어떤 상태가 변할 때 애니메이션할지 value: 로 명시함

@State private var on = false

var body: some View {
	VStack(spacing: 24) {
    	Circle()
        	.fill(on ? .mint : .pint)
            .frame(width: on ? 180 : 80, height: on ? 180 : 80)
            .offset(y: on ? -80 : 0)
            .animation(.spring(), value: on)	//on일 때만 애니메이션
    }
}

🔹대표 애니메이션

.animation(.linear(duration: 0.3), value: x)
.animation(.easeInOut(duration: 0.35), value: x)
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: x)  // 스프링
.animation(.bouncy, value: x)     // iOS 17+
.animation(.snappy, value: x)     // iOS 17+
.animation(.smooth, value: x)     // iOS 17+

// 반복, 지연, 속ㄷ
.animation(.easeInOut(duration: 1).delay(0.2), value: x)
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: spinning)
.animation(.easeInOut(duration: 1).repeatCount(3, autoreverses: true), value: pulsate)
.animation(.easeInOut(duration: 0.6).speed(1.5), value: x)

🔹애니메이션이 가능한 경우

숫자 / 각도 / 색 / 오파시티 / 프레임 / 오프셋 / 회전 / 스케일 / 블러 / 그레이디언트 등 대부분의 수치 기반 modifier

텍스트 문자열, 이미지 교체 자체는 직접 애니메이션 불가 (대신 opacity로 페이드 등 조합)


🔹예시 모음

 개별 움직임

@State private var sizeOn = false
@State private var colorOn = false

var body: some View {
    let base = RoundedRectangle(cornerRadius: 20)
        .frame(width: sizeOn ? 200 : 120, height: sizeOn ? 120 : 80)
        .foregroundStyle(colorOn ? .orange : .blue)

    VStack(spacing: 20) {
        base
            .animation(.spring(), value: sizeOn)   // 크기 변화만 스프링
            .animation(.easeInOut(duration: 0.25), value: colorOn) // 색 변화는 빠르게

        HStack {
            Button("Size") { withAnimation { sizeOn.toggle() } }
            Button("Color") { withAnimation { colorOn.toggle() } }
        }
    }
}


반복 / 자율 애니메이션 패턴

@State private var spin = false
@State private var pulse = false

var body: some View {
    VStack(spacing: 24) {
        Image(systemName: "arrow.2.circlepath")
            .rotationEffect(.degrees(spin ? 360 : 0))
            .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: spin)
            .onAppear { spin = true }

        Circle()
            .scaleEffect(pulse ? 1.1 : 0.95)
            .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: pulse)
            .onAppear { pulse = true }
    }
}


✅ 제스처를 통한 애니메이션

@GestureState private var drag = CGSize.zero
@State private var pos = CGSize.zero

var body: some View {
    let dragGesture = DragGesture()
        .updating($drag) { value, state, _ in
            state = value.translation
        }
        .onEnded { _ in
            withAnimation(.spring()) { pos = .zero }
        }

    Circle()
        .frame(width: 80, height: 80)
        .offset(x: pos.width + drag.width, y: pos.height + drag.height)
        .gesture(dragGesture)
}


 List / 레이아웃 변화 애니메이션

@State private var items = [1,2,3,4,5]
@State private var reversed = false

var body: some View {
    VStack {
        Button("Toggle Order") { reversed.toggle() }

        List(reversed ? items.reversed() : items, id: \.self) { i in
            Text("Row \(i)")
        }
        .animation(.easeInOut, value: reversed)  // 순서 뒤집기 애니메이션
    }
}


➡️ 차이 정리

항목 animation transition
무엇을 이미 존재하는 뷰의 속성 변화 뷰의 삽입/제거 자체를 애니메이트
언제 동작 상태 값이 바뀌어 modifier 값이 달라질 때 if/else, switch, ForEach로 뷰가 생기거나 사라질 때
적용 방법 withAnimation{},
.animation(.spring(), value: state)
.transition(.opacity),
.transition(.move(edge: .bottom))
애니메이션 지정 곡선/스프링/반복 보통 withAnimation{} 또는 .animation(..)로 언제 적용
자주 하는 실수 .animation(_:)만 붙이고 value: 안 주는 실수 항상 뷰가 존재하면 transition 안 먹음

 


➡️ 사용하는 경우

뷰가 이미 화면에 있고 속성 값만 바뀜 -> animation

뷰를 보여주거나 (삽입) 숨긴다 (제거) -> transition

함께 사용하는 경우 ~ 토글 시점에 withAnimation으로 전환 실행 + 해당 뷰에 적절한 transition 지정


⚠️ 주의 사항

- 트랜지션이 안 먹는 경우

뷰가 진짜로 삽입 / 제거되는 경우인지 확인 (if / switch) ~> opacity(0)로 숨기는 경우는 안됨

- 깜빡이거나 튀는 경우

각 뷰에 상반된 transition을 지정하고 zIndex로 레이어 순서를 명확히 해야 함

동일 영역을 겹치는 동안 앞 / 뒤가 확실해야 깔끔함

- iOS 15+에서 .animation(_:) 경고/ 무시

value: 버전 사용 또는 withAnimation으로 트리거 시점에만 명시


 

Comments