[Project-Shook] Hero Animation 도입기

2024. 12. 4. 15:00· → Shook
목차
  1. 🦸 히어로 애니메이션이란?
  2. 히어로 애니메이션의 장점
  3. 🧐 코드 살펴보기
  4. 우리의 목표
  5. 화면 전환 위임받기
  6. transitioningDelegate??
  7. func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) 살펴보기
  8. 코드로 봐볼까요?
  9. 헉헉 이제 애니메이션 부분을 살펴볼까요?
  10. 마치며

🦸 히어로 애니메이션이란?

  • 히어로 애니메이션은 뭘까요? 아래와 같이 마치 앞으로 튀어나오듯이 화면간 전환을 연결하는 애니메이션 입니다.
  • 기본앱으로는 앱스토어에서 찾아볼수 있겠네요.
앱스토어 히어로 애니메이션

히어로 애니메이션의 장점

  • 모바일 앱 디자인이 발전하면서 앱 간 전환과 내비게이션이 단순한 클릭에서 점점 더 직관적이고 스토리텔링 중심으로 바뀌기 시작했습니다.
  • 각각의 UI 요소가 상태 간 전환할 때 컨텍스트를 잃지 않게 하는 부드러운 연결성을 가지고 있는 것이 특징입니다.
  • 맥락을 유지하고 사용자의 주의를 끌면서도 과도하지 않은 움직임으로 직관성을 높이는 효과가 있다고 합니다.

🧐 코드 살펴보기

  • 복잡해보이지만 비교적 간단하게도...(?) 180줄 정도면 구현이 가능하다는 사실! 한번 살펴볼까요?

우리의 목표

  • 완벽하게 부드럽진 않지만 썸네일을 클릭 시 일어나는 상황입니다.
  • Present: 썸네일이 약간 줄어듬 → 썸네일이 확장되면서 상단으로 움직임 → 동영상 재생뷰 표시
  • Dismiss: 화면이 전체적으로 줄어듬 → 썸네일의 위치로 돌아가며 기존 리스트 표시
목표

화면 전환 위임받기

  • 제가 구현한 히어로 애니메이션의 핵심은 아래와 같습니다.
  • 보통 화면전환에 present를 많이 사용하는데요 화면 전환을 제가 만든 커스텀 Transitioning이 위임 받습니다.
public class BroadcastCollectionViewController {
    private let transitioning = CollectionViewCellTransitioning() // 커스텀 Transitioning 클래스
}

extension BroadcastCollectionViewController: UICollectionViewDelegate {
    public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        /// ... viewController = 다음에 띄울 ViewController

        viewController.modalPresentationStyle = .overFullScreen
        viewController.transitioningDelegate = transitioning // 위임 받는 부분
        present(viewController, animated: true)
    }
}

transitioningDelegate??

  • 공식문서에서는 아래와 같이 나와있습니다.

  • 실제 클래스는 어떻게 구현되어 있을까요?
  • CollectionViewCellTransitioning은 UIViewControllerTransitioningDelegate, UIViewControllerAnimatedTransitioning 두가지 프로토콜을 채택했습니다. 그리고 transition: Transition = .present 혹은 .dismiss의 속성을 가지고 있습니다. 이 속성을 present, dismiss 하는 시점에 transition 속성을 변경해줍니다.
extension CollectionViewCellTransitioning: UIViewControllerTransitioningDelegate {
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? {
        transition = .present
        return self // UIViewControllerAnimatedTransitioning을 채택한 자기 자신을 반환
    }

    func animationController(forDismissed dismissed: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? {
        transition = .dismiss
        return self // UIViewControllerAnimatedTransitioning을 채택한 자기 자신을 반환
    }
}
  • 그렇다면 이제 UIViewControllerAnimatedTransitioning프로토콜 부분을 봐야겠죠? (이 친구가 애니메이션의 핵심입니다)
class Transitioning: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval {
        // 애니메이션 Duration 설정
    }

    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        // 실제로 애니메이션을 구현하는 부분
    }
}

func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) 살펴보기

  • 이제 준비는 다 마쳤고 진짜 시작입니다!!! 😈
  • 우선 어떠한 로직으로 돌아가는지 간단하게 그림으로 살펴보겠습니다.
  • 썸네일을 복사하는 트릭을 활용했습니다.
  • 기존 썸네일 복사 → 기존 썸네일 숨김 및 복사된 썸네일 표시 → 복사된 썸네일 애니메이션 → 복사된 썸네일 삭제 → 다음 뷰 표시

코드로 봐볼까요?

1. 깔끔한 전환을 위해 전환에 사용되는 뷰를 가져온뒤 모든 요소를 지워줍니다.

    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        /// 전환에 관련된 뷰를 사용
        let containerView = transitionContext.containerView
        /// 1. 깔끔한 애니메이션을 위해 containerView의 모든 요소 제거
        containerView.subviews.forEach { $0.removeFromSuperview() }

2. 전환에 사용되는 뷰에 blurEffectView를 추가합니다. 그래야 뷰가 확대될때 기존 뷰가 보이지 않습니다.

  • 아래와 같이 containerView가 추가되고 그 containerView 안에서 애니메이션이 일어나는 구조라는 것을 알 수 있습니다! 아래를 보시면 containerView가 최종적으로 애니메이션이 끝난 후 나타나는 뷰가 됩니다.

    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        /// 1. 깔끔한 애니메이션을 위해 containerView의 모든 요소 제거

        /// 2. 전환에 사용되는 뷰에 blurEffectView를 추가
        blurEffectView.frame = containerView.frame
        blurEffectView.alpha = transition.next.blurAlpha
        containerView.addSubview(blurEffectView)

3. 선택한 셀의 썸네일을 복사하고 위치값을 추출하기 위해 가지고 와야합니다.

  • 아래와 같이 from, to 키를 활용하여 애니메이션을 시작하는 뷰 혹은 나타나야할 뷰 컨트롤러를 가져올 수 있습니다.
        let fromView = transitionContext.viewController(forKey: .from)
        let toView = transitionContext.viewController(forKey: .to)
  • fromView, toView를 활용하여 thumbnailView를 가져옵시다.
    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        /// 1. 깔끔한 애니메이션을 위해 containerView의 모든 요소 제거

        /// 2. 전환에 사용되는 뷰에 blurEffectView를 추가

        var thumbnailView: ThumbnailView?
        /// 3. Present: 시작하는 뷰의 썸네일을 가져옵니다. (시작 좌표 및 복사를 위해)
        if let navigationController = (fromView as? UINavigationController), let viewController = (navigationController.topViewController as? BroadcastCollectionViewController) {
            thumbnailView = viewController.selectedThumbnailView
        }

        /// 3. Dismiss: 목적지 뷰의 썸네일을 가져옵니다. (도착 좌표 및 복사를 위해)
        if let navigationController = (toView as? UINavigationController), let viewController = (navigationController.topViewController as? BroadcastCollectionViewController) {
            thumbnailView = viewController.selectedThumbnailView
        }

4. 썸네일 뷰를 복사하고 절대 프레임을 가져옵니다.

  • 여기서 주의할 점은 깊은 복사를 해줘야 한다는 점입니다. 아예 새로운 뷰를 만들어서 얹어야 합니다. 기존의 썸네일 뷰의 속성들을 가지고 충분히 새로 만들 수 있겠죠?
    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        /// 1. 깔끔한 애니메이션을 위해 transition되는 containerView의 모든 요소를 제거합니다.
        /// 2. 전환에 사용되는 뷰에 blurEffectView 추가합니다.
        /// 3. Present: 시작하는 뷰의 썸네일을 가져옵니다. (시작 좌표 및 복사를 위해)
        /// 3. Dismiss: 목적지 뷰의 썸네일을 가져옵니다. (도착 좌표 및 복사를 위해)

        /// 4. 썸네일 뷰를 복사하고 절대 프레임을 가져옵니다.
        guard let thumbnailView else { return }
        let thumbnailViewCopy = copy(of: thumbnailView) // 깊은 복사
        let absoluteFrame = thumbnailView.convert(thumbnailView.frame, to: nil)

5. 기존 썸네일을 숨기고, 복사한 썸네일을 추가합니다.

    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        /// 1. 깔끔한 애니메이션을 위해 transition되는 containerView의 모든 요소를 제거합니다.
        /// 2. 전환에 사용되는 뷰에 blurEffectView 추가합니다.
        /// 3. Present: 시작하는 뷰의 썸네일을 가져옵니다. (시작 좌표 및 복사를 위해)
        /// 3. Dismiss: 목적지 뷰의 썸네일을 가져옵니다. (도착 좌표 및 복사를 위해)
        /// 4. 썸네일 뷰를 복사하고 절대 프레임을 가져옵니다.        

        /// 5. 기존 썸네일을 숨기고 복사한 썸네일을 containerView에 추가합니다.
        containerView.addSubview(thumbnailViewCopy)
        thumbnailView.isHidden = true

6. 애니메이션을 해줍니다.

  • 진짜진짜진짜 준비가 끝났습니다. 👍 이제 애니메이션 시간입니다.
    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        /// 1. 깔끔한 애니메이션을 위해 transition되는 containerView의 모든 요소를 제거합니다.
        /// 2. 전환에 사용되는 뷰에 blurEffectView 추가합니다.
        /// 3. Present: 시작하는 뷰의 썸네일을 가져옵니다. (시작 좌표 및 복사를 위해)
        /// 3. Dismiss: 목적지 뷰의 썸네일을 가져옵니다. (도착 좌표 및 복사를 위해)
        /// 4. 썸네일 뷰를 복사하고 절대 프레임을 가져옵니다.
        /// 5. 기존 썸네일을 숨기고 복사한 썸네일을 containerView에 추가합니다.

        /// 6. Present: 애니메이션, 작았다 커지면서 위로 이동합니다.
        if transition == .present, let toView {
            thumbnailViewCopy.frame = absoluteFrame
            containerView.addSubview(toView.view)
            toView.view.isHidden = true
            moveAndConvert(thumbnailView: thumbnailViewCopy, containerView: containerView, to: absoluteFrame) {
                toView.view.isHidden = false
                thumbnailViewCopy.removeFromSuperview()
                thumbnailView.isHidden = false
                transitionContext.completeTransition(true)
            }
        }

        /// 6. Dismiss: 위에서 셀의 위치로 돌아오면서 작아집니다.
        if transition == .dismiss, let fromView {
            fromView.view.isHidden = true

            thumbnailViewCopy.frame = CGRect(x: 0, y: fromView.view.layoutMargins.top, width: containerView.frame.width, height: containerView.frame.width * 0.5625)

            moveAndConvert(thumbnailView: thumbnailViewCopy, containerView: containerView, to: absoluteFrame) {
                thumbnailView.isHidden = false
                transitionContext.completeTransition(true)
            }
        }

6-a. Present 애니메이션 상세

    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        /// 1. 깔끔한 애니메이션을 위해 transition되는 containerView의 모든 요소를 제거합니다.
        /// 2. 전환에 사용되는 뷰에 blurEffectView 추가합니다.
        /// 3. Present: 시작하는 뷰의 썸네일을 가져옵니다. (시작 좌표 및 복사를 위해)
        /// 3. Dismiss: 목적지 뷰의 썸네일을 가져옵니다. (도착 좌표 및 복사를 위해)
        /// 4. 썸네일 뷰를 복사하고 절대 프레임을 가져옵니다.
        /// 5. 기존 썸네일을 숨기고 복사한 썸네일을 containerView에 추가합니다.

        /// 6. Present: 애니메이션, 작았다 커지면서 위로 이동합니다.
        if transition == .present, let toView {
            /// 6-1. 썸네일은 원래 위치에서 시작
            thumbnailViewCopy.frame = absoluteFrame
            /// 6-2. containerView에 띄워줄 뷰 추가 후 숨김
            containerView.addSubview(toView.view)
            toView.view.isHidden = true
            /// 6-3. 애니메이션
            moveAndConvert(thumbnailView: thumbnailViewCopy, containerView: containerView, to: absoluteFrame) {
                /// 6-4. 띄워줄 뷰 표시
                toView.view.isHidden = false
                /// 6-5. 썸네일 복사 뷰 제거, 썸네일 뷰 원상복구 (표시)
                thumbnailViewCopy.removeFromSuperview()
                thumbnailView.isHidden = false
                /// 6-6. 애니메이션 종료 알림
                transitionContext.completeTransition(true)
            }
        }

6-b. Dismiss 애니메이션 상세

    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
        /// 1. 깔끔한 애니메이션을 위해 transition되는 containerView의 모든 요소를 제거합니다.
        /// 2. 전환에 사용되는 뷰에 blurEffectView 추가합니다.
        /// 3. Present: 시작하는 뷰의 썸네일을 가져옵니다. (시작 좌표 및 복사를 위해)
        /// 3. Dismiss: 목적지 뷰의 썸네일을 가져옵니다. (도착 좌표 및 복사를 위해)
        /// 4. 썸네일 뷰를 복사하고 절대 프레임을 가져옵니다.
        /// 5. 기존 썸네일을 숨기고 복사한 썸네일을 containerView에 추가합니다.

        /// 6. Dismiss: 위에서 셀의 위치로 돌아오면서 작아집니다.
        if transition == .dismiss, let fromView {
            /// 6-1. 시작 뷰 숨김
            fromView.view.isHidden = true
            /// 6-2. 썸네일 복사 뷰 위치를 시작하는 플레이어 위치부터 시작
            thumbnailViewCopy.frame = CGRect(x: 0, y: fromView.view.layoutMargins.top, width: containerView.frame.width, height: containerView.frame.width * 0.5625)
            /// 6-3. 애니메이션
            moveAndConvert(thumbnailView: thumbnailViewCopy, containerView: containerView, to: absoluteFrame) {
                /// 6-4. 썸네일 복사 뷰 제거, 썸네일 뷰 원상복구 (표시)
                thumbnailViewCopy.removeFromSuperview()
                thumbnailView.isHidden = false
                /// 6-6. 애니메이션 종료 알림
                transitionContext.completeTransition(true)
            }
        }

헉헉 이제 애니메이션 부분을 살펴볼까요?

makeShrinkAnimator

  • 너무 쉬워요 그냥 크기를 잠깐 줄여줍니다.
    private func makeShrinkAnimator(of thumbnailView: ThumbnailView) -> UIViewPropertyAnimator {
        UIViewPropertyAnimator(duration: shrinkDuration, curve: .linear) {
            thumbnailView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
        }
    }

makeScaleAndPositionAnimator

  • 확대시키면서 썸네일 뷰의 cornerRadius와 layout 등을 설정해줍니다.
        animator.addAnimations {
            switch self.transition {
            case .present:
                /// 작아졌던 썸네일 뷰 원상태로
                thumbnailView.transform = .identity
                /// 썸네일 뷰 Style, Layout 업데이트
                thumbnailView.updateStyles(for: .present)
                thumbnailView.updateLayouts(for: .present)
                /// 썸네일 뷰 플레이어뷰 위치로 이동 및 확대
                thumbnailView.frame = CGRect(x: 0, y: containerView.layoutMargins.top, width: containerView.frame.width, height: containerView.frame.width * 0.5625)

            case .dismiss:
                /// 썸네일 뷰 Style, Layout 업데이트
                thumbnailView.updateStyles(for: .dismiss)
                thumbnailView.updateLayouts(for: .dismiss)
                /// 인자로 받은 frame위치로 이동 (기존 썸네일 뷰의 절대 프레임)
                thumbnailView.frame = frame
            }
  • 이 두친구를 조합하여 애니메이션을 해줍니다.
    private func moveAndConvert(thumbnailView: ThumbnailView, containerView: UIView, to frame: CGRect, completion: @escaping () -> Void) {
        let shrinkAnimator = makeShrinkAnimator(of: thumbnailView)
        let scaleAndPositionAnimator = makeScaleAndPostionAnimator(of: thumbnailView, in: containerView, to: frame)

        switch transition {
        case .present:
            shrinkAnimator.startAnimation()

            /// 축소 애니메이션 종료 후 확대 애니메이션
            shrinkAnimator.addCompletion { _ in
                scaleAndPositionAnimator.startAnimation()
            }

        case .dismiss:
            /// 썸네일 뷰의 위치를 먼저 잡고 축소 애니메이션
            thumbnailView.layoutIfNeeded()
            scaleAndPositionAnimator.startAnimation()
        }

        /// 크기, 위치 애니메이션 종료 후
        scaleAndPositionAnimator.addCompletion { _ in
            completion()
        }
    }
  • 이제 진짜 히어로 애니메이션 설명은 끝났습니다!!!

마치며

  • 히어로 애니메이션을 하면서 생각보다 View의 Transition이 엄청난게 아니구나!! 라는 생각을 갖게 되었던 것 같습니다. 앞으로 어느 뷰든 presentHero 하게되면 Hero 애니메이션처럼 되게 만들어 보고 싶습니다.
  • 읽으시느라 수고 많으셨습니다!! 🙇🏻

'→ Shook' 카테고리의 다른 글

[Project-Shook] DiffableDataSource, CollectionView  (0) 2024.12.04
  1. 🦸 히어로 애니메이션이란?
  2. 히어로 애니메이션의 장점
  3. 🧐 코드 살펴보기
  4. 우리의 목표
  5. 화면 전환 위임받기
  6. transitioningDelegate??
  7. func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) 살펴보기
  8. 코드로 봐볼까요?
  9. 헉헉 이제 애니메이션 부분을 살펴볼까요?
  10. 마치며
'→ Shook' 카테고리의 다른 글
  • [Project-Shook] DiffableDataSource, CollectionView
Swift librarian
Swift librarian
Swift librarian
Swift Library
Swift librarian
전체
오늘
어제
  • 분류 전체보기 (231)
    • 📺 Programming (5)
    • → Architecture (2)
    • → Design Pattern (0)
    • → Computer Science (15)
    • ⚙️ Algorithm (0)
    • → 알고리즘 관련 (22)
    • → Problems (104)
    • 🚀 Project (0)
    • → 알쏭달쏭 (0)
    • → Shook (2)
    • → Solver (8)
    • → Taster (7)
    • → Outline (4)
    • → Pointer (2)
    • → Guesser (3)
    • 🦜 Swift (2)
    • → Swift Archive (12)
    • → Swift Study (12)
    • → Xcode (6)
    • 🧰 Framework (0)
    • → Foundation (1)
    • → UIKit (2)
    • → SwiftUI (3)
    • → CoreData (2)
    • → MapKit (1)
    • → CoreHaptic (1)
    • → User Notification (1)
    • → StoreKit (2)
    • 🏛️ Library (0)
    • → TCA (0)
    • 🐈‍⬛ Git (8)
    • → Git의 원리 (2)
    • → Git 심화 (1)
    • 📦 Other (1)
    • 👦🏻 Log (0)

최근 글

hELLO · Designed By 정상우.v4.2.2
Swift librarian
[Project-Shook] Hero Animation 도입기
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.