-
Notifications
You must be signed in to change notification settings - Fork 0
화면 전환을 위한 Coordinator
🏠 동네 한입팀이 화면전환을 위해 Coordinator 패턴을 설계한 과정을 담아보고자 작성합니다.****
이전에 타 프로젝트를 진행할때 Coordinator를 이용해 화면전환을 했던 경험이 있었습니다
childCoordinator, parentCoordinator를 지정하고 finish를 하기 위해 childFinishDelegate를 설정해줘야했습니다.
저는 타 프로젝트를 진행할때 큰 실수를 하였는데요 backButton을 이용해 pop되는 로직이 실행될때는 Coordinator 파일이 메모리에서 해제되지 않는 버그를 발견하기도 했습니다 🥲
이렇게 기존의 Coordinator의 불편함을 느끼고 있었습니다
Coordinator를 사용해보면서 제가 느꼈던 장점, 단점을 정리해보았습니다
장점
- 화면 전환이나 View간 데이터 전달을 쉽게 파악할 수 있다
단점
- ViewModel, ViewController객체를 포함하면서 메모리 관리에 불편함이 있을 수 있다
- Coordinator를 사용하는 과정에서 휴먼에러가 발생하기 쉽다 ( 보일러 플레이트 또한 발생 )
저희 팀은 Coordinator를 도입할지 고민을 해보았고 위에 적은 단점을 개선한 Coordinator를 설계하기로 결정하였습니다
동네 한입 app은 위에 생각했던 단점을 극복하고자 하였습니다
ViewModel, ViewController객체를 포함하면서 메모리 관리에 불편함이 있을 수 있다
→ 자동으로 메모리를 조절할 수 있는 구조로 설계하자!
Coordinator를 사용하는 과정에서 휴먼에러가 발생하기 쉽다 ( 보일러 플레이트 발생 )
→ Enum을 활용하고 최대한 단순한 구조로 설계하자!
RxFlow와 https://github.com/AlbertMontserrat/CoordinatorsAutoHandled 을 참고하여 저희 Coordinator를 설계하였습니다
MVVM으로 구성된 하나의 Coordinator으로
- presenter는 navigationController로 강한 참조로 ViewController를 가지고 있습니다
- ViewController는 ViewModel을 가지고 있습니다
- ViewModel에는 강한 참조로 Cooridinator 가져 ViewModelDelegate에 해당하는 이벤트를 전달합니다
- Coordinator에서 ViewModelDelegate가 위임한 이벤트를 실제로 실행하게 되고 약한 참조로 presenter를 가져 viewController가 해제되는 경우 ViewController -> ViewModel -> Coordinator순으로 메모리가 자동으로 해제 되게 됩니다
Coordinator 프로토콜은 Coordinator의 공통 메서드인 start()를 추상화시켜 채택받게 하도록 구현하였습니다
FlowCoordinator들을 구성하기 위한 프로토콜을 구현하였습니다 구현에 필요한 presentationStyle, navigationController, start, initScene을 작성하였고 start 메서드는 공통적으로 쓰이는 부분이기 때문에 extension을 이용하여 프로토콜 기본구현하였습니다
public protocol FlowCoordinator: Coordinator {
var presentationStyle: PresentationStyle { get set }
var navigationController: UINavigationController? { get set }
func start()
func initScene() -> UIViewController
}
public extension FlowCoordinator {
func start() {
switch presentationStyle {
case let .push(navigationController):
self.navigationController = navigationController
let initScene = initScene()
initScene.hidesBottomBarWhenPushed = true
self.navigationController?.pushViewController(initScene, animated: true)
case .present(presenter: let presenter, modalPresentationStyle: let modalPresentationStyle):
let navigationController = BaseNavigationController(rootViewController: initScene())
self.navigationController = navigationController
self.navigationController?.modalPresentationStyle = modalPresentationStyle
presenter.present(navigationController, animated: true, completion: nil)
case .setViewController(navigationController: let navigationController):
self.navigationController?.viewControllers = []
let newNavigation = BaseNavigationController(rootViewController: initScene())
newNavigation.modalTransitionStyle = .flipHorizontal
newNavigation.modalPresentationStyle = .overFullScreen
navigationController.present(newNavigation, animated: true, completion: nil)
case .none:
self.navigationController = BaseNavigationController(rootViewController: initScene())
}
}
}
PresentationStyle은 화면전환을 정리한 열거형으로 push, present, setViewController, none으로 구성되어 있습니다
- push는 새로운 Coordinator flow를 Push할때 사용합니다
- present는 새로운 Coordinator flow를 modally하게 띄워줄때 사용합니다 modalPresentationStyle로 overFullScreen, formSheet 등 설정할 수 있고 기본값은 automatic입니다
- setViewController는 TabCoordinator나 로그아웃하면 Login창을 띄우는 등 ViewHierachy를 초기화 시키려고 만들었습니다
- none은 TabBar로 구성된 Home, Map, Favorite, Setting Coordinator를 띄우려고 만들었어요 push 할순 없어서...
public enum PresentationStyle {
case push(navigationController: UINavigationController)
case present(presenter: UINavigationController, modalPresentationStyle: UIModalPresentationStyle = .automatic)
case setViewController(navigationController: UINavigationController)
case none
}
AppCoordinator에서는 root Coordinator로 Coordinator flow를 처음 실행하는 로직을 담고 있어요! 현재는 자동로그인 로직 구현을 위해 메서드 껍데기들을 만들어놓았습니다
TabBarController를 담고있는 coordinator로 Home, Map, Favorite, Setting Coordinator를 생성합니다 지금은 Home이랑 Map만 만들어 두었습니다 :)
- FlowCoordinator protocol로 기본구현을 해두어서 initScene메서드만 구현해주면 됩니다!
- initScene에는 해당 Flow 초기화면인 ViewController를 반환해주어야합니다 ( ViewController가 ViewModel을 가지고 있어야함 )
- HomeCoordinator는 HomeViewModelDelegate를 채택하고 있어 메서드 내부에 새로운 Coordinator들을 push, present하는 로직을 구성할 수 있습니다!
- 추가로 ViewController만 push하고 싶다면
secondScene()
메서드를 만들어서 push만 하면 됩니다! present도 마찬가지!!navigationController.pushViewController(secondScene(), animated: true)
final class HomeCoordinator: FlowCoordinator {
var presentationStyle: PresentationStyle
weak var navigationController: UINavigationController?
init(presentationStyle: PresentationStyle) {
self.presentationStyle = presentationStyle
}
internal func initScene() -> UIViewController {
let homeViewModel = HomeViewModel(delegate: self)
let homeViewController = HomeViewController(viewModel: homeViewModel)
return homeViewController
}
}
extension HomeCoordinator: HomeViewModelDelegate {
func push() {
guard let navigationController = navigationController else { return }
MapCoordinator(presentationStyle: .push(navigationController: navigationController)).start()
}
func present() {
guard let navigationController = navigationController else { return }
HomeCoordinator(presentationStyle: .present(presenter: navigationController)).start()
}
func pop() {
guard let navigationController = navigationController else { return }
navigationController.popViewController(animated: true)
}
func dismiss() {
guard let navigationController = navigationController else { return }
navigationController.dismiss(animated: true)
}
func setViewController() {
guard let navigationController = navigationController else { return }
TabBarCoordinator(presentationStyle: .setViewController(navigationController: navigationController)).start()
}
}
HomeViewController는 ViewModel을 가지고 있습니다
class HomeViewController: BaseViewController {
var viewModel: HomeViewModel?
HomeViewmodel은 delegate를 설정하여 실제 실행될 action을 명시하고 action을 coordinator에 위임하여 화면전환 로직을 건네주게 됩니다
- 추가로 HomeViewModelType은 메서드 일일이 만들기 힘들어서 구현했어요.. 메서드 복붙만 하면 되니까요!
protocol HomeViewModelDelegate {
func push()
func present()
func pop()☺️
func dismiss()
func setViewController()
}
protocol HomeViewModelType {
func push()
func present()
func pop()
func dismiss()
func setViewController()
}
final class HomeViewModel: BaseViewModel {
let delegate: HomeViewModelDelegate
init(delegate: HomeViewModelDelegate) {
self.delegate = delegate
}
}
엄청나게 완벽하다고 박수를 쳤지만 코드 리뷰를 받아보니 끝없는 에러의 향연이 있었습니다.. 😇
로그인 flow에서 TabBar를 띄울 때 문제가 발생합니다.
AppCoordinator에서 navigationController를 가지고 있고
TabBar는 각 VC내 navigationController를 가지고 있어서 navigationBar가 쌓이는 현상이 발생하였습니다
찾아보니 TabBarController는 SceneDelegate내에 있는 window 객체의 rootViewController로 지정하는 경우가 많았습니다
그렇다면 Coordinator를 생성할 때 window객체를 전달해야하는가에 고민이 되었습니다
필요없는 코드가 너무 방대해지고 당장의 flow에서는 TabBar는 한번만 push되기 때문에 navigationBar를 hidden 처리해주어서 해결하였습니다
navigationController.isNavigationBarHidden = true
TabBarCoordinator(presentationStyle: .push(navigationController: navigationController)).start()
정말 열심히 [PR](https://github.com/YAPP-Github/21st-iOS-Team-1-iOS/pull/27)을 남겨서 구조를 설명하려고 애썼으나 직접 개발하지 않으신만큼 러닝커브가 많이 존재하였습니다
그리고 화면 구성을 하는 지금도 PR에 이렇게 작성되어 있습니다…😅 제잘못입니다..
그래서 협업 측면에서 효율적인지 고민하게 되었고 더 단순하게 할순 없었는지 RxFlow를 사용했었더라면 더 편했을지 생각을 하게 되었습니다
화면전환 설계는 너무나도 어렵네요 😇
감사합니다
[참고]