[UIKit] AppStore Hero Animation

Junyoung Park·2022년 12월 2일
0

UIKit

목록 보기
106/142
post-thumbnail
post-custom-banner

App Store Card Transition with Andrei Blaj, Senior iOS Developer

AppStore Hero Animation

구현 목표

  • 앱 스토어 모달 이동 전환 애니메이션을 UIKit을 통해 구현

구현 태스크

핵심 코드

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return transitionDuration
    }
  • UIViewControllerAnimaedTransitioning 프로토콜을 따르는 함수에서 미리 매니저 클래스 내에서 선언해 둔 TimeInterval을 리턴
private func makeShrinkAnimator(for cardView: CardView) -> UIViewPropertyAnimator {
        return UIViewPropertyAnimator(duration: shrinkDuration, curve: .easeOut) {
            cardView.transform = CGAffineTransform(scaleX: 0.95, y: 0.5)
            self.dimmingView.alpha = 0.05
        }
    }
  • 카드 뷰를 줄이는 애니메이션
    private func makeExpandContractAnimator(for cardView: CardView, in containerView: UIView, yOrigin: CGFloat) -> UIViewPropertyAnimator {
        let springTiming = UISpringTimingParameters(dampingRatio: 0.75, initialVelocity: .init(dx: 0, dy: 4))
        let animator = UIViewPropertyAnimator(duration: transitionDuration - shrinkDuration, timingParameters: springTiming)
        animator.addAnimations {
            cardView.transform = .identity
            cardView.containerView.layer.cornerRadius = self.transition.next.cornerRadius
            cardView.frame.origin.y = yOrigin
            self.blurEffectView.alpha = self.transition.blurAlpha
            self.dimmingView.alpha = self.transition.dimAlpha
            self.closeButton.alpha = self.transition.closeAlpha
            self.whiteView.layer.cornerRadius = self.transition.next.cornerRadius
            containerView.layoutIfNeeded()
            self.whiteView.frame = self.transition == .presentation ? containerView.frame : cardView.containerView.frame
        }
        return animator
    }
  • 카드 뷰를 확대하는 애니메이션
  • 홈 뷰의 특정 카드 뷰가 클릭되었을 때 해당 카드 뷰를 확대하는 동시에 블러링, 백그라운드 이펙트 등을 추가
private func moveAndConvertToCardView(cardView: CardView, containerView: UIView, yOriginToMoveTo: CGFloat, completion: @escaping () -> ()) {
        let shrinkAnimator = makeShrinkAnimator(for: cardView)
        let expandContractAnimator = makeExpandContractAnimator(for: cardView, in: containerView, yOrigin: yOriginToMoveTo)
        
        expandContractAnimator.addCompletion { _ in
            completion()
        }
        
        if transition == .presentation {
            shrinkAnimator.addCompletion { _ in
                cardView.layoutIfNeeded()
                cardView.updateLayout(for: self.transition.next.cardMode)
                expandContractAnimator.startAnimation()
            }
            
            shrinkAnimator.startAnimation()
        } else {
            cardView.layoutIfNeeded()
            cardView.updateLayout(for: self.transition.next.cardMode)
            expandContractAnimator.startAnimation()
        }
    }
}
  • 현재 상황에 따라 카드 뷰를 확대/축소 애니메이션을 적용하는 함수
  • 해당 함수를 적용할 때 컴플리션 함수로 넣어준 부분을 expandContractAnimator를 적용할 때 함께 적용
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let containerView = transitionContext.containerView
        containerView.subviews.forEach({ $0.removeFromSuperview() })
        addBackgroundViews(to: containerView)
        let fromView = transitionContext.viewController(forKey: .from)
        let toView = transitionContext.viewController(forKey: .to)
        
        var cardView: CardView?
        if transition == .presentation {
            guard
                let fromView = fromView as? TodayView,
                let selectedCardView = fromView.selectedCellCardView() else { return }
            cardView = selectedCardView
        } else {
            guard
                let toView = toView as? TodayView,
                let selectedCardView = toView.selectedCellCardView() else { return }
            cardView = selectedCardView
        }
        guard let cardView = cardView else { return }
        let cardViewCopy = createCardViewCopy(cardView: cardView)
        containerView.addSubview(cardViewCopy)
        cardView.isHidden = true
        
        let absoluteCardViewFrame = cardView.convert(cardView.frame, to: nil)
        cardViewCopy.frame = absoluteCardViewFrame
        cardViewCopy.layoutIfNeeded()
        
        whiteView.frame = transition == .presentation ? cardViewCopy.containerView.frame : containerView.frame
        whiteView.layer.cornerRadius = transition.cornerRadius
        cardViewCopy.insertSubview(whiteView, aboveSubview: cardViewCopy.shadowView)
        
        // MARK: Close Button
        if cardView.cardModel.backgroundType == .light {
            closeButton.setImage(UIImage(named: "darkOnLight"), for: .normal)
        } else {
            closeButton.setImage(UIImage(named: "lightOnDark"), for: .normal)
        }
        cardViewCopy.containerView.addSubview(closeButton)
        NSLayoutConstraint.activate([
            closeButton.topAnchor.constraint(equalTo: cardViewCopy.shadowView.topAnchor, constant: 20.0),
            closeButton.widthAnchor.constraint(equalToConstant: 30.0),
            closeButton.heightAnchor.constraint(equalTo: closeButton.widthAnchor, multiplier: 1.0),
            closeButton.trailingAnchor.constraint(equalTo: cardViewCopy.shadowView.trailingAnchor, constant: -20.0)
        ])
        
        if transition == .presentation {
            guard let detailView = toView as? DetailView else { return }
            containerView.addSubview(detailView.view)
            detailView.viewsAreHidden = true
            moveAndConvertToCardView(cardView: cardViewCopy, containerView: containerView, yOriginToMoveTo: 0) {
                detailView.viewsAreHidden = false
                cardViewCopy.removeFromSuperview()
                cardView.isHidden = false
                detailView.createSnapshotOfView()
                transitionContext.completeTransition(true)
            }
            closeButton.alpha = transition.next.closeAlpha
        } else {
            // Dismissal
            guard
                let detailView = fromView as? DetailView,
                let size = detailView.cardView?.frame.size else { return }
            detailView.viewsAreHidden = true
            closeButton.alpha = transition.next.closeAlpha
            cardViewCopy.frame = CGRect(origin: .zero, size: size)
            moveAndConvertToCardView(cardView: cardViewCopy, containerView: containerView, yOriginToMoveTo: absoluteCardViewFrame.origin.y) {
                cardView.isHidden = false
                transitionContext.completeTransition(true)
            }
        }
    }
extension CardTransitionManager: UIViewControllerTransitioningDelegate {
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        transition = .presentation
        return self
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        transition = .dismissal
        return self
    }
}
  • 뷰 전환 애니메이션의 핵심 코드
  • fromViewtoView를 커스텀, 모달 전환 상황에 따라서 서로 다른 뷰가 적용
  • 전환 상황이 presentation일 때에는 전환이 TodayView에서 DetailView: 현재 컨테이너 뷰에 해당 디테일 뷰를 추가한 뒤 디테일 뷰를 볼 수 있도록 설정. 카드 뷰 복사본은 지우고 디테일 뷰의 스냅샷을 생성
  • 전환 상황이 dismissal일 때에는 전환이 DetailView에서 TodayView: 현재 디테일 뷰, 클로즈 버튼 등을 숨기고 카드 뷰의 복사본을 사용해 디테일 뷰에서 홈 뷰로 넘어가는 듯한 애니메이션 적용
func createSnapshot() -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(frame.size, false, .zero)
        drawHierarchy(in: frame, afterScreenUpdates: true)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        
        return image
    }
  • UIView의 익스텐션으로 추가된 함수
  • 현재 뷰를 스크린샷으로 찍은 뒤 해당 이미지를 리턴
private func addBackgroundViews(to containterView: UIView) {
        blurEffectView.frame = containterView.frame
        blurEffectView.alpha = transition.next.blurAlpha
        containterView.addSubview(blurEffectView)
        
        dimmingView.frame = containterView.frame
        dimmingView.alpha = transition.next.dimAlpha
        containterView.addSubview(dimmingView)
    }
  • 현재 뷰에 블러링, 흐린 백그라운드 뷰를 추가하는 함수
private func createCardViewCopy(cardView: CardView) -> CardView {
        let cardModel = cardView.cardModel
        cardModel.viewMode = transition.cardMode
        let appView = AppView(cardView.appView?.viewModel)
        let cardViewCopy = CardView(cardModel: cardModel, appView: appView)
        return cardViewCopy
    }
  • 주어진 카드 뷰의 카피 뷰를 새롭게 생성한 뒤 리턴
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        
        let cardViewModel = cardsViewData[indexPath.row]
        let detailView = DetailView(cardViewModel: cardViewModel)
        detailView.modalPresentationStyle = .overCurrentContext
        detailView.transitioningDelegate = transitionManager
        present(detailView, animated: true, completion: nil)
        
        // To wake up the UI, Apple issue with cells with selectionStyle = .none
        CFRunLoopWakeUp(CFRunLoopGetCurrent())
    }
  • 홈 뷰의 카드 뷰가 들어 있는 테이블 뷰의 델리게이트 함수
  • 네비게이션 푸시가 발생하는 곳에서 해당 transitionDelegate를 위에서 구현한 매니저 클래스로 주기
lazy var snapshotView: UIImageView = {
        let imageView = UIImageView()
        imageView.clipsToBounds = true
        imageView.backgroundColor = .white
        imageView.layer.shadowColor = UIColor.black.cgColor
        imageView.layer.shadowOpacity = 0.2
        imageView.layer.shadowRadius = 10.0
        imageView.layer.shadowOffset = CGSize(width: -1, height: 2)
        imageView.isHidden = true
        return imageView
    }()
  • 디테일 뷰가 띄워질 때 디스미스가 발생할 때 현 상태 그대로 축소된 뒤 홈 뷰로 합쳐지는 이벤트로 이어지기 위한 스냅샷 이미지 뷰
  • 기본적으로는 보이지 않아도 되므로 isHidden을 참으로 설정
func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let yPositionForDismissal: CGFloat = 20.0
        var yContentOffset = scrollView.contentOffset.y
        let topPadding = UIWindow.topPadding
        
        yContentOffset += topPadding
        updateCloseButton(yContentOffset: yContentOffset)
        
        if scrollView.isTracking {
            scrollView.bounces = true
        } else {
            scrollView.bounces = yContentOffset > 0
        }
        
        if yContentOffset < 0 && scrollView.isTracking {
            viewsAreHidden = true
            snapshotView.isHidden = false
            let scale = (100 + yContentOffset) / 100
            snapshotView.transform = CGAffineTransform(scaleX: scale, y: scale)
            snapshotView.layer.cornerRadius = -yContentOffset > yPositionForDismissal ? yPositionForDismissal : -yContentOffset
            
            if yPositionForDismissal + yContentOffset <= 0 {
                close()
            }
            
        } else {
            viewsAreHidden = false
            snapshotView.isHidden = true
        }
    }
  • 디테일 뷰를 가득 채우고 있는 스크롤 뷰의 델리게이트 함수
  • 스크롤이 될 때 현재 y 값을 계산, 최상단인 상황에서도 계속해서 스크롤을 한다면 자동으로 현재 뷰를 축소시키면서 디스미스시키기 위한 코드
  • snapshot을 생성한 이유는 transform을 적용해서 자동으로 축소되는 듯한 UI를 보여주기 위함
private func updateCloseButton(yContentOffset: CGFloat) {
        let topPadding = UIWindow.topPadding
        if yContentOffset < 450 - topPadding && cardView?.cardModel.backgroundType == .dark {
            closeButton.setImage(UIImage(named: "lightOnDark"), for: .normal)
        } else {
            closeButton.setImage(UIImage(named: "darkOnLight"), for: .normal)
        }
    }
  • 클로즈 버튼 또한 디테일 뷰 내의 현 시점이 카드 뷰인지, 텍스트 뷰인지에 따라 컬러를 변경 가능하도록 오프셋을 통해 체크

구현 화면

UIKit의 세계란...

profile
JUST DO IT
post-custom-banner

0개의 댓글