본문 바로가기
iOS/App Frameworks

UINavigationController push 할 때 이미지 확대/축소 전환효과주기 (+ interactive)

by 탄이. 2020. 1. 26.

masamichiueta/FluidPhoto

Create transition and interaction like iOS Photos app

I. class 구조 만들기

  • class ZoomTransitionController

    • UIViewControllerTransitioningDelegate 또는 UINavigationControllerDelegate를 구현하고 전환을 관리합니다.
  • class ZoomAnimator

    • UIViewControllerAnimatedTransitioning 및 줌 애니메이션 논리를 구현합니다.
  • ZoomAnimator는 ZoomTransitionController의 속성입니다.

  • 두 클래스에서 ZoomAnimatorZoomTransitionController 를 분리 한 이유 는 대화식 전환과 일반 전환을 관리하기 위해서입니다.

  • ZoomAnimator은 대화식 전환이 아닙니다.

II. 확대/축소 애니메이션 구현

1. ZoomAnimator

  • UIImageView를 가져 와서 확대 / 축소하고 전환 원본 프레임에서 전환 대상 프레임으로 애니메이션을 적용합니다.

    // 델리게이트를 사용하여 전환 이미지와 프레임을 가져옵니다.
    // 이 델리게이트는 전환소스/전환대상인 ViewController에 의해 구현됩니다
    protocol ZoomAnimatorDelegate: class {

     

      func transitionWillStartWith(zoomAnimator: ZoomAnimator)
      func transitionDidEndWith(zoomAnimator: ZoomAnimator)
      func referenceImageView(for zoomAnimator: ZoomAnimator) -> UIImageView?
      func referenceImageViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect?

    }

    class ZoomAnimator: NSObject {

      // 출발
      weak var fromDelegate: ZoomAnimatorDelegate?
      // 도착
      weak var toDelegate: ZoomAnimatorDelegate?
    
      // 애니메이션 이미지
      var transitionImageView: UIImageView?
      // 목록에서 확대하여 세부 정보를 표시할지 세부 정보를 축소할지를 결정!
      var isPresenting: Bool = true

    }

  • UIViewControllerAnimatedTransitioning 프로토콜의 두 메서드

    extension ZoomAnimator: UIViewControllerAnimatedTransitioning {

      // 애니메이션의 지속 기간
      func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
          if self.isPresenting {
              return 0.5
          } else {
              return 0.25
          }
      }
    
      // 애니메이션이 실제로 실행되는 곳
      // 이 메서드에서는 isPresenting 속성을 사용하여 확대 또는 축소를 확인합니다.
      func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
          if self.isPresenting {
              animateZoomInTransition(using: transitionContext)
          } else {
              animateZoomOutTransition(using: transitionContext)
          }
      }

    }

  // 애니메이션의 지속 기간
  func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
      if self.isPresenting {
          return 0.5
      } else {
          return 0.25
      }
  }

  // 애니메이션이 실제로 실행되는 곳
  // 이 메서드에서는 isPresenting 속성을 사용하여 확대 또는 축소를 확인합니다.
  func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
      if self.isPresenting {
          animateZoomInTransition(using: transitionContext)
      } else {
          animateZoomOutTransition(using: transitionContext)
      }
  }

2. 애니메이션 로직

  1. 출발 및 도착 이미지 숨기기
  2. 출발 이미지에서 애니메이션을 만들 이미지 만들기
  3. 도착 이미지의 프레임 계산
  4. 출발 프레임에서 도착 프레임으로 이미지 애니메이션

확대

fileprivate func animateZoomInTransition(using transitionContext: UIViewControllerContextTransitioning) {

    let containerView = transitionContext.containerView

    guard let toVC = transitionContext.viewController(forKey: .to),
        let fromVC = transitionContext.viewController(forKey: .from),

        // 출발 imageView 가져오기
        let fromReferenceImageView = self.fromDelegate?.referenceImageView(for: self),

        // 도착 imageView 가져오기
        let toReferenceImageView = self.toDelegate?.referenceImageView(for: self),

        // 출발 imageView frame 가져오기
        let fromReferenceImageViewFrame = self.fromDelegate?.referenceImageViewFrameInTransitioningView(for: self)
        else {
            return
    }

    self.fromDelegate?.transitionWillStartWith(zoomAnimator: self)
    self.toDelegate?.transitionWillStartWith(zoomAnimator: self)

    toVC.view.alpha = 0

    // 도착 imageView 숨기기
    toReferenceImageView.isHidden = true
    containerView.addSubview(toVC.view)

    let referenceImage = fromReferenceImageView.image!

    // 확대/축소 애니메이션에 사용할 imageView 생성
    if self.transitionImageView == nil {
        let transitionImageView = UIImageView(image: referenceImage)
        transitionImageView.contentMode = .scaleAspectFill
        transitionImageView.clipsToBounds = true
        transitionImageView.frame = fromReferenceImageViewFrame
        self.transitionImageView = transitionImageView
        containerView.addSubview(transitionImageView)
    }

    // 출발 imageView 숨기기
    fromReferenceImageView.isHidden = true

    // 애니메이션 이후 도착 imageView frame 계산하기
    let finalTransitionSize = calculateZoomInImageFrame(image: referenceImage, forView: toVC.view)

    UIView.animate(withDuration: transitionDuration(using: transitionContext),
                    delay: 0,
                    usingSpringWithDamping: 0.8,
                    initialSpringVelocity: 0,
                    options: [UIViewAnimationOptions.transitionCrossDissolve],
                    animations: {

                    // imageView의 frame 갱신
                    self.transitionImageView?.frame = finalTransitionSize
                    toVC.view.alpha = 1.0
                    fromVC.tabBarController?.tabBar.alpha = 0
    },
                    completion: { completed in

                    // imageView 삭제
                    self.transitionImageView?.removeFromSuperview()

                    // 출발 imageView 및 도착 imageView 보이기
                    toReferenceImageView.isHidden = false
                    fromReferenceImageView.isHidden = false

                    self.transitionImageView = nil

                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                    self.toDelegate?.transitionDidEndWith(zoomAnimator: self)
                    self.fromDelegate?.transitionDidEndWith(zoomAnimator: self)
    })
}

축소

fileprivate func animateZoomOutTransition(using transitionContext: UIViewControllerContextTransitioning) {

    let containerView = transitionContext.containerView

    guard let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to),
        let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
        let fromReferenceImageView = self.fromDelegate?.referenceImageView(for: self),
        let toReferenceImageView = self.toDelegate?.referenceImageView(for: self),
        let fromReferenceImageViewFrame = self.fromDelegate?.referenceImageViewFrameInTransitioningView(for: self),
        let toReferenceImageViewFrame = self.toDelegate?.referenceImageViewFrameInTransitioningView(for: self)
        else {
            return
    }

    self.fromDelegate?.transitionWillStartWith(zoomAnimator: self)
    self.toDelegate?.transitionWillStartWith(zoomAnimator: self)

    toReferenceImageView.isHidden = true

    let referenceImage = fromReferenceImageView.image!

    if self.transitionImageView == nil {
        let transitionImageView = UIImageView(image: referenceImage)
        transitionImageView.contentMode = .scaleAspectFill
        transitionImageView.clipsToBounds = true
        transitionImageView.frame = fromReferenceImageViewFrame
        self.transitionImageView = transitionImageView
        containerView.addSubview(transitionImageView)
    }

    containerView.insertSubview(toVC.view, belowSubview: fromVC.view)
    fromReferenceImageView.isHidden = true

    let finalTransitionSize = toReferenceImageViewFrame

    UIView.animate(withDuration: transitionDuration(using: transitionContext),
                    delay: 0,
                    options: [],
                    animations: {
                    fromVC.view.alpha = 0
                    self.transitionImageView?.frame = finalTransitionSize
                    toVC.tabBarController?.tabBar.alpha = 1
    }, completion: { completed in

        self.transitionImageView?.removeFromSuperview()
        toReferenceImageView.isHidden = false
        fromReferenceImageView.isHidden = false

        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        self.toDelegate?.transitionDidEndWith(zoomAnimator: self)
        self.fromDelegate?.transitionDidEndWith(zoomAnimator: self)

    })
}

3. ZoomTransitionController

  • ZoomTransitionController는 출발과 도착을 대리자로 참조하는 ZoomAnimator와 동일한 속성을 갖습니다.

    class ZoomTransitionController: NSObject {

      let animator: ZoomAnimator
    
      weak var fromDelegate: ZoomAnimatorDelegate?
      weak var toDelegate: ZoomAnimatorDelegate?
    
      ...
  • ZoomTransitionController는 변환의 시작 부분에 ZoomAnimator를 반환

    extension ZoomTransitionController: UIViewControllerTransitioningDelegate {

      func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
          self.animator.isPresenting = true
          self.animator.fromDelegate = fromDelegate
          self.animator.toDelegate = toDelegate
          return self.animator
      }
    
      func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
          self.animator.isPresenting = false
          let tmp = self.fromDelegate
          self.animator.fromDelegate = self.toDelegate
          self.animator.toDelegate = tmp
          return self.animator
      }

    }

    extension ZoomTransitionController: UINavigationControllerDelegate {

      func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
          if operation == .push {
              self.animator.isPresenting = true
              self.animator.fromDelegate = fromDelegate
              self.animator.toDelegate = toDelegate
          } else {
              self.animator.isPresenting = false
              let tmp = self.fromDelegate
              self.animator.fromDelegate = self.toDelegate
              self.animator.toDelegate = tmp
          }
    
          return self.animator
      }

    }

  • 줌 애니메이션 설정이 완료되었습니다.

  • ViewController에서 ZoomTransitionController를 사용 하여 줌 전환을 만듭니다.

4. HomeContentViewController

  • VRDetailViewControllerZoomTransitionController 를 속성으로가집니다.

    class VRDetailViewController: UIViewController, UIGestureRecognizerDelegate {

    var transitionController = ZoomTransitionController()
    
    ...

    }

  • CollectionView에서 셀을 탭하면 Segue를 실행하고 전환을 시작합니다. prepare(for:sender:) 메서드에서 애니메이션 대리자를 설정합니다.

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

      if segue.identifier == "DetailSegue" {
          let nav = self.navigationController
          let vc = segue.destination as! VRDetailViewController
    
          // ZoomTransitionController에 navigationController 위임을 설정합니다.
          nav?.delegate = vc.transitionController
          vc.transitionController.fromDelegate = self
          vc.transitionController.toDelegate = vc
    
          ...
      }

    }

  • 이제 ZoomTransitionController 에 의한 전환은 UINavigationController 전환이 발생할 때 실행됩니다 .

  • 나머지는 각 ViewController에 ZoomAnimatorDelegate 를 구현하고 ZoomAnimator 에 출발과 도착의 이미지와 프레임을 알려줌으로써 완성 됩니다.

    extension HomeContentViewController: ZoomAnimatorDelegate {

      func transitionWillStartWith(zoomAnimator: ZoomAnimator) {
    
      }
    
      func transitionDidEndWith(zoomAnimator: ZoomAnimator) {
    
          // CollectionView의 셀의 위치를 조정합니다 
          let cell = self.collectionView.cellForItem(at: self.selectedIndexPath) as! PhotoCollectionViewCell
    
          let cellFrame = self.collectionView.convert(cell.frame, to: self.view)
    
          if cellFrame.minY < self.collectionView.contentInset.top {
              self.collectionView.scrollToItem(at: self.selectedIndexPath, at: .top, animated: false)
          } else if cellFrame.maxY > self.view.frame.height - self.collectionView.contentInset.bottom {
              self.collectionView.scrollToItem(at: self.selectedIndexPath, at: .bottom, animated: false)
          }
      }
    
      func referenceImageView(for zoomAnimator: ZoomAnimator) -> UIImageView? {
          // CollectionView의 이미지를 반환합니다.
          let cell = self.collectionView.cellForItem(at: self.selectedIndexPath) as! PhotoCollectionViewCell
          return cell.imageView
      }
    
      func referenceImageViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? {
          // CollectionView의 이미지의 프레임을 반환합니다.
          let cell = self.collectionView.cellForItem(at: self.selectedIndexPath) as! PhotoCollectionViewCell
    
          let cellFrame = self.collectionView.convert(cell.frame, to: self.view)
    
          if cellFrame.minY < self.collectionView.contentInset.top {
              return CGRect(x: cellFrame.minX, y: self.collectionView.contentInset.top, width: cellFrame.width, height: cellFrame.height - (self.collectionView.contentInset.top - cellFrame.minY))
          }
    
          return cellFrame
      }

    }

5. VRDetailViewController

extension VRDetailViewController: ZoomAnimatorDelegate {

    func transitionWillStartWith(zoomAnimator: ZoomAnimator) {
    }

    func transitionDidEndWith(zoomAnimator: ZoomAnimator) {
    }

    func referenceImageView(for zoomAnimator: ZoomAnimator) -> UIImageView? {
        return self.currentViewController.imageView
    }

    func referenceImageViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? {        
        return self.currentViewController.scrollView.convert(self.currentViewController.imageView.frame, to: self.currentViewController.view)
    }
}
  • 확대/축소 애니메이션 완료!

III. 팬 제스처로 대화식(interactive) 전환 구현

1. interactive 전환 정의하기

  • 확대 / 축소하지 않고 사진을 아래로 당기면 사진이 점차 작아집니다. 최소 크기가 존재합니다.
  • 사진을 아래로 당길 때 사진의 손가락 위치가 나타납니다.
  • 사진을 당기는 정도에 따라 배경이 투명 해집니다.
  • 손가락을 위쪽으로 움직이면 사진이 원래 위치로 돌아갑니다.

2. ZoomDismissalInteractionController

  • ZoomDismissalInteractionControllerUIViewControllerInteractiveTransitioning을 구현 하며 대화식 전환 논리를 담당합니다.

  • ZoomDismissalInteractionController 에는 사용자가 팬 제스처를 수행 할 때마다 호출되는 메서드가 있으며 현재 팬 동작의 상태에 따라 사진의 애니메이션을 제어합니다.

    class ZoomDismissalInteractionController: NSObject {

    ...
    
    // 사용자가 팬 동작을 수행 할 때마다 호출됩니다
    func didPanWith(gestureRecognizer: UIPanGestureRecognizer) {
    
      // 원본 이미지의 중심점
      let anchorPoint = CGPoint(x: fromReferenceImageViewFrame.midX, y: fromReferenceImageViewFrame.midY)
    
      // 이미지 변형
      let translatedPoint = gestureRecognizer.translation(in: fromReferenceImageView)
    
      // 이미지의 수직 이동
      let verticalDelta = translatedPoint.y < 0 ? 0 : translatedPoint.y
    
      let backgroundAlpha = backgroundAlphaFor(view: fromVC.view, withPanningVerticalDelta: verticalDelta)
    
      let scale = scaleFor(view: fromVC.view, withPanningVerticalDelta: verticalDelta)
    
      // 팬 제스처의 변형에 따라 이미지 크기 업데이트
      transitionImageView.transform = CGAffineTransform(scaleX: scale, y: scale)
    
      // 애니메이션을 사용할 이미지의 위치 계산
      let newCenter = CGPoint(x: anchorPoint.x + translatedPoint.x, y: anchorPoint.y + translatedPoint.y - transitionImageView.frame.height * (1 - scale) / 2.0)
      transitionImageView.center = newCenter
    
      // 스케일에 따른 대화 형 전환 업데이트
      transitionContext.updateInteractiveTransition(1 - scale)
    if gestureRecognizer.state == .ended {

        let velocity = gestureRecognizer.velocity(in: fromVC.view)

        // If the user take the image upwards, cancel the transition
        if velocity.y < 0 || newCenter.y < anchorPoint.y {

          // Cancel
        }

        // Animate
    }
  }

}

extension ZoomDismissalInteractionController: UIViewControllerInteractiveTransitioning {

    func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
      self.transitionContext = transitionContext

      let containerView = transitionContext.containerView

      guard let animator = self.animator as? ZoomAnimator,
          let fromVC = transitionContext.viewController(forKey: .from),
          let toVC = transitionContext.viewController(forKey: .to),
          let fromReferenceImageViewFrame = animator.fromDelegate?.referenceImageViewFrameInTransitioningView(for: animator),
          let toReferenceImageViewFrame = animator.toDelegate?.referenceImageViewFrameInTransitioningView(for: animator),
          let fromReferenceImageView = animator.fromDelegate?.referenceImageView(for: animator)
          else {
              return
      }

      animator.fromDelegate?.transitionWillStartWith(zoomAnimator: animator)
      animator.toDelegate?.transitionWillStartWith(zoomAnimator: animator)

      self.fromReferenceImageViewFrame = fromReferenceImageViewFrame
      self.toReferenceImageViewFrame = toReferenceImageViewFrame

      let referenceImage = fromReferenceImageView.image!

      containerView.insertSubview(toVC.view, belowSubview: fromVC.view)
      if animator.transitionImageView == nil {
          let transitionImageView = UIImageView(image: referenceImage)
          transitionImageView.contentMode = .scaleAspectFill
          transitionImageView.clipsToBounds = true
          transitionImageView.frame = fromReferenceImageViewFrame
          animator.transitionImageView = transitionImageView
          containerView.addSubview(transitionImageView)
      }
}

댓글