[UIKit] Side Menu

Junyoung Park·2022년 12월 17일
0

UIKit

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

Swift: Side Menu from Scratch (Swift 5, Xcode 12, UIKit, 2022) - iOS Development

Side Menu

구현 목표

  • 사이드 메뉴 구현

구현 태스크

  • 컨테이너 뷰 구현
  • 메뉴 테이블 뷰 구현
  • 뷰 이동 로직 구현

핵심 코드

extension ContainerViewController: HomeViewControllerDelegate {
    func didTapMenuButton() {
        toggleMenu(completion: nil)
    }
    
    private func toggleMenu(completion: (() -> Void)?) {
        switch menuState {
        case .closed:
            UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .curveEaseInOut) { [weak self] in
                guard let self = self else { return }
                self.navVC?.view.frame.origin.x = self.homeVC.view.frame.size.width - 100
            } completion: { [weak self] done in
                if done {
                    self?.menuState = .opened
                }
            }
        case .opened:
            UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .curveEaseInOut) { [weak self] in
                guard let self = self else { return }
                self.navVC?.view.frame.origin.x = 0
            } completion: { [weak self] done in
                if done {
                    self?.menuState = .closed
                    DispatchQueue.main.async {
                        completion?()
                    }
                }
            }
        }
    }
}
  • 홈뷰 버튼의 메뉴 버튼을 누르면 사이드 바가 펼쳐지고, 해당 사이드 바 메뉴를 클릭할 때 특정 뷰로 이동되는 로직
  • 메뉴 상태를 컨테이너 뷰의 전역 변수 menuState로 저장, 버튼을 토글하면서 사이드바를 펼치고 닫는 애니메이션 효과를 구현한 toggleMenu
  • 해당 토글 메뉴 함수가 적용되는 경우는 버튼을 통해 열고/닫히는 상황, 버튼 중 특정 메뉴 버튼을 클릭한 뒤 뷰 이동 + 사이드바 닫히는 경우로 구분되기 때문에 옵셔널 컴플리션 핸들러를 통해 구현
extension ContainerViewController: SideMenuViewControllerDelegate {
    func didSelect(menuItem: SideMenuViewController.SideMenuOptions) {
        toggleMenu(completion: nil)
        switch menuItem {
        case .home:
            resetToHome()
        case .settings: addVC(to: SettingsViewController())
        case .info: addVC(to: InfoViewController())
        case .appRating:
            break
        case .shareApp:
            break
        }
    }
    
    private func addVC(to VC: UIViewController) {
        selectedVC = VC
        homeVC.addChild(VC)
        homeVC.view.addSubview(VC.view)
        VC.view.frame = view.bounds
        VC.didMove(toParent: homeVC)
        homeVC.title = VC.title
    }
    
    private func resetToHome() {
        guard let selectedVC = selectedVC else { return }
        selectedVC.view.removeFromSuperview()
        selectedVC.didMove(toParent: nil)
        self.selectedVC = nil
        homeVC.title = "Home"
    }

}
  • 컨테이너 뷰는 자식 뷰 사이드 바 뷰 컨트롤러가 델리게이트를 통해 알려주는 선택된 메뉴에 따라 현재 뷰 컨트롤러가 보여줄 뷰를 변경
  • addVC, resetToHome이라는 별도의 함수를 통해 selectedVC라는 뷰 컨트롤러 옵셔널 값을 전역으로 사용해 바꿔끼우기
  • 실제로 뷰 간의 이동이 homeVC 상단에 addSubview로 해당 뷰 컨트롤러의 뷰 자체를 추가하거나 해당 뷰 컨트롤러의 뷰를 슈퍼 뷰에서 삭제하는 방법으로 '이동하는 것처럼' 보이게 구현
  • addChild 메소드와 didMove를 함께 사용
private func addChildVC() {
        addChild(menuVC)
        view.addSubview(menuVC.view)
        menuVC.didMove(toParent: self)
        menuVC.delegate = self
        
        let navVC = UINavigationController(rootViewController: homeVC)
        homeVC.delegate = self
        addChild(navVC)
        view.addSubview(navVC.view)
        navVC.didMove(toParent: self)
        self.navVC = navVC
    }
  • 컨테이너 뷰의 자식 뷰 컨트롤러를 구성하는 함수
  • 사이드 바 메뉴를 담당하는 뷰 컨트롤러와 홈 뷰 컨트롤러를 순서대로 자식 뷰로 넣기
  • 해당 뷰 컨트롤러의 커스텀 델리게이트를 따르기
  • 네비게이션 바 컨트롤러를 사용한 까닭은 해당 홈 뷰 컨트롤러 등의 네비게이션 타이틀을 보기 위함
  • 뷰 컨트롤러 이동을 이후 델리게이트를 통해 조작하기 위해 전역 변수 값으로 넣기

소스 코드

class ContainerViewController: UIViewController {
    enum SideMenuState {
        case opened
        case closed
    }
    
    private var menuState = SideMenuState.closed
    private let menuVC = SideMenuViewController()
    private let homeVC = HomeViewController()
    private var navVC: UINavigationController?
    private var selectedVC: UIViewController?

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        addChildVC()
    }
    
    private func setUI() {
        view.backgroundColor = .systemRed
    }
    
    private func addChildVC() {
        addChild(menuVC)
        view.addSubview(menuVC.view)
        menuVC.didMove(toParent: self)
        menuVC.delegate = self
        
        let navVC = UINavigationController(rootViewController: homeVC)
        homeVC.delegate = self
        addChild(navVC)
        view.addSubview(navVC.view)
        navVC.didMove(toParent: self)
        self.navVC = navVC
    }
}

extension ContainerViewController: SideMenuViewControllerDelegate {
    func didSelect(menuItem: SideMenuViewController.SideMenuOptions) {
        toggleMenu(completion: nil)
        switch menuItem {
        case .home:
            resetToHome()
        case .settings: addVC(to: SettingsViewController())
        case .info: addVC(to: InfoViewController())
        case .appRating:
            break
        case .shareApp:
            break
        }
    }
    
    private func addVC(to VC: UIViewController) {
        selectedVC = VC
        homeVC.addChild(VC)
        homeVC.view.addSubview(VC.view)
        VC.view.frame = view.bounds
        VC.didMove(toParent: homeVC)
        homeVC.title = VC.title
    }
    
    private func resetToHome() {
        guard let selectedVC = selectedVC else { return }
        selectedVC.view.removeFromSuperview()
        selectedVC.didMove(toParent: nil)
        self.selectedVC = nil
        homeVC.title = "Home"
    }

}

extension ContainerViewController: HomeViewControllerDelegate {
    func didTapMenuButton() {
        toggleMenu(completion: nil)
    }
    
    private func toggleMenu(completion: (() -> Void)?) {
        switch menuState {
        case .closed:
            UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .curveEaseInOut) { [weak self] in
                guard let self = self else { return }
                self.navVC?.view.frame.origin.x = self.homeVC.view.frame.size.width - 100
            } completion: { [weak self] done in
                if done {
                    self?.menuState = .opened
                }
            }
        case .opened:
            UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .curveEaseInOut) { [weak self] in
                guard let self = self else { return }
                self.navVC?.view.frame.origin.x = 0
            } completion: { [weak self] done in
                if done {
                    self?.menuState = .closed
                    DispatchQueue.main.async {
                        completion?()
                    }
                }
            }
        }
    }
}
  • 홈 뷰 컨트롤러 또한 selectedVC의 일종으로 해도 될 것 같은데, 이는 즉 addVC 함수에서 선택한 뷰 컨트롤러의 뷰 자체를 homeVC에 넣거나 빼는 게 아니라 homeVC 자체를 특정 뷰 컨트롤러로 바꿔버리는 방법임
  • 이러한 경우 addChildremoveFromParent 등 자식/부모 관계를 설정하는 뷰 컨트롤러 인스턴스 메소드를 계속해서 사용해야 하기 때문에 UI 상으로 보여지는 데에도 레이턴시가 존재할 것
protocol HomeViewControllerDelegate: AnyObject {
    func didTapMenuButton()
}

class HomeViewController: UIViewController {
    weak var delegate: HomeViewControllerDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
    }
    
    private func setUI() {
        view.backgroundColor = .systemRed
        title = "Home"
        navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "list.dash"), style: .done, target: self, action: #selector(didTapMenuButton))
    }
    
    @objc private func didTapMenuButton() {
        delegate?.didTapMenuButton()
    }
}
  • 사이드 바 메뉴를 불러오는 메뉴 버튼을 홈 뷰 버튼에 달아놓고 해당 메뉴 버튼의 클릭 여부를 델리게이트를 통해 전달
protocol SideMenuViewControllerDelegate: AnyObject {
    func didSelect(menuItem: SideMenuViewController.SideMenuOptions)
}

class SideMenuViewController: UIViewController {
    enum SideMenuOptions: String, CaseIterable {
        case home = "Home"
        case settings = "Settings"
        case info = "Information"
        case appRating = "App Rating"
        case shareApp = "Share App"
        
        var imageName: String {
            switch self {
            case .home:
                return "house"
            case .settings:
                return "gear"
            case .info:
                return "airplane"
            case .appRating:
                return "star"
            case .shareApp:
                return "message"
            }
        }
    }
    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.delegate = self
        tableView.dataSource = self
        tableView.backgroundColor = .systemGray
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "tableViewCell")
        return tableView
    }()
    weak var delegate: SideMenuViewControllerDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        tableView.frame = CGRect(x: 0, y: view.safeAreaInsets.top, width: view.bounds.size.width, height: view.bounds.size.height)
    }
    
    private func setUI() {
        view.backgroundColor = .systemGray
        view.addSubview(tableView)
    }
}

extension SideMenuViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let model = SideMenuOptions.allCases[indexPath.row]
        delegate?.didSelect(menuItem: model)
    }
}

extension SideMenuViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return SideMenuOptions.allCases.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "tableViewCell", for: indexPath)
        let model = SideMenuOptions.allCases[indexPath.row]
        cell.textLabel?.text = model.rawValue
        cell.textLabel?.textColor = .white
        cell.imageView?.image = UIImage(systemName: model.imageName)?.withTintColor(.white, renderingMode: .alwaysOriginal)
        cell.backgroundColor = .systemGray
        cell.contentView.backgroundColor = .systemGray
        return cell
    }
}
  • 사이드 바 메뉴를 보여주는 테이블 뷰 컨트롤러
  • 특정 테이블 뷰 셀 클릭 여부를 뷰 컨트롤러의 델리게이트를 통해 전달

구현 화면

사이드 바의 선택 메뉴에 따라 현재 보여주는 뷰를 결정하는 게 키 포인트인데, 강의에서 보여주는 컨테이너 뷰가 가지고 있는 첫 번째 자식 뷰 homeVC에 선택 뷰 컨트롤러의 뷰 자체를 추가/삭제하는 방법은 위험할 수도 있겠다는 생각이 들었다.

  • 즉 일반적인 탭바 뷰와 마찬가지로 각 사이드바 메뉴에 따라 특정 뷰 컨트롤러(또는 해당 뷰 컨트롤러를 루트 뷰로 가진 네비게이션 컨트롤러)가 컨테이너 뷰에 로드되어야 한다는 생각
  • 이렇게 구현하기 위해서는 컨테이너 뷰가 실질적으로는 스플릿 뷰가 되어, 자신이 가지고 있는 두 번째 (첫 번째 뷰 컨트롤러는 사이드 바 뷰 컨트롤러) 뷰를 계속해서 갈아 끼워주는 역할을 담당해야 한다.
  • 델리게이트를 통해 사이드 바의 메뉴 선택 상황을 컨테이너 뷰에 전달하는 방법도 있지만, 동시에 노티피케이션 센터 등 전역으로 해당 상황을 캐치할 수도 있다.
profile
JUST DO IT
post-custom-banner

0개의 댓글