본문 바로가기

스위프트

[Swift] UIViewControllerTransitioningDelegate를 사용하여 Transition을 통해 뷰 컨트롤러를 Present/Dismiss 해보자 - 2편

저번 1편에서는 커스텀 트랜지션을 사용하고 싶을 때

필요한 프로토콜들과

프로토콜 내부에서 제공되는 메서드 및 프로퍼티를 확인하였다.

 

이번에는 완성된 동영상을 먼저 올리고,

이와 같은 커스텀 트랜지션을 만드는 데 아주 최소한의 필요했던 내용과 이해를 적어보고자 한다.

 

일단, 완성본은 이러한 형태가 될 것이다.

 

 

누르면 선택한 Item으로부터 뷰 컨트롤러까지 자연스럽게 커지는 형태이고, 드래그로 디스미스하여 원래 자리로 돌아가는 형태이다.

커스텀 트랜지션을 만들기위해서는

UIViewControllerTransitioningDelegate를 구현한 객체가 존재해야한다.

UIViewControllerTransitioningDelegate를 구현하면

각 Present/Dismiss때 사용할 애니메이터 객체를 반환해줘야 한다.

ViewController에서 바로 구현할 수 있지만, 난 어떠한 뷰 컨트롤러에서 사용할 수 있도록

TransitioningDelegate를 구현한 객체를 하나 만들었다.

final class CustomTransition: NSObject {
    private let config: CustomTransitionConfiguration
    private let referenceView: UIView
    private let presentingTransitionAnimator: CustomPresentingTransitionAnimator
    private let dismissalTransitionAnimator: CustomDismissalTransitionAnimator
    private var presentationController: UIPresentationController?
    private var currentTranslationY: CGFloat = 0

    // MARK: - Initializers

    init(
        referenceView: UIView,
        config: CustomTransitionConfiguration = CustomTransitionConfiguration()
    ) {
        self.referenceView = referenceView
        self.config = config

        presentingTransitionAnimator = CustomPresentingTransitionAnimator(
            config: config,
            referenceView: referenceView
        )
        
        dismissalTransitionAnimator = CustomDismissalTransitionAnimator(
            config: config,
            referenceView: referenceView
        )
    }
}

// MARK: - Extension - UIViewControllerTransitioningDelegate

extension CustomTransition: UIViewControllerTransitioningDelegate {
    // 뷰 컨트롤러를 프레젠팅할 때 트랜지션 애니메이터 객체를 요청하는 메서드
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        print("\(#function)")
        return presentingTransitionAnimator
    }

    // 뷰 컨트롤러를 디스미스할 때 프렌지션 애니메이터 객체를 요청하는 메서드
    func animationController(
        forDismissed dismissed: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        print("\(#function)")
        return dismissalTransitionAnimator
    }

    // 뷰 컨트롤러를 프레젠팅할 때 인터렉티브 애니메이터 객체를 요청하는 메서드
    func interactionControllerForPresentation(
        using animator: UIViewControllerAnimatedTransitioning
    ) -> UIViewControllerInteractiveTransitioning? {
        print("\(#function)")
        return nil
    }

    // 뷰 컨트롤러를 디스미스할 때 인터렉티브 애니메이터 객체를 요청하는 메서드
    func interactionControllerForDismissal(
        using animator: UIViewControllerAnimatedTransitioning
    ) -> UIViewControllerInteractiveTransitioning? {
        print("\(#function)")
        return dismissalTransitionAnimator
    }

    // 뷰 컨트롤러를 프레젠팅할 때 뷰 하이러키를 관리하기 위해 사용되는 커스텀 프레젠테이션 컨트롤러를 요청하는 메서드
    func presentationController(
        forPresented presented: UIViewController,
        presenting: UIViewController?,
        source: UIViewController
    ) -> UIPresentationController? {
        print("\(#function)")
        let presentationController = UIPresentationController(
            presentedViewController: presented,
            presenting: source
        )
        let panGestureRecognizer = UIPanGestureRecognizer(
            target: self,
            action: #selector(handlePanGesture(_:))
        )

        presented.view.addGestureRecognizer(panGestureRecognizer)

        self.presentationController = presentationController

        return presentationController
    }

    // MARK: - Private Methods

    @objc
    private func handlePanGesture(_ gestureRecognizer: UIPanGestureRecognizer) {
        guard let presentedVC = presentationController?.presentedViewController else {
            return
        }

        switch gestureRecognizer.state {
        case .changed:
            let translation = gestureRecognizer.translation(in: presentedVC.view)
            currentTranslationY = abs(translation.y)
            presentedVC.view.alpha = config.getAlhpa(by: translation)
            presentedVC.view.transform = CGAffineTransform(translationX: 0, y: translation.y)
            gestureRecognizer.setTranslation(translation, in: presentedVC.view)
        case .ended, .cancelled:
            presentedVC.view.alpha = 1
            if currentTranslationY > config.panGestureDismissThreshold {
                presentedVC.dismiss(animated: true, completion: nil)
            }
            else {
                currentTranslationY = 0

                UIView.animate(withDuration: 0.1) {
                    presentedVC.view.transform = .identity
                }
            }
        default:
            break
        }
    }
}

위 코드에서 최소한의 필요한 내용은

UIViewControllerTransitioningDelegate를 구현한 Extension의 상위 다섯 메서드이다.

각 이름에서 알 수 있듯이

1. Present 때 사용할 UIViewControllerAnimatedTransitioning 반환 요청

2. Present 때 사용할 UIViewControllerInteractiveTransitioning 반환 요청

3. Dismiss 때 사용할 UIViewControllerAnimatedTransitioning 반환 요청

4. Dismiss 때 사용할 UIViewControllerInteractiveTransitioning 반환 요청

5. 이번 Transition때 Presenting/Presented된 뷰 컨트롤러를 관리할 UIPresentationController이다.

 

나는 Present할 때와 Dismiss할 때의 애니메이션 구현을 분리하고 싶어서 별도의 객체로 아래처럼 구현하였다.

 

final class CustomPresentingTransitionAnimator: NSObject {
    private let config: CustomTransitionConfiguration
    private let referenceView: UIView
    private var transitionContext: UIViewControllerContextTransitioning?

    // MARK: - Initializers

    init(config: CustomTransitionConfiguration, referenceView: UIView) {
        self.config = config
        self.referenceView = referenceView
    }
}

// MARK: - Extension - UIViewControllerAnimatedTransitioning

extension CustomPresentingTransitionAnimator: UIViewControllerAnimatedTransitioning {
    // 트랜지션이 일어날 시간을 반환한다. 애니메이션의 총 시간을 생각하면 된다.
    func transitionDuration(
        using transitionContext: UIViewControllerContextTransitioning?
    ) -> TimeInterval {
        print("\(#function)")
        return config.transitionDuration
    }

    // transitionContext의 정보를 통해 필요한 애니메이션을적용시키면 된다.
    func animateTransition(
        using transitionContext: UIViewControllerContextTransitioning
    ) {
        print("\(#function)")
        guard let presentedViewController = transitionContext.viewController(forKey: .to) else {
            return
        }

        self.transitionContext = transitionContext

        let containerView = transitionContext.containerView

        let backgroundView = UIView(frame: containerView.bounds)
        backgroundView.backgroundColor = .white
        backgroundView.alpha = 0
        containerView.addSubview(backgroundView)

        let transitionViewStartFrame = containerView.convert(
            referenceView.bounds,
            from: referenceView
        )
        let transitionView = UIView(frame: transitionViewStartFrame)
        transitionView.backgroundColor = .clear
        containerView.addSubview(transitionView)

        let transitionTargetFrame: CGRect
        var targetColor: UIColor? = .white
        if let singleColorVC = presentedViewController as? SingleColorViewController,
           let singleColorView = singleColorVC.getSingleColorView() {
            transitionTargetFrame = singleColorView.frame
            targetColor = singleColorView.backgroundColor
        }
        else {
            transitionTargetFrame = containerView.bounds
        }

        presentedViewController.view.isHidden = true
        containerView.addSubview(presentedViewController.view)

        UIView.animate(
            withDuration: config.transitionDuration,
            delay: 0,
            usingSpringWithDamping: config.springWithDamping,
            initialSpringVelocity: config.initialSpringVelocity,
            options: [.beginFromCurrentState]
        ) {
            backgroundView.alpha = 1
            transitionView.frame = transitionTargetFrame
            transitionView.backgroundColor = targetColor
        } completion: { _ in
            backgroundView.removeFromSuperview()
            transitionView.removeFromSuperview()
            presentedViewController.view.backgroundColor = .white
            presentedViewController.view.isHidden = false
            transitionContext.completeTransition(transitionContext.transitionWasCancelled == false)
        }
    }

    // 트랜지션 애니메이션의 종료 여부를 알려준다.
    func animationEnded(_ transitionCompleted: Bool) {
        print("\(#function)")
        transitionContext = nil
    }
}
final class CustomDismissalTransitionAnimator: NSObject {
    private let config: CustomTransitionConfiguration
    private let referenceView: UIView
    private var transitionContext: UIViewControllerContextTransitioning?

    // MARK: - Initializers

    init(config: CustomTransitionConfiguration, referenceView: UIView) {
        self.config = config
        self.referenceView = referenceView
    }
}

// MARK: - Extension - UIViewControllerAnimatedTransitioning

extension CustomDismissalTransitionAnimator: UIViewControllerAnimatedTransitioning {
    // 트랜지션이 일어날 시간을 반환한다. 애니메이션의 총 시간을 생각하면 된다.
    func transitionDuration(
        using transitionContext: UIViewControllerContextTransitioning?
    ) -> TimeInterval {
        print("\(#function)")
        return config.transitionDuration
    }

    // transitionContext의 정보를 통해 필요한 애니메이션을적용시키면 된다.
    func animateTransition(
        using transitionContext: UIViewControllerContextTransitioning
    ) {
        print("\(#function)")
        // InteractiveTransitioning을 사용하므로, 아무동작도 하지 않는다.
    }

    // 트랜지션 애니메이션의 종료 여부를 알려준다.
    func animationEnded(_ transitionCompleted: Bool) {
        print("\(#function)")
        transitionContext = nil
    }
}

// MARK: - Extension - UIViewControllerInteractiveTransitioning

extension CustomDismissalTransitionAnimator: UIViewControllerInteractiveTransitioning {
    func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
        print("\(#function)")
        guard let fromViewController = transitionContext.viewController(forKey: .from),
              let singleColorVC = fromViewController as? SingleColorViewController,
              let colorView = singleColorVC.getSingleColorView() else {
            return
        }

        self.transitionContext = transitionContext

        singleColorVC.view.isHidden = true
        
        let containerView = transitionContext.containerView
        let startPoint = CGPoint(x: colorView.frame.minX,
                                 y: colorView.frame.minY + singleColorVC.view.frame.minY)
        let startSize = colorView.frame.size

        let transitionView = UIView(frame: CGRect(origin: startPoint, size: startSize))
        transitionView.backgroundColor = colorView.backgroundColor
        containerView.addSubview(transitionView)

        let finalFrame = containerView.convert(referenceView.bounds, from: referenceView)

        UIView.animate(withDuration: config.transitionDuration,
                       delay: 0,
                       usingSpringWithDamping: config.springWithDamping,
                       initialSpringVelocity: config.initialSpringVelocity,
                       options: [.beginFromCurrentState]) {
            transitionView.frame = finalFrame
        } completion: { wasCancelled in
            transitionContext.completeTransition(true)
        }
    }
}

작업을 하면서 가장 막혔던 부분은

UIViewControllerContextTransitioning의 containerView 였다.

난 containerView위에 add된 뷰들은 애니메이션이 종료됨에 따라 UIKit에서 자연스럽게 제거하고

present하려 했던 뷰 컨트롤러가 보이는 형태인 줄 알았으나,

사실 모든건 UItransitionView를 기준으로 올라가는 것 뿐이었고

중간에 에니메이팅을 하기 위한 뷰 로직 작업도,

완료된 이후에 뷰 컨트롤러를 보일 작업도 모두다 animate시점에 이뤄져야하는 것이었다.

 

정리하면

containerView위에서 트랜지션을 통해 시작부터 뷰 컨트롤러가 Presented되어 사용자에게 나와서

인터렉션이 가능한 시점까지 모두 컨트롤해줘야 하는 것 이었다.

 

그래서 코드에 animateTransition이나 startInteractiveTransition 메서드 구현부를 보면

containerView를 기준으로 트랜지션동안 보여질 뷰들을 구현한 것 부터해서

애니메이션 완료후, 임시로 올려진 뷰들제거 및 최종 뷰 컨트롤러 설정까지 로직에 들어갔다.

 

이렇게 진행하면 라이프싸이클은 아래와 같다.

 

// present 시작
presentationController(forPresented:presenting:source:)
SingleColorViewController viewDidLoad()
animationController(forPresented:presenting:source:)
interactionControllerForPresentation(using:)
transitionDuration(using:)
SingleColorViewController viewWillAppear(_:)
transitionDuration(using:)
animateTransition(using:)
SingleColorViewController viewDidAppear(_:)
animationEnded(_:)
// present 종료

// dismiss 시작
animationController(forDismissed:)
interactionControllerForDismissal(using:)
transitionDuration(using:)
SingleColorViewController viewWillDisappear(_:)
transitionDuration(using:)
startInteractiveTransition(_:)
SingleColorViewController viewDidDisappear(_:)
animationEnded(_:)
// dismiss 종료