콘텐츠 대표 이미지 - Core Animation과 UIView 애니메이션으로 만드는 인터랙티브 UI 완전정복

Core Animation과 UIView 애니메이션으로 만드는 인터랙티브 UI 완전정복

iOS 개발자라면 꼭 알아야 할 애니메이션 기술의 모든 것 ✨

🎬 애니메이션, 왜 이렇게 중요해졌을까?

요즘 앱 하나 켜보면 진짜 다들 애니메이션 미쳤다 싶을 정도로 화려하잖아요 ㅋㅋㅋ 근데 이게 그냥 예쁘자고 넣는 게 아니라는 거! 사용자 경험(UX)에서 애니메이션은 이제 선택이 아니라 필수가 됐어요. 버튼 하나 눌렀을 때 딱딱하게 화면만 바뀌는 것보다, 부드럽게 전환되면서 피드백을 주는 게 훨씬 직관적이고 자연스럽거든요.

iOS 개발할 때 애니메이션을 구현하는 방법은 크게 두 가지예요. 하나는 UIView 애니메이션이고, 다른 하나는 Core Animation이죠. 둘 다 강력한 도구인데, 언제 뭘 써야 하는지 헷갈리는 분들 많더라고요. 오늘은 이 두 가지를 완전 파헤쳐볼 거예요! 💪

🎯 이 글에서 다룰 내용 • UIView 애니메이션의 기본부터 고급 기법까지
• Core Animation의 작동 원리와 실전 활용법
• 두 방식의 차이점과 선택 기준
• 인터랙티브 UI를 위한 실전 테크닉
• 성능 최적화 꿀팁들
iOS 애니메이션 구조 UIView Animation 간편한 고수준 API 위치 크기 투명도 빠른 프로토타이핑 직관적인 코드 Core Animation 강력한 저수준 API Layer Transform Custom 세밀한 제어 고성능 렌더링 조합 가능

🎨 UIView 애니메이션 - 시작은 여기서부터

UIView 애니메이션은 진짜 입문자 친화적이에요 ㅋㅋㅋ Apple이 개발자들 편하라고 만든 고수준 API거든요. 복잡한 거 신경 안 쓰고도 멋진 애니메이션을 뚝딱 만들 수 있어요.

가장 기본적인 형태는 UIView.animate 메서드예요. 이거 하나만 알아도 앱이 확 살아나는 느낌을 받을 수 있죠.

📌 기본 애니메이션 구조

UIView 애니메이션의 가장 심플한 형태부터 볼까요? 코드 보면 바로 이해될 거예요.

UIView.animate(withDuration: 0.3) {
    myView.alpha = 0.0
    myView.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
}

이게 끝이에요 ㅋㅋㅋ 0.3초 동안 뷰가 투명해지면서 1.5배로 커지는 애니메이션이 완성됐어요. 클로저 안에 최종 상태만 적어주면 UIKit이 알아서 중간 프레임들을 계산해서 부드럽게 전환시켜줘요.

근데 실전에서는 이것보다 좀 더 복잡한 상황이 많죠. 애니메이션 끝나고 뭔가 해야 할 때도 있고, 타이밍을 조절하고 싶을 때도 있고요.

💡 애니메이션 가능한 UIView 속성들 frame - 뷰의 위치와 크기
bounds - 뷰의 내부 좌표계
center - 뷰의 중심점
transform - 회전, 크기 조절, 이동
alpha - 투명도
backgroundColor - 배경색

이 속성들은 애니메이션 블록 안에서 변경하면 자동으로 부드럽게 전환돼요!

⚙️ 고급 옵션들 활용하기

실전에서는 더 세밀한 컨트롤이 필요할 때가 많아요. 그럴 때 쓰는 게 옵션 파라미터예요.

UIView.animate(
    withDuration: 0.5,
    delay: 0.2,
    options: [.curveEaseInOut, .allowUserInteraction],
    animations: {
        self.loginButton.transform = CGAffineTransform(translationX: 0, y: -50)
        self.loginButton.alpha = 1.0
    },
    completion: { finished in
        if finished {
            print("애니메이션 완료!")
            self.showNextScreen()
        }
    }
)

여기서 주목할 점들이 몇 가지 있어요:

delay는 애니메이션 시작 전 대기 시간이에요. 여러 요소를 순차적으로 나타낼 때 개꿀이죠 ㅋㅋㅋ

options에는 다양한 설정을 넣을 수 있는데, .curveEaseInOut은 시작과 끝을 부드럽게 만들어주고, .allowUserInteraction은 애니메이션 중에도 사용자 터치를 받을 수 있게 해줘요.

completion 클로저는 애니메이션이 끝난 후 실행될 코드를 넣는 곳이에요. 연속적인 애니메이션을 만들 때 필수죠!

🌊 스프링 애니메이션으로 생동감 더하기

일반 애니메이션도 좋지만, 스프링 애니메이션을 쓰면 진짜 앱이 살아있는 것처럼 느껴져요. 물리 법칙을 시뮬레이션해서 탄성 있는 움직임을 만들어내거든요.

UIView.animate(
    withDuration: 0.6,
    delay: 0,
    usingSpringWithDamping: 0.6,
    initialSpringVelocity: 0.5,
    options: [],
    animations: {
        self.notificationBadge.transform = .identity
        self.notificationBadge.alpha = 1.0
    }
)

usingSpringWithDamping 값은 0~1 사이인데, 작을수록 통통 튀는 느낌이 강해져요. 0.6 정도면 적당히 생동감 있으면서도 과하지 않아요.

initialSpringVelocity는 초기 속도예요. 1.0이면 1초에 애니메이션 거리만큼 이동하는 속도로 시작하는 거죠. 이 값을 조절하면 더 다이나믹한 느낌을 낼 수 있어요!

🔧 Core Animation - 진짜 파워를 원한다면

UIView 애니메이션으로 안 되는 게 있을까요? 있어요 ㅋㅋㅋ 예를 들어 그림자 애니메이션, 코너 라운드 애니메이션, 3D 변환 같은 거요. 이럴 때 필요한 게 바로 Core Animation이에요.

Core Animation은 UIView 애니메이션보다 한 단계 아래 레벨에서 작동해요. 모든 UIView는 내부적으로 CALayer를 가지고 있는데, Core Animation은 이 레이어를 직접 조작하는 거죠.

🎭 CALayer의 세계

UIView는 사실 CALayer의 래퍼(wrapper)예요. 실제 화면에 그려지는 건 전부 레이어 단위로 처리되죠. 그래서 레이어를 직접 다루면 훨씬 더 많은 걸 할 수 있어요.

let layer = myView.layer

// 레이어 속성들
layer.cornerRadius = 10
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.5
layer.shadowOffset = CGSize(width: 0, height: 2)
layer.shadowRadius = 4
layer.borderWidth = 2
layer.borderColor = UIColor.blue.cgColor

이런 속성들은 UIView에는 없어요. 레이어에만 있는 거죠. 근데 여기서 끝이 아니에요. 이 속성들도 전부 애니메이션 가능해요! 🎉

✨ CABasicAnimation - 기본 중의 기본

Core Animation의 가장 기본적인 형태는 CABasicAnimation이에요. 시작값과 끝값을 지정하면 그 사이를 보간해주는 방식이죠.

let animation = CABasicAnimation(keyPath: "cornerRadius")
animation.fromValue = 0
animation.toValue = 25
animation.duration = 0.5

myView.layer.add(animation, forKey: "cornerRadiusAnimation")
myView.layer.cornerRadius = 25  // 최종 상태도 설정해야 함!

여기서 중요한 포인트! 애니메이션을 추가한 후에 실제 속성값도 변경해줘야 해요. 안 그러면 애니메이션 끝나고 원래대로 돌아가버려요 ㅠㅠ 이게 UIView 애니메이션과의 큰 차이점이에요.

keyPath는 애니메이션할 속성의 이름이에요. 레이어의 거의 모든 속성을 여기에 넣을 수 있어요.

🎯 자주 쓰는 keyPath들 opacity - 투명도
position - 위치
transform.scale - 크기
transform.rotation - 회전
backgroundColor - 배경색
shadowOpacity - 그림자 투명도
cornerRadius - 모서리 둥글기
borderWidth - 테두리 두께

🎪 CAKeyframeAnimation - 복잡한 경로 만들기

단순히 A에서 B로 가는 게 아니라, 중간에 여러 지점을 거쳐가고 싶을 때가 있죠? 그럴 때 쓰는 게 CAKeyframeAnimation이에요.

let animation = CAKeyframeAnimation(keyPath: "position")
animation.values = [
    CGPoint(x: 100, y: 100),
    CGPoint(x: 200, y: 150),
    CGPoint(x: 150, y: 250),
    CGPoint(x: 100, y: 100)
]
animation.keyTimes = [0, 0.3, 0.7, 1.0]
animation.duration = 2.0
animation.timingFunctions = [
    CAMediaTimingFunction(name: .easeIn),
    CAMediaTimingFunction(name: .easeOut),
    CAMediaTimingFunction(name: .easeInEaseOut)
]

myView.layer.add(animation, forKey: "complexPath")

values 배열에 중간 지점들을 넣어주면 그 순서대로 이동해요. keyTimes는 각 지점에 도달하는 시간 비율이고요 (0~1 사이 값).

더 멋진 건 path를 사용하는 방법이에요. 베지어 곡선 같은 복잡한 경로도 만들 수 있어요!

let path = UIBezierPath()
path.move(to: CGPoint(x: 50, y: 50))
path.addCurve(
    to: CGPoint(x: 250, y: 50),
    controlPoint1: CGPoint(x: 100, y: 0),
    controlPoint2: CGPoint(x: 200, y: 100)
)

let animation = CAKeyframeAnimation(keyPath: "position")
animation.path = path.cgPath
animation.duration = 1.5
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)

myView.layer.add(animation, forKey: "curvePath")

이렇게 하면 뷰가 곡선을 따라 부드럽게 이동해요. 게임 같은 데서 캐릭터 움직임 만들 때 완전 유용하죠 ㅋㅋㅋ

🎬 CAAnimationGroup - 여러 애니메이션 동시에

하나의 레이어에 여러 애니메이션을 동시에 적용하고 싶을 때가 있어요. 예를 들어 이동하면서 동시에 회전하고 크기도 변하는 거요. 이럴 때 쓰는 게 CAAnimationGroup이에요.

let moveAnimation = CABasicAnimation(keyPath: "position")
moveAnimation.fromValue = CGPoint(x: 100, y: 100)
moveAnimation.toValue = CGPoint(x: 300, y: 300)

let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0
rotateAnimation.toValue = CGFloat.pi * 2

let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 1.0
scaleAnimation.toValue = 1.5

let group = CAAnimationGroup()
group.animations = [moveAnimation, rotateAnimation, scaleAnimation]
group.duration = 1.0
group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)

myView.layer.add(group, forKey: "complexAnimation")

그룹으로 묶으면 duration이나 timingFunction 같은 공통 속성을 한 번에 설정할 수 있어서 편해요. 각 애니메이션마다 따로 설정할 수도 있고요!

🎮 인터랙티브 애니메이션 - 사용자와 소통하기

요즘 앱들 보면 그냥 재생되는 애니메이션이 아니라, 사용자 터치에 반응하는 인터랙티브한 애니메이션이 많잖아요? 이게 진짜 UX의 핵심이에요. 사용자가 직접 컨트롤하는 느낌을 주는 거죠.

재능넷 같은 플랫폼에서도 이런 인터랙티브한 UI가 사용자 경험을 크게 향상시켜요. 프로필 카드를 스와이프하거나, 서비스 상세 정보를 펼치고 접을 때 부드러운 애니메이션이 있으면 훨씬 자연스럽거든요.

👆 제스처와 애니메이션 결합하기

가장 기본적인 인터랙티브 애니메이션은 제스처 인식기와 애니메이션을 결합하는 거예요. 팬 제스처로 뷰를 드래그하는 예제를 볼까요?

class InteractiveViewController: UIViewController {
    var animator: UIViewPropertyAnimator?
    var initialCenter: CGPoint = .zero
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
        myView.addGestureRecognizer(panGesture)
    }
    
    @objc func handlePan(_ gesture: UIPanGestureRecognizer) {
        let translation = gesture.translation(in: view)
        
        switch gesture.state {
        case .began:
            initialCenter = myView.center
            
        case .changed:
            myView.center = CGPoint(
                x: initialCenter.x + translation.x,
                y: initialCenter.y + translation.y
            )
            
        case .ended:
            let velocity = gesture.velocity(in: view)
            let magnitude = sqrt(velocity.x * velocity.x + velocity.y * velocity.y)
            let duration = min(magnitude / 2000, 0.5)
            
            UIView.animate(
                withDuration: TimeInterval(duration),
                delay: 0,
                usingSpringWithDamping: 0.7,
                initialSpringVelocity: magnitude / 1000,
                options: [],
                animations: {
                    self.myView.center = self.initialCenter
                }
            )
            
        default:
            break
        }
    }
}

이 코드의 핵심은 제스처의 속도(velocity)를 애니메이션에 반영하는 거예요. 빠르게 스와이프하면 빠르게 돌아가고, 천천히 하면 천천히 돌아가죠. 이런 디테일이 자연스러운 느낌을 만들어요! 💫

🎛️ UIViewPropertyAnimator - 인터랙티브의 끝판왕

iOS 10부터 추가된 UIViewPropertyAnimator는 진짜 게임 체인저예요. 애니메이션을 중간에 멈추거나, 되돌리거나, 속도를 바꾸는 게 다 가능해요!

class ModernAnimationViewController: UIViewController {
    var animator: UIViewPropertyAnimator?
    
    func setupInteractiveAnimation() {
        animator = UIViewPropertyAnimator(duration: 1.0, curve: .easeInOut) {
            self.cardView.transform = CGAffineTransform(translationX: 0, y: -200)
            self.cardView.alpha = 0.5
        }
        
        // 애니메이션을 바로 시작하지 않고 대기
        animator?.pauseAnimation()
    }
    
    @objc func handleSlider(_ slider: UISlider) {
        // 슬라이더 값에 따라 애니메이션 진행도 조절
        animator?.fractionComplete = CGFloat(slider.value)
    }
    
    func completeAnimation() {
        animator?.startAnimation()
    }
}

fractionComplete 속성이 진짜 미쳤어요 ㅋㅋㅋ 0~1 사이 값으로 애니메이션의 어느 지점이든 바로 이동할 수 있거든요. 슬라이더로 애니메이션을 직접 컨트롤하는 UI를 만들 수 있어요.

더 멋진 건 애니메이션 중간에 방향을 바꿀 수도 있다는 거예요!

// 애니메이션 진행 중에 역방향으로 전환
animator?.isReversed = true

// 애니메이션 일시정지
animator?.pauseAnimation()

// 다시 시작
animator?.startAnimation()

// 즉시 완료 상태로
animator?.stopAnimation(false)
animator?.finishAnimation(at: .end)

🌊 스크롤과 연동된 애니메이션

스크롤 위치에 따라 변하는 애니메이션도 인터랙티브 UI의 대표적인 예죠. 헤더가 작아지거나, 배경이 변하거나 하는 거요.

class ScrollAnimationViewController: UIViewController, UIScrollViewDelegate {
    @IBOutlet weak var scrollView: UIScrollView!
    @IBOutlet weak var headerView: UIView!
    
    let maxHeaderHeight: CGFloat = 200
    let minHeaderHeight: CGFloat = 80
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offset = scrollView.contentOffset.y
        
        // 스크롤 오프셋에 따라 헤더 높이 조절
        let newHeight = max(minHeaderHeight, maxHeaderHeight - offset)
        headerHeightConstraint.constant = newHeight
        
        // 투명도도 함께 조절
        let progress = (maxHeaderHeight - newHeight) / (maxHeaderHeight - minHeaderHeight)
        headerView.alpha = 1.0 - (progress * 0.5)
        
        // 타이틀 크기 조절
        let scale = 1.0 - (progress * 0.3)
        titleLabel.transform = CGAffineTransform(scaleX: scale, y: scale)
    }
}

스크롤 델리게이트에서 오프셋 값을 받아서 실시간으로 UI를 업데이트하는 방식이에요. 이렇게 하면 스크롤에 완벽하게 동기화된 애니메이션을 만들 수 있어요!

인터랙티브 애니메이션 플로우 사용자 입력 터치 제스처 스크롤 이벤트 처리 UIGestureRecognizer Delegate 메서드 상태 계산 애니메이션 실행 UIView.animate PropertyAnimator Core Animation 실전 패턴 드래그 Pan Gesture + Spring Animation 스와이프 Velocity 기반 방향 감지 스크롤 Offset 연동 실시간 업데이트 진행률 fractionComplete 양방향 제어

⚡ 성능 최적화 - 부드러운 60fps 유지하기

애니메이션 만드는 건 재밌는데, 막상 실행해보면 버벅거리는 경우 많죠? ㅠㅠ 특히 복잡한 UI에서 여러 애니메이션이 동시에 돌아갈 때 성능 문제가 생겨요. iOS는 기본적으로 60fps(초당 60프레임)를 목표로 하는데, 이걸 유지하려면 몇 가지 주의할 점이 있어요.

🎯 GPU 가속 활용하기

애니메이션은 CPU가 아니라 GPU에서 처리되는 게 훨씬 빨라요. 그런데 모든 속성이 GPU 가속을 받는 건 아니에요!

✅ GPU 가속되는 속성들 (빠름!) • transform (이동, 회전, 크기)
• opacity (투명도)
• backgroundColor (레이어의 배경색)
• contents (레이어 컨텐츠)

❌ CPU에서 처리되는 속성들 (느림)

• frame (레이아웃 재계산 필요)
• bounds (레이아웃 재계산 필요)
• cornerRadius (마스킹 필요시)
• shadow (복잡한 계산)

그래서 위치를 바꿀 때 frame보다는 transform을 쓰는 게 훨씬 좋아요!

// ❌ 느린 방법
UIView.animate(withDuration: 0.3) {
    myView.frame.origin.x += 100
}

// ✅ 빠른 방법
UIView.animate(withDuration: 0.3) {
    myView.transform = CGAffineTransform(translationX: 100, y: 0)
}

🔍 그림자 최적화하기

그림자는 진짜 성능 킬러예요 ㅋㅋㅋ 특히 실시간으로 변하는 뷰에 그림자가 있으면 엄청 느려져요. 이럴 때 쓰는 꿀팁이 있어요!

// ❌ 느린 방법 - 매번 그림자 경로 계산
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.5
layer.shadowOffset = CGSize(width: 0, height: 2)
layer.shadowRadius = 4

// ✅ 빠른 방법 - 그림자 경로 미리 지정
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.5
layer.shadowOffset = CGSize(width: 0, height: 2)
layer.shadowRadius = 4
layer.shadowPath = UIBezierPath(
    roundedRect: layer.bounds,
    cornerRadius: layer.cornerRadius
).cgPath

shadowPath를 지정하면 시스템이 매번 그림자 모양을 계산할 필요가 없어져요. 성능이 확 올라가죠!

🎨 래스터화(Rasterization) 활용

복잡한 레이어 계층 구조를 가진 뷰를 애니메이션할 때는 래스터화가 도움이 돼요. 레이어를 이미지로 캐싱해서 매번 다시 그리지 않는 거죠.

myView.layer.shouldRasterize = true
myView.layer.rasterizationScale = UIScreen.main.scale

// 애니메이션 후에는 다시 끄기
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
    myView.layer.shouldRasterize = false
}

주의할 점은 래스터화된 이미지는 100ms 동안만 캐시에 유지돼요. 그래서 자주 변하는 컨텐츠에는 오히려 역효과가 날 수 있어요. 애니메이션 중에만 켜고 끄는 게 좋아요!

📊 성능 측정하기

최적화했는지 확인하려면 측정이 필수죠! Xcode의 Instruments를 쓰면 정확하게 측정할 수 있어요.

🛠️ Instruments 활용법 1. Core Animation 템플릿 선택
2. FPS 그래프로 프레임 드롭 확인
3. Color Blended Layers로 블렌딩 문제 찾기
4. Color Offscreen-Rendered로 오프스크린 렌더링 확인
5. Color Misaligned Images로 이미지 정렬 문제 체크

디버그 옵션에서 Color Blended Layers를 켜면 블렌딩이 발생하는 부분이 빨간색으로 표시돼요. 이게 많으면 성능이 떨어지는 거예요. 불투명한 배경색을 설정하면 해결되는 경우가 많아요!

// 블렌딩 줄이기
myView.backgroundColor = .white  // 투명하지 않은 색
myView.isOpaque = true  // 불투명 플래그 설정

🎭 실전 예제 - 카드 스와이프 UI 만들기

이론만 보면 재미없으니까 실전 예제 하나 만들어볼까요? 틴더 스타일의 카드 스와이프 UI를 구현해볼게요. 이거 완전 인기 많은 패턴이잖아요 ㅋㅋㅋ

class SwipeableCardView: UIView {
    private var animator: UIViewPropertyAnimator?
    private var initialCenter: CGPoint = .zero
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
        setupGesture()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
        setupGesture()
    }
    
    private func setupView() {
        layer.cornerRadius = 16
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.2
        layer.shadowOffset = CGSize(width: 0, height: 4)
        layer.shadowRadius = 8
        layer.shadowPath = UIBezierPath(
            roundedRect: bounds,
            cornerRadius: 16
        ).cgPath
    }
    
    private func setupGesture() {
        let panGesture = UIPanGestureRecognizer(
            target: self,
            action: #selector(handlePan)
        )
        addGestureRecognizer(panGesture)
    }
    
    @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
        let translation = gesture.translation(in: superview)
        let velocity = gesture.velocity(in: superview)
        
        switch gesture.state {
        case .began:
            initialCenter = center
            animator?.stopAnimation(true)
            
        case .changed:
            // 카드 이동
            center = CGPoint(
                x: initialCenter.x + translation.x,
                y: initialCenter.y + translation.y
            )
            
            // 회전 효과 (최대 15도)
            let rotationAngle = translation.x / bounds.width * 0.26
            transform = CGAffineTransform(rotationAngle: rotationAngle)
            
            // 투명도 조절
            let distance = abs(translation.x)
            alpha = 1.0 - min(distance / 200, 0.5)
            
        case .ended:
            let threshold: CGFloat = 100
            let shouldDismiss = abs(translation.x) > threshold
            
            if shouldDismiss {
                // 카드 날리기
                let direction: CGFloat = translation.x > 0 ? 1 : -1
                animateDismiss(direction: direction, velocity: velocity)
            } else {
                // 원위치로 복귀
                animateReturn()
            }
            
        default:
            break
        }
    }
    
    private func animateDismiss(direction: CGFloat, velocity: CGPoint) {
        let screenWidth = UIScreen.main.bounds.width
        let finalX = initialCenter.x + (screenWidth * direction)
        let finalY = initialCenter.y + velocity.y * 0.1
        
        animator = UIViewPropertyAnimator(
            duration: 0.5,
            dampingRatio: 0.8
        ) {
            self.center = CGPoint(x: finalX, y: finalY)
            self.transform = CGAffineTransform(rotationAngle: 0.5 * direction)
            self.alpha = 0
        }
        
        animator?.addCompletion { _ in
            self.removeFromSuperview()
            // 다음 카드 표시 로직
        }
        
        animator?.startAnimation()
    }
    
    private func animateReturn() {
        animator = UIViewPropertyAnimator(
            duration: 0.4,
            dampingRatio: 0.6
        ) {
            self.center = self.initialCenter
            self.transform = .identity
            self.alpha = 1.0
        }
        
        animator?.startAnimation()
    }
}

이 코드의 핵심 포인트들을 정리해볼게요:

1. 실시간 피드백 - 드래그하는 동안 카드가 회전하고 투명도가 변해요. 사용자가 어떤 일이 일어날지 예측할 수 있죠.

2. 속도 기반 애니메이션 - 제스처의 velocity를 활용해서 자연스러운 물리 효과를 만들어요.

3. 임계값 판단 - 일정 거리 이상 드래그하면 카드가 날아가고, 아니면 돌아와요.

4. 스프링 애니메이션 - dampingRatio를 조절해서 탄성 있는 움직임을 만들어요.

🎨 추가 효과 넣기

여기에 좀 더 디테일을 추가해볼까요? 좋아요/싫어요 표시를 넣어보겠습니다!

class EnhancedSwipeableCardView: SwipeableCardView {
    private let likeLabel = UILabel()
    private let nopeLabel = UILabel()
    
    override func setupView() {
        super.setupView()
        
        // 좋아요 라벨
        likeLabel.text = "LIKE"
        likeLabel.font = .boldSystemFont(ofSize: 48)
        likeLabel.textColor = .systemGreen
        likeLabel.alpha = 0
        likeLabel.transform = CGAffineTransform(rotationAngle: -0.3)
        addSubview(likeLabel)
        
        // 싫어요 라벨
        nopeLabel.text = "NOPE"
        nopeLabel.font = .boldSystemFont(ofSize: 48)
        nopeLabel.textColor = .systemRed
        nopeLabel.alpha = 0
        nopeLabel.transform = CGAffineTransform(rotationAngle: 0.3)
        addSubview(nopeLabel)
        
        // 레이아웃
        likeLabel.translatesAutoresizingMaskIntoConstraints = false
        nopeLabel.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            likeLabel.topAnchor.constraint(equalTo: topAnchor, constant: 40),
            likeLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40),
            
            nopeLabel.topAnchor.constraint(equalTo: topAnchor, constant: 40),
            nopeLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40)
        ])
    }
    
    override func handlePan(_ gesture: UIPanGestureRecognizer) {
        super.handlePan(gesture)
        
        if gesture.state == .changed {
            let translation = gesture.translation(in: superview)
            
            // 스와이프 방향에 따라 라벨 표시
            if translation.x > 0 {
                likeLabel.alpha = min(translation.x / 100, 1.0)
                nopeLabel.alpha = 0
            } else {
                nopeLabel.alpha = min(abs(translation.x) / 100, 1.0)
                likeLabel.alpha = 0
            }
        } else if gesture.state == .ended {
            // 라벨 숨기기
            UIView.animate(withDuration: 0.2) {
                self.likeLabel.alpha = 0
                self.nopeLabel.alpha = 0
            }
        }
    }
}

이렇게 하면 카드를 스와이프할 때 방향에 따라 LIKE나 NOPE가 점점 진하게 나타나요. 사용자에게 명확한 피드백을 주는 거죠! 이런 디테일이 UX를 완전 업그레이드시켜요 ✨

🔥 고급 테크닉 - 프로처럼 애니메이션하기

기본은 마스터했으니 이제 좀 더 고급 기술들을 알아볼까요? 프로 개발자들이 쓰는 테크닉들이에요!

🎬 CADisplayLink로 커스텀 애니메이션

때로는 기본 애니메이션으로는 표현이 안 되는 게 있어요. 예를 들어 숫자가 카운트업되는 애니메이션이나, 복잡한 물리 시뮬레이션 같은 거요. 이럴 때 CADisplayLink를 쓰면 매 프레임마다 직접 제어할 수 있어요!

class CounterLabel: UILabel {
    private var displayLink: CADisplayLink?
    private var startValue: Double = 0
    private var endValue: Double = 0
    private var startTime: CFTimeInterval = 0
    private var duration: CFTimeInterval = 0
    
    func animateCount(from: Double, to: Double, duration: TimeInterval) {
        startValue = from
        endValue = to
        self.duration = duration
        startTime = CACurrentMediaTime()
        
        displayLink?.invalidate()
        displayLink = CADisplayLink(
            target: self,
            selector: #selector(updateCount)
        )
        displayLink?.add(to: .main, forMode: .common)
    }
    
    @objc private func updateCount() {
        let elapsed = CACurrentMediaTime() - startTime
        let progress = min(elapsed / duration, 1.0)
        
        // Ease-out 효과
        let easedProgress = 1 - pow(1 - progress, 3)
        
        let currentValue = startValue + (endValue - startValue) * easedProgress
        text = String(format: "%.0f", currentValue)
        
        if progress >= 1.0 {
            displayLink?.invalidate()
            displayLink = nil
        }
    }
}

CADisplayLink는 화면 주사율(보통 60Hz)에 맞춰서 콜백을 호출해줘요. 그래서 매우 부드러운 애니메이션을 만들 수 있죠. 숫자 카운터, 프로그레스 바, 파티클 효과 같은 거 만들 때 완전 유용해요!

🌈 그라디언트 애니메이션

그라디언트 색상이 변하는 애니메이션도 멋지죠? 근데 이게 생각보다 까다로워요. CAGradientLayer의 colors 속성은 암묵적 애니메이션이 안 되거든요. 명시적으로 애니메이션을 만들어야 해요.

class GradientAnimationView: UIView {
    private let gradientLayer = CAGradientLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupGradient()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupGradient()
    }
    
    private func setupGradient() {
        gradientLayer.frame = bounds
        gradientLayer.colors = [
            UIColor.systemBlue.cgColor,
            UIColor.systemPurple.cgColor
        ]
        gradientLayer.startPoint = CGPoint(x: 0, y: 0)
        gradientLayer.endPoint = CGPoint(x: 1, y: 1)
        layer.insertSublayer(gradientLayer, at: 0)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        gradientLayer.frame = bounds
    }
    
    func animateGradient() {
        let animation = CABasicAnimation(keyPath: "colors")
        animation.fromValue = gradientLayer.colors
        animation.toValue = [
            UIColor.systemPink.cgColor,
            UIColor.systemOrange.cgColor
        ]
        animation.duration = 2.0
        animation.autoreverses = true
        animation.repeatCount = .infinity
        animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        
        gradientLayer.add(animation, forKey: "colorChange")
    }
    
    func animateGradientPosition() {
        let animation = CABasicAnimation(keyPath: "startPoint")
        animation.fromValue = CGPoint(x: 0, y: 0)
        animation.toValue = CGPoint(x: 1, y: 0)
        animation.duration = 3.0
        animation.autoreverses = true
        animation.repeatCount = .infinity
        
        gradientLayer.add(animation, forKey: "positionChange")
    }
}

그라디언트의 색상뿐만 아니라 시작점과 끝점도 애니메이션할 수 있어요. 이렇게 하면 그라디언트가 움직이는 것처럼 보이죠. 로딩 인디케이터나 배경 효과로 쓰면 완전 멋져요! 🎨

🎪 체이닝과 시퀀싱

여러 애니메이션을 순차적으로 실행하거나 동시에 실행하는 것도 중요한 스킬이에요. 복잡한 UI 전환을 만들 때 필수죠.

class AnimationSequencer {
    static func runSequence(on view: UIView) {
        // 1단계: 페이드 인
        UIView.animate(
            withDuration: 0.3,
            animations: {
                view.alpha = 1.0
            },
            completion: { _ in
                // 2단계: 크기 증가
                UIView.animate(
                    withDuration: 0.4,
                    delay: 0,
                    usingSpringWithDamping: 0.6,
                    initialSpringVelocity: 0.5,
                    animations: {
                        view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
                    },
                    completion: { _ in
                        // 3단계: 원래 크기로
                        UIView.animate(
                            withDuration: 0.3,
                            animations: {
                                view.transform = .identity
                            }
                        )
                    }
                )
            }
        )
    }
    
    // 더 깔끔한 방법 - UIViewPropertyAnimator 사용
    static func runSequenceWithAnimator(on view: UIView) {
        let fadeIn = UIViewPropertyAnimator(duration: 0.3, curve: .easeIn) {
            view.alpha = 1.0
        }
        
        let scaleUp = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.6) {
            view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
        }
        
        let scaleDown = UIViewPropertyAnimator(duration: 0.3, curve: .easeOut) {
            view.transform = .identity
        }
        
        fadeIn.addCompletion { _ in
            scaleUp.startAnimation()
        }
        
        scaleUp.addCompletion { _ in
            scaleDown.startAnimation()
        }
        
        fadeIn.startAnimation()
    }
}

completion 클로저를 체이닝하는 방식도 있지만, UIViewPropertyAnimator를 쓰면 코드가 훨씬 깔끔해져요. 각 애니메이션을 독립적으로 관리할 수 있어서 나중에 수정하기도 쉽고요!

🎯 타이밍 함수 커스터마이징

애니메이션의 느낌은 타이밍 함수에 따라 완전 달라져요. 기본 제공되는 것들도 좋지만, 직접 만들 수도 있어요!

// 커스텀 타이밍 함수 만들기
let customTiming = CAMediaTimingFunction(
    controlPoints: 0.42, 0, 0.58, 1.0
)

let animation = CABasicAnimation(keyPath: "position")
animation.timingFunction = customTiming
animation.duration = 1.0

// 또는 UIViewPropertyAnimator에서
let animator = UIViewPropertyAnimator(duration: 1.0, curve: .linear)
animator.addAnimations({
    view.center = newCenter
}, delayFactor: 0.0)

// 커스텀 커브 적용
animator.timingParameters = UICubicTimingParameters(
    controlPoint1: CGPoint(x: 0.42, y: 0),
    controlPoint2: CGPoint(x: 0.58, y: 1.0)
)

타이밍 함수는 베지어 곡선으로 정의돼요. 컨트롤 포인트를 조절해서 원하는 느낌을 만들 수 있죠. 웹사이트 cubic-bezier.com 같은 데서 시각적으로 조절해보고 값을 가져오면 편해요!

🎓 실무에서 자주 쓰는 패턴들

이론도 중요하지만 실무에서 바로 쓸 수 있는 패턴들을 알아두면 개발 속도가 확 빨라져요. 재능넷 같은 플랫폼에서 Swift 개발 재능을 공유하는 분들도 이런 패턴들을 많이 활용하시더라고요!

📱 모달 프레젠테이션 애니메이션

커스텀 모달 전환 애니메이션은 앱의 개성을 보여주는 좋은 방법이에요.

class CustomModalTransition: NSObject, UIViewControllerAnimatedTransitioning {
    let isPresenting: Bool
    
    init(isPresenting: Bool) {
        self.isPresenting = isPresenting
        super.init()
    }
    
    func transitionDuration(
        using transitionContext: UIViewControllerContextTransitioning?
    ) -> TimeInterval {
        return 0.5
    }
    
    func animateTransition(
        using transitionContext: UIViewControllerContextTransitioning
    ) {
        guard let fromVC = transitionContext.viewController(forKey: .from),
              let toVC = transitionContext.viewController(forKey: .to) else {
            return
        }
        
        let containerView = transitionContext.containerView
        
        if isPresenting {
            // 프레젠트 애니메이션
            containerView.addSubview(toVC.view)
            toVC.view.frame = containerView.bounds
            toVC.view.transform = CGAffineTransform(
                translationX: 0,
                y: containerView.bounds.height
            )
            
            UIView.animate(
                withDuration: transitionDuration(using: transitionContext),
                delay: 0,
                usingSpringWithDamping: 0.8,
                initialSpringVelocity: 0.5,
                animations: {
                    toVC.view.transform = .identity
                    fromVC.view.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
                    fromVC.view.alpha = 0.7
                },
                completion: { finished in
                    fromVC.view.transform = .identity
                    fromVC.view.alpha = 1.0
                    transitionContext.completeTransition(finished)
                }
            )
        } else {
            // 디스미스 애니메이션
            UIView.animate(
                withDuration: transitionDuration(using: transitionContext),
                animations: {
                    fromVC.view.transform = CGAffineTransform(
                        translationX: 0,
                        y: containerView.bounds.height
                    )
                    toVC.view.transform = .identity
                    toVC.view.alpha = 1.0
                },
                completion: { finished in
                    transitionContext.completeTransition(finished)
                }
            )
        }
    }
}

🔄 풀 투 리프레시

스크롤뷰를 당겨서 새로고침하는 패턴이에요. 거의 모든 앱에 있죠?

class CustomRefreshControl: UIView {
    private let activityIndicator = UIActivityIndicatorView(style: .medium)
    private let progressLayer = CAShapeLayer()
    private var isRefreshing = false
    
    var onRefresh: (() -> Void)?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }
    
    private func setupView() {
        // 프로그레스 서클
        let circlePath = UIBezierPath(
            arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
            radius: 20,
            startAngle: -.pi / 2,
            endAngle: .pi * 1.5,
            clockwise: true
        )
        
        progressLayer.path = circlePath.cgPath
        progressLayer.fillColor = UIColor.clear.cgColor
        progressLayer.strokeColor = UIColor.systemBlue.cgColor
        progressLayer.lineWidth = 3
        progressLayer.strokeEnd = 0
        layer.addSublayer(progressLayer)
        
        // 액티비티 인디케이터
        activityIndicator.center = CGPoint(x: bounds.midX, y: bounds.midY)
        activityIndicator.hidesWhenStopped = true
        addSubview(activityIndicator)
    }
    
    func updateProgress(_ progress: CGFloat) {
        guard !isRefreshing else { return }
        
        let clampedProgress = min(max(progress, 0), 1)
        progressLayer.strokeEnd = clampedProgress
        
        // 회전 효과
        let rotation = clampedProgress * .pi * 2
        progressLayer.transform = CATransform3DMakeRotation(rotation, 0, 0, 1)
    }
    
    func beginRefreshing() {
        guard !isRefreshing else { return }
        isRefreshing = true
        
        UIView.animate(withDuration: 0.2) {
            self.progressLayer.opacity = 0
        }
        
        activityIndicator.startAnimating()
        onRefresh?()
    }
    
    func endRefreshing() {
        isRefreshing = false
        activityIndicator.stopAnimating()
        
        UIView.animate(withDuration: 0.2) {
            self.progressLayer.opacity = 1
            self.progressLayer.strokeEnd = 0
        }
    }
}

// 사용 예시
class TableViewController: UIViewController, UIScrollViewDelegate {
    let refreshControl = CustomRefreshControl(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        refreshControl.onRefresh = { [weak self] in
            self?.loadData()
        }
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offset = scrollView.contentOffset.y
        if offset < 0 {
            let progress = min(abs(offset) / 100, 1.0)
            refreshControl.updateProgress(progress)
            
            if progress >= 1.0 && !scrollView.isDragging {
                refreshControl.beginRefreshing()
            }
        }
    }
    
    func loadData() {
        // 데이터 로딩...
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self.refreshControl.endRefreshing()
        }
    }
}

🎨 스켈레톤 로딩 애니메이션

데이터 로딩 중에 보여주는 스켈레톤 UI도 요즘 대세죠. 반짝이는 효과를 넣으면 더 멋있어요!

class SkeletonView: UIView {
    private let gradientLayer = CAGradientLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupSkeleton()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupSkeleton()
    }
    
    private func setupSkeleton() {
        backgroundColor = .systemGray5
        layer.cornerRadius = 8
        
        gradientLayer.colors = [
            UIColor.systemGray5.cgColor,
            UIColor.systemGray4.cgColor,
            UIColor.systemGray5.cgColor
        ]
        gradientLayer.locations = [0, 0.5, 1]
        gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
        gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
        layer.addSublayer(gradientLayer)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        gradientLayer.frame = bounds
    }
    
    func startAnimating() {
        let animation = CABasicAnimation(keyPath: "locations")
        animation.fromValue = [-1.0, -0.5, 0.0]
        animation.toValue = [1.0, 1.5, 2.0]
        animation.duration = 1.5
        animation.repeatCount = .infinity
        gradientLayer.add(animation, forKey: "shimmer")
    }
    
    func stopAnimating() {
        gradientLayer.removeAnimation(forKey: "shimmer")
    }
}

스켈레톤 애니메이션은 사용자에게 "뭔가 로딩 중이다"라는 걸 명확하게 알려줘요. 빈 화면보다 훨씬 나은 경험을 제공하죠!

🐛 흔한 실수와 해결법

애니메이션 개발하다 보면 다들 비슷한 실수를 해요 ㅋㅋㅋ 저도 처음엔 엄청 헤맸거든요. 자주 하는 실수들과 해결법을 정리해봤어요.

❌ 실수 1: 메인 스레드 블로킹

애니메이션 중에 무거운 작업을 하면 버벅거려요.

// ❌ 잘못된 방법
UIView.animate(withDuration: 0.3) {
    self.imageView.image = self.processHeavyImage()  // 메인 스레드 블로킹!
}

// ✅ 올바른 방법
DispatchQueue.global(qos: .userInitiated).async {
    let processedImage = self.processHeavyImage()
    
    DispatchQueue.main.async {
        UIView.animate(withDuration: 0.3) {
            self.imageView.image = processedImage
        }
    }
}

❌ 실수 2: 애니메이션 중첩

같은 속성에 여러 애니메이션을 동시에 적용하면 이상하게 동작해요.

// ❌ 잘못된 방법
UIView.animate(withDuration: 1.0) {
    self.myView.alpha = 0
}
UIView.animate(withDuration: 0.5) {
    self.myView.alpha = 1  // 충돌!
}

// ✅ 올바른 방법
animator?.stopAnimation(true)  // 기존 애니메이션 중지
animator = UIViewPropertyAnimator(duration: 0.5, curve: .easeInOut) {
    self.myView.alpha = 1
}
animator?.startAnimation()

❌ 실수 3: 레이아웃 제약과 애니메이션

Auto Layout을 쓸 때는 제약을 변경하고 layoutIfNeeded()를 호출해야 해요.

// ❌ 잘못된 방법
UIView.animate(withDuration: 0.3) {
    self.heightConstraint.constant = 200  // 애니메이션 안 됨
}

// ✅ 올바른 방법
self.heightConstraint.constant = 200
UIView.animate(withDuration: 0.3) {
    self.view.layoutIfNeeded()  // 레이아웃 업데이트
}

❌ 실수 4: Core Animation 최종 상태 미설정

Core Animation은 최종 상태를 따로 설정해줘야 해요.

// ❌ 잘못된 방법
let animation = CABasicAnimation(keyPath: "opacity")
animation.toValue = 0
layer.add(animation, forKey: "fade")
// 애니메이션 끝나면 원래대로 돌아감!

// ✅ 올바른 방법
let animation = CABasicAnimation(keyPath: "opacity")
animation.toValue = 0
layer.add(animation, forKey: "fade")
layer.opacity = 0  // 최종 상태 설정

❌ 실수 5: 메모리 누수

클로저에서 self를 캡처할 때 주의해야 해요.

// ❌ 잘못된 방법
UIView.animate(withDuration: 0.3) {
    self.myView.alpha = 0  // 강한 참조
} completion: { _ in
    self.dismiss(animated: true)  // 메모리 누수 가능
}

// ✅ 올바른 방법
UIView.animate(withDuration: 0.3) { [weak self] in
    self?.myView.alpha = 0
} completion: { [weak self] _ in
    self?.dismiss(animated: true)
}

💡 마무리하며

와... 여기까지 읽으셨다면 진짜 대단하세요! 👏 애니메이션은 처음엔 어렵게 느껴지지만, 계속 만들다 보면 점점 재미있어져요. 사용자가 "우와 이거 부드럽다"라고 느끼는 순간이 오면 진짜 뿌듯하거든요 ㅋㅋㅋ

오늘 다룬 내용을 정리해볼게요:

📚 핵심 요약 UIView 애니메이션 - 간단하고 직관적, 대부분의 경우 이걸로 충분해요

Core Animation - 세밀한 제어가 필요할 때, 레이어 속성 애니메이션

UIViewPropertyAnimator - 인터랙티브 애니메이션의 끝판왕

성능 최적화 - GPU 가속, 래스터화, 그림자 경로 설정

실전 패턴 - 카드 스와이프, 모달 전환, 풀 투 리프레시 등

애니메이션은 앱의 품질을 결정하는 중요한 요소예요. 같은 기능이라도 애니메이션 하나 차이로 완전 다른 느낌이 나거든요. 재능넷 같은 플랫폼에서 iOS 개발 재능을 공유하시는 분들도 이런 애니메이션 스킬을 많이 강조하시더라고요.

처음부터 완벽하게 만들려고 하지 마세요. 일단 만들어보고, 테스트해보고, 개선하는 과정을 반복하다 보면 어느새 프로 수준의 애니메이션을 만들 수 있을 거예요! 💪

궁금한 점이나 더 알고 싶은 내용이 있으면 Apple 공식 문서도 참고해보세요. Human Interface Guidelines의 애니메이션 섹션도 완전 유용해요. 그리고 다른 앱들 쓰면서 "이 애니메이션 어떻게 만들었을까?" 생각해보는 것도 좋은 공부 방법이에요!

자, 이제 여러분의 앱에 생명을 불어넣어보세요! 화이팅! 🚀✨

애니메이션 개발 여정 시작 학습 실습 최적화 마스터 기본 개념 API 이해 프로젝트 적용 성능 개선 전문가 지금 바로 시작하세요! 작은 애니메이션 하나가 큰 차이를 만듭니다
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.