🦸 히어로 애니메이션이란?
- 히어로 애니메이션은 뭘까요? 아래와 같이 마치 앞으로 튀어나오듯이 화면간 전환을 연결하는 애니메이션 입니다.
- 기본앱으로는 앱스토어에서 찾아볼수 있겠네요.
앱스토어 히어로 애니메이션 |
|
히어로 애니메이션의 장점
- 모바일 앱 디자인이 발전하면서 앱 간 전환과 내비게이션이 단순한 클릭에서 점점 더 직관적이고 스토리텔링 중심으로 바뀌기 시작했습니다.
- 각각의 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 애니메이션처럼 되게 만들어 보고 싶습니다.
- 읽으시느라 수고 많으셨습니다!! 🙇🏻