버그 잡이

iOS/swift - Side Menu를 만드는 2가지 방법 본문

IOS

iOS/swift - Side Menu를 만드는 2가지 방법

버그잡이 2022. 12. 28. 22:28

아래와 같은 메뉴를 한번 만들어 보겠습니다.

햄버거 메뉴 또는 사이드 메뉴라고도 하죠.

 

 

0. SideMenu 라이브러리

 

먼저 SideMenu라는 라이브러리가 있다는 것을 공유드리겠습니다.
* SideMenu 깃헙 주소: https://github.com/jonkykong/SideMenu

굳이 직접 만들 필요는 없습니다. 잘 가져다 쓰는게 더 좋을 수도 있죠.

 

하지만 저는 직접 만들어 보고 싶었습니다. 제가 시도한 2가지 방법을 공유드립니다.

정확히는 첫번째 방법에서 더 발전된 방법이 두번째 방법입니다.

물론 첫번째 방법과 두번째 방법은 개념상 이어지는 부분이 있기 때문에 첫번째 방법도 한번 훑어보시고 두번째 방법을 보시는 것을 추천드립니다.

 

 

1. UIViewControllerTransitioningDelegate 를 활용한 방법

 

UIViewControllerTransitioningDelegate 에 대한 애플 공식 문서를 보면

뷰 컨트롤러간의 전환을 관리하기 위한 메서드 집합이라고 합니다.

이 메서드 집합을 잘 활용하면 컴스텀한 전환 효과를 줄 수 있다고 합니다.

 

어떻게 전환 효과를 주는지 알아보죠

 

먼저 메뉴를 띄울 부모 VC에서 UIViewControllerTransitioningDelegate를 채택합니다.

extension MenuPresentingViewController: UIViewControllerTransitioningDelegate {
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        transition.isPresenting = true
        return transition
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        transition.isPresenting = false
        return transition
    }
}

// 위 Delegate를 채택하기 위해서는 띄울 VC(사이드 메뉴)에 transitioningDelegate로 연결해줘야합니다.
let sideMenuVC = SideMenuViewController()
sideMenuVC.transitioningDelegate = self

 

위 두 함수를 통해서 메뉴를 띄울때, 그리고 메뉴를 dismiss 할때의 에니메이션을 지정해줍니다.

함수를 보시면 UIViewControllerAnimatedTransitioning을 리턴하는 것을 볼 수 있죠?

당연히 이 객체도 만들어줘여합니다.

 

아래와 같이 UIViewControllerAnimatedTransitioning를 채택한 객체를 하나 만들어주고 
두 함수를 구현해서 커스텀한 애니메이션을 만들어줄겁니다.

 

class SlideInTransition: NSObject, UIViewControllerAnimatedTransitioning {

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {

    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

    }
}

 

 

커스텀 에니메이션을 구현한 코드입니다.

코드는 좀 복잡한데 핵심은 두가지입니다.

1. dimmingView 추가

2. toViewController원하는 크기로 만들어 두고(여기서는 화면의 2/3) 왼쪽에 숨겨뒀다가 오른쪽으로 꺼내면서 애니메이션 효과 주기

 

애니메이션 효과를 위해서 CGAffineTransform를 사용합니다. 

transform과 identity가 있는데, show와 dismiss 애니메이션을 정의해둔 것입니다.

 

class SlideInTransition: NSObject, UIViewControllerAnimatedTransitioning {

    var isPresenting = false
    let dimmingView = UIView()

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.3
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

        guard let toViewController = transitionContext.viewController(forKey: .to),
            let fromViewController = transitionContext.viewController(forKey: .from) else { return }

        let containerView = transitionContext.containerView

        let finalWidth = toViewController.view.bounds.width * 0.75
        let finalHeight = toViewController.view.bounds.height

        if isPresenting {
            dimmingView.backgroundColor = .black
            dimmingView.alpha = 0.0
            containerView.addSubview(dimmingView)
            dimmingView.frame = containerView.bounds
            containerView.addSubview(toViewController.view)

            toViewController.view.frame = CGRect(x: -finalWidth, y: 0, width: finalWidth, height: finalHeight)
        }

        let transform = {
            self.dimmingView.alpha = 0.7
            toViewController.view.transform = CGAffineTransform(translationX: finalWidth, y: 0)
        }

        let identity = {
            self.dimmingView.alpha = 0.0
            fromViewController.view.transform = .identity
        }

        let duration = transitionDuration(using: transitionContext)
        let isCancelled = transitionContext.transitionWasCancelled
        UIView.animate(withDuration: duration, animations: {
            self.isPresenting ? transform() : identity()
        }) { (_) in
            transitionContext.completeTransition(!isCancelled)
        }
    }
}

 

그런데...  Side-Effect가 있다...

그런데 위 방식은 문제가 있었습니다. SideMenuVC가 띄워진 상태에서 그 위에 .fullScreen으로 VC를 하나 더 띄우고 dismissAll 하면 SideMenu에 있던 dimView는 사라지지 않고 남는 사이드 이펙트가 있었습니다. dimView로 덮고 있기 때문에 터치가 되지 않아 앱을 다시 실행시켜야하는 치명적인 버그입니다.

그래서 다른 방법을 찾아보았고 UIPresentationController를 활용하는 방법이 있었습니다.

 

 

 

2. UIPresentationController를 활용하여 한층 더 발전시켜보자

 

위 방식에서 한 층 더 추가되는 방법이라고 생각하시면 됩니다.

UIViewControllerTransitioningDelegate를 VC에서 채택하는 것이 아니고 새로운 객체(일종의 Manager 객체)에서 채택하고 이를 transitioningDelegate로 넘겨주는 방식입니다.

 

말이 어렵죠? 바로 코드로 살펴보겠습니다.

 

아래와 같이 UIViewControllerTransitioningDelegate를 VC에서 채택하는 것이 아니라 SlideInPresentationManager라는 클래스를 추가로 만들어서 여기서 delegate를 채택하고 구현해줍니다.

 

import Foundation

final class SlideInPresentationManager: NSObject {
    var ratio: CGFloat = 0.75
    var transition = SlideInTransition()
}

// MARK: - UIViewControllerTransitioningDelegate
extension SlideInPresentationManager: UIViewControllerTransitioningDelegate {
  func presentationController(
    forPresented presented: UIViewController,
    presenting: UIViewController?,
    source: UIViewController
  ) -> UIPresentationController? {
    let presentationController = SlideInPresentationController(
      presentedViewController: presented,
      presenting: presenting,
      ratio: ratio
    )
    return presentationController
  }

  func animationController(
    forPresented presented: UIViewController,
    presenting: UIViewController,
    source: UIViewController
  ) -> UIViewControllerAnimatedTransitioning? {
      transition.isPresenting = true
      return transition
  }

  func animationController(
    forDismissed dismissed: UIViewController
  ) -> UIViewControllerAnimatedTransitioning? {
      transition.isPresenting = false
      return transition
  }
}

 

그리고 SlideMenuVC를 호출할때 위에서 만든 SlideInPresentationManager 객체를 transitioningDelegate로 넘겨줍니다.

이렇게 하면 1번 방식처럼 delegate 세부 내용을 VC에서 구현할 필요가 없습니다.

1번 방법보다 모듈화된 방식이라고 볼 수 있습니다.

 

// 호출부

lazy var slideTransition = SlideInPresentationManager().then {
    $0.ratio = 1
}

let viewController = SideMenuViewController()
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.modalPresentationStyle = .custom
navigationController.transitioningDelegate = slideTransition
self.present(navigationController, animated: true)

 

안타깝게도 여기서 끝이 아닙니다.

아직 UIPresentationController가 나오지 않았는데 이제 등장합니다.

SlideMenuController에서 UIPresentationController를 채택해야합니다.

 

그리고 몇가지 메소드를 구현해주면 진짜 끝입니다.

import UIKit

final class SlideMenuViewController: UIPresentationController {
    // MARK: - Properties
    private var dimmingView = UIView().then {
        $0.backgroundColor = .black
        $0.alpha = 0.0
    }
    private var ratio: CGFloat
    
    override var frameOfPresentedViewInContainerView: CGRect {
        var frame: CGRect = .zero
        frame.size = size(forChildContentContainer: presentedViewController,
                          withParentContainerSize: containerView!.bounds.size)
        return frame
    }
    
    // MARK: - Initializers
    init(presentedViewController: UIViewController,
         presenting presentingViewController: UIViewController?,
         ratio: CGFloat) {
        self.ratio = ratio
        super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
        setupDimmingViewGesture()
    }
    
    override func presentationTransitionWillBegin() {
        setupDimmingViewLayout()
        
        guard let coordinator = presentedViewController.transitionCoordinator else {
            dimmingView.alpha = 0.7
            return
        }
        
        coordinator.animate(alongsideTransition: { _ in
            self.dimmingView.alpha = 0.7
        })
    }
    
    override func dismissalTransitionWillBegin() {
        guard let coordinator = presentedViewController.transitionCoordinator else {
            dimmingView.alpha = 0.0
            return
        }
        
        coordinator.animate(alongsideTransition: { _ in
            self.dimmingView.alpha = 0.0
        })
    }
    
    override func containerViewWillLayoutSubviews() {
        presentedView?.frame = frameOfPresentedViewInContainerView
    }
    
    override func size(forChildContentContainer container: UIContentContainer,
                       withParentContainerSize parentSize: CGSize) -> CGSize {
        return CGSize(width: parentSize.width * ratio, height: parentSize.height)
    }
}

// MARK: - Private
private extension SlideMenuViewController {
    func setupDimmingViewLayout() {
        containerView?.insertSubview(dimmingView, at: 0)
        
        dimmingView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    
    func setupDimmingViewGesture() {
        let recognizer = UITapGestureRecognizer(target: self,
                                                action: #selector(handleTap(recognizer:)))
        dimmingView.addGestureRecognizer(recognizer)
    }
    
    @objc func handleTap(recognizer: UITapGestureRecognizer) {
        presentingViewController.dismiss(animated: true)
    }
}

 

 

위 코드의 핵심은 두 가지입니다.

 

1. dimView 관련 셋업을 해주기: setupDimmingViewLayout(), setupDimmingViewGesture()

   * dimView 관련 애니메이션도 presentationTransitionWillBegin()와 dismissalTransitionWillBegin()에서 설정해줍니다.

 

2. Size 관련하여 몇가지 값을 정해주기

    1) override var frameOfPresentedViewInContainerView

         - 전체 화면 사이즈 (메뉴와 dimView를 포함하는 ContainerView)

 

    2) override func size()

         - 메뉴 화면의 크기 설정 (dimView를 제외한 화면)

 

    3) override func containerViewWillLayoutSubviews()

         - 화면의 회전과 같은 화면 비율에 대응

 

 

위와 같이 dimView를 SideMenuViewController에서 처리하기 때문에 기존의 SlideInTransition에 있던 DimView는 필요가 없게 됩니다.

아래와 SlideInTransition 클래스에서 dimView 관련 코드를 제거해줍니다.

class SlideInTransition: NSObject, UIViewControllerAnimatedTransitioning {

    var isPresenting = false
    var widthRatio: CGFloat = 0.75

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.3
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

        guard let toViewController = transitionContext.viewController(forKey: .to),
            let fromViewController = transitionContext.viewController(forKey: .from) else { return }

        let containerView = transitionContext.containerView

        let finalWidth = toViewController.view.bounds.width * widthRatio
        let finalHeight = toViewController.view.bounds.height

        if isPresenting {
            containerView.addSubview(toViewController.view)
            toViewController.view.frame = CGRect(x: -finalWidth, y: 0, width: finalWidth, height: finalHeight)
        }

        let transform = {
            toViewController.view.transform = CGAffineTransform(translationX: finalWidth, y: 0)
        }

        let identity = {
            fromViewController.view.transform = .identity
        }

        let duration = transitionDuration(using: transitionContext)
        let isCancelled = transitionContext.transitionWasCancelled
        UIView.animate(withDuration: duration, animations: {
            self.isPresenting ? transform() : identity()
        }) { (_) in
            transitionContext.completeTransition(!isCancelled)
        }
    }
}

 

 

 

이제 정말 끝입니다! 수고하셨습니다.

 

 

 

정리

 

1. Side Menu 애니메이션을 구현하는 방식은 두가지 방식이 있다. UIViewControllerTransitioningDelegate를 활용한 방식과 UIPresentationController를 활용한 방식이다.

 

2. UIPresentationController를 활용한 방식이 기존 방식(UIViewControllerTransitioningDelegate)에서 한층 더 발전된 방식이다.

 

3. 기존 방식은 사이드 이펙트도 있고 애니메이션을 모듈화 하기 힘든 부분이 있기 때문에 두번째 방식을 쓰는 것이 좋겠다.

 

 

 

참고

* https://www.kodeco.com/3636807-uipresentationcontroller-tutorial-getting-started

 

 

 

 

 

반응형
Comments