본문으로 건너뛰기

네비게이션 구조 작성하기

본격적으로 프로젝트를 진행해보자.


iOS 로 개발하려는 하이피치는 SwiftUI 를 활용해 macOS 앱으로 개발 된 상태로 맥 앱스토어에 배포되어 있다. (버그가 많은 것은 비밀) 그렇기에 이전 프로젝트를 활용해서 프로젝트를 세팅하려 했다. 그런데..

생각대로 잘 되지 않는다..

이 전에는 SwiftUI 를 비롯해 iOS 17 기반의 다양한 애플의 Kit 들을 활용했었다. 그런데 UIKit 으로 바꾼 것 부터 폴더 구조가 싹 바뀌고, 코드 작성 방식이 달라지다보니 세팅부터가 난항이다.. 게다가 로그인 및 맥 앱과의 데이터 연동과 같은 추가 개발 사항들도 있다보니, 이전 프로젝트의 코드를 바로 적용하기는 쉽지 않은 것 같다. 다행히 앱 내의 사용 될 데이터와 네비게이션 구조는 크게 바뀌진 않은 터라, 이것들부터 세팅해야겠다.

네비게이션 처리는 로그인 기능이 추가되긴 했지만 플랫폼이 iOS 로 변경되어 비교적 간단해졌다. macOS 앱을 개발 했을 때는, 사용자의 앱 접근성을 높이기 위해 MenubarExtra 를 활용했었는데, 이 때문에 여러 Scnene 으로 나뉘어, 데이터 공유에 신경쓸 게 있었는데, iOS 는 우선 단일 Scnene 으로 개발 될 것 같다. 위젯과 같은 기능이 추가되면 씬이 추가되려나? 아직은 해보지 않아서 거기까진 잘 모르겠다.

문제는, UIKit 방식의 네비게이션 처리가 익숙치 않다는 점.. NavigationStack 이 그립다.. 일단 간단하게 최종적인 네비게이션까지 이동하는 구조를 먼저 작성하자. 구조를 작성하기 전에, 와이어프레임과 네비게이션을 통해 전달 될 데이터를 살펴보자.

네비게이션 구조 잡기

MVP에 대한 와이어프레임

와이어 프레임을 살펴보면, 로그인 이후 메인 화면에서 가장 먼저 보여져야 할 데이터로 프로젝트 리스트 를 확인할 수 있다. 프로젝트는 개별 연습을 하나로 묶어주는 단위이다. 이 리스트를 위한 모델링은 다음과 같다.

struct ProjectModel {
let id = UUID().uuidString
var name: String
var creatAt: Date
var editAt: Date
var practices: [PracticeModel] = []
}

ProjectModel 은 연습 기록들을 담고 있는 간단한 모델이다. 각각의 프로젝트 별로 여러 개의 연습 기록이 있을 수 있고, 연습 기록에는 사용자가 진행한 발표 연습에 대한 미디어 정보와, 분석 결과를 포함하고 있다. 최종적인 네비게이션 도착지가 이 개별 연습 기록 페이지다. 연습 기록에 대한 모델링은 다음과 같다.

struct PracticeModel {
let id = UUID().uuidString
var name: String
var creatAt: Date
var isRemarkable: Bool = false
var isLocalStored: Bool = true // 로컬 저장여부
var analysis: PracticeAnalysis // 개별 연습에 대한 분석정보
var media: PracticeMedia // 연습의 미디어 데이터 타입(음성 or 영상)
}


연습 기록을 저장하기 위해 작성한 PracticeModel 은 최종적인 네비게이션 도착지에서 사용될 모델이며 모델의 데이터는 뷰에서 보여질 정보에 따라 달라질 것이기 때문에 하위 데이터 구조에 맞게 디자인에 맞게 수정할 예정이다.

위 두 모델이 네비게이션 구조의 핵심 모델이다. 이 모델들을 활용한 네비게이션 구조를 러프하게 작성해보았다.


아주 심플한 단방향 네비게이션 구조가 완성되었다. 이제 위 그림에 연습을 진행할 수 있는 뷰를 비롯한 와이어프레임에 보여지는 다른 뷰들도 추가해주자.


작성된 와이어프레임과 지난 macOS 앱 개발 시 작성했던 구조를 토대로 위와같은 네비게이션 구조를 작성했다. 유저 관련 및 다른 기능들을 위한 뷰가 추가되면 네비게이션 구조도 추가되겠지만, 로그인 이후 프로젝트 목록을 통해 개별 연습기록으로 도달하는 메인 플로우는 변하지 않을 것이므로, 이 구조를 기준으로 네비게이션 구조를 작성하자.


지금까지 기본적인 네비게이션 구조를 작성했다. 이제 이 구조를 바탕으로 네비게이션을 위한 코드를 작성해보자.

뷰 컨트롤러 작성하기

UIKit 방식으로 화면전환을 하려면 NavigationViewController 를 활용해야 한다. 앱이 가장 먼저 실행될 뷰를 rootView 로 지정하고, 다른 뷰로 이동할 때는 pushNavigation, 이전 뷰로 돌아갈 때는 dismiss 가 활용된다. 하이피치 앱은 로그인 정보를 바탕으로 사용자가 서버에 저장한 프로젝트 및 연습 기록을 가져올 것이므로, 로그인 화면인 LoginVC(ViewController)rootView 로 설정하였다. 로그인 뷰에서 로그인 여부를 확인 후 로그인 된 상태라면 탭바를 활용하는 MyProjectVC 로 이동시킬 것이다.

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?

func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {

guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
let vc = UINavigationController(rootViewController: SignInViewController())
window.rootViewController = vc
self.window = window
window.makeKeyAndVisible()
}
}


import UIKit

final class SignInViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()
setup()
}
private func setup() {
let button = makeButton(withText: "Sign-In")
view.addSubview(button)

NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])

button.addTarget(self, action: #selector(signIn), for: .touchUpInside)
}

private func makeButton(withText text: String) -> UIButton {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle(text, for: .normal)

return button
}

@objc func signIn() {
let mainVC = UINavigationController(rootViewController: MainTabBarViewController())
mainVC.modalPresentationStyle = .fullScreen
present(mainVC, animated: false)
}

deinit {
print("I'm Die")
}
}


SignInViewController 를 살펴보자. Sign-In 이라는 버튼이 하나 있고, 이 버튼을 클릭하면 로그인 된 것으로 간주한다. 로그인이 되었다면, TabBar를 갖는 MainTabBarViewControllerrootView 로 하는 NavigationViewController 로 뷰를 이동시킨다. 해당 네비게이션 처리는 present 메서드를 사용했다. 이유는 Navigation 을 분리하고 싶어서.. SignInVC 는 회원가입과 같은 다른 네비게이션처리도 할 텐데, 로그인 된 상태와 로그인 안된 상태의 네비게이션이 분리되는 것이 좋겠다고 생각했다.

presentpushNavigation

둘 모두 화면 전환 처리를 담당하는 메서드다. presentpushNavigation 과 달리 NavigationViewController 로 감싸지 않더라도, 화면 전환이 가능하며, 기본 애니메이션 동작은 present 는 아래에서 위로, pushNavigation 는 우에서 좌로 이동한다.


import UIKit

final class MainTabBarViewController: UITabBarController {

override func viewDidLoad() {
super.viewDidLoad()
setup()
}

private func setup() {
setupTabBar()
}

private func setupTabBar() {
let vcs = [MyProjectViewController(), MyPracticeAnalysisViewController(), MyPageViewController()]
let tabBarItems = [
UITabBarItem(title: "내 프로젝트", image: UIImage(systemName: "house.fill"), tag: 0),
UITabBarItem(title: "내 연습 분석", image: UIImage(systemName: "newspaper.fill"), tag: 1),
UITabBarItem(title: "마이페이지", image: UIImage(systemName: "person.fill"), tag: 2)
]

vcs.enumerated().forEach { index, viewController in
tabBarItems[index].image = tabBarItems[index].image?.withBaselineOffset(fromBottom: 16)

viewController.tabBarItem = tabBarItems[index]
viewController.tabBarItem.title = nil

viewController.navigationItem.title = tabBarItems[index].title
}
setViewControllers( vcs.map { $0 }, animated: true)

tabBar.backgroundColor = .white
tabBar.tintColor = .point
}
}

MainTabBarViewController 는 로그인 된 상태라면 도달하는 첫번째 뷰컨트롤러다. 기본적으로 TabBar 네비게이션 처리를 담당하고 있다. 또한 present 된 이후 보여지는 UINavigationControllerrootView 이므로, 탭 바 이외의 네비게이션처리도 담당할 예정이다.

첫번째 탭 바는 MyProjectViewController (내 프로젝트) 로, 사용자들이 실질적으로 가장 먼저 상호작용할 뷰다. 프로젝트의 리스트를 담고 있으며, 리스트의 아이템을 통해 개별 프로젝트에 접근할 수 있다. 여기서 부터는 데이터 전달 및 레이아웃 작성 코드가 길어지므로,, 간단히 생략 후 추가적인 코드를 작성하며 완성해 나가도록 하겠다.

final class MyProjectViewController: UIViewController {
private let collectionView = UICollectionView() // 프로젝트 리스트를 담을 컬렉션 뷰
var projects: [ProjectModel] = [] // 프로젝트 리스트 정보
override viewDidLoad() {
super.viewDidLoad()
setup()
}

func setup() {
// 뷰 세팅
}
}

final class ProjectDetailViewController: UIViewController {
private let collectionView = UICollectionView() // 연습 리스트를 담을 컬렉션 뷰
var project: ProjectModel? // 개별 프로젝트 정보

override viewDidLoad() {
super.viewDidLoad()
setup()
}

func setup() {
// 뷰 세팅
}
}

final class PracticeDetailViewController: UIViewController {
var practice: PracticeModel? // 개별 연습 정보

override viewDidLoad() {
super.viewDidLoad()
setup()
}

func setup() {
// 뷰 세팅
}
}

위의 뷰 컨트롤러들을 따라 이동하며 최종적으로 PracticeDetailViewControllerPracticeModel 을 전달하면 메인플로우의 최종적인 네비게이션의 처리가 완료된다. 쉽죠?


작성한 네비게이션 구조 중 색칠 된 영역까지가 지금까지 작성한 뷰 컨트롤러에 해당되는 부분이다.

맺음말

지금까지 큰 틀에서의 네비게이션 구조를 작성해보았다. 크게 어렵지는 않았지만, UIKit 에 익숙치 않아, 어떤 메서드 및 라이프사이클을 활용할 수 있을지, 어떻게 코드를 작성해야할지가 고민되게 하는 부분이 많았다. 이제 위의 네비게이션 구조를 골자로 UIKit 기반의 MVVM 의 프로젝트 구조를 작성해보자..

SwiftUI 로 편하게 @observable 할 때가 좋았는데 말이지.. MVVM 을 위해 rxSwift 를 사용하고자 하는데, 이 친구.. 아주 아주하다 😇😇