안녕하세요.
SwiftUI를 써보셨나요?
SwiftUI를 쓰다 보면... UIKit에서 자주 사용하던 MVC 패턴을 사용하고 싶다는 생각이 들 때가 있습니다.
물론 SwiftUI에서 MVC 패턴을 사용 못한다는 것은 아니에요.
그런데 막상 SwiftUI에서 MVC 패턴을?? 어떻게???라는 의문점이 들더라구요.
그래서 이번 글에서는 SwiftUI에서 MVVM 패턴에 얽매이지 않고 ViewController라는 개념을 적용해보는 시간을 가져볼게요.
# 1. SwiftUI에서의 ViewModel
이런 화면을 ViewModel을 사용해서 만든다고 가정해볼게요. (cow 모듈은 여기를 참고해주세요!)
import SwiftUI
import cows
struct ContentView: View {
var body: some View {
NavigationView {
CowsOverview()
}
}
}
struct CowsOverview: View {
@StateObject private var viewModel = CowsViewModel()
var body: some View {
List(viewModel.cows, id: \.self) { cow in
Text(verbatim: cow)
.font(.body.monospaced())
}
.searchable(text: $viewModel.search)
.navigationTitle("Cows Overview")
}
}
class CowsViewModel: ObservableObject {
@Published var cows = allCows
@Published var search = "" {
didSet {
cows = allCows.filter { search.isEmpty || $0.contains(search) }
}
}
}
CowsOverview 뷰는 뷰를 어떻게 그릴지에 대해서만 명시하고 있고, CowsViewModel이 로직들을 가지고 있어서 좋은 구조로 보입니다ㅎㅎ
# 2. ViewModel.....?
이번에는 List의 element를 클릭했을 때, 상단에 Detail 화면이 출력되도록 구현해봅시다.
아래처럼 구현했다고 가정해볼게요.
struct CowDetail: View {
@ObservedObject var viewModel : CowDetailViewModel
init(cow: String) {
_viewModel = ObservedObject(wrappedValue: CowDetailViewModel(cow: cow)) 🤔
}
var body: some View {
Text(verbatim: viewModel.cow)
.font(.body.monospaced())
.navigationTitle("Cow Detail")
}
}
class CowDetailViewModel: ObservableObject {
@Published var cow = ""
init(cow: String) { self.cow = cow }
}
struct CowsOverview: View {
@StateObject private var viewModel = CowsViewModel()
var body: some View {
VStack {
if let cow = viewModel.selectedCow {
CowDetail(cow: cow) 🤔
Text("Selection")
.font(.footnote)
Divider()
}
List(viewModel.cows, id: \.self) { cow in
Button(action: { viewModel.selectedCow = cow }) {
Text(verbatim: cow)
.font(.body.monospaced())
}
}
}
.searchable(text: $viewModel.search)
.navigationTitle("Cows Overview")
}
}
class CowsViewModel: ObservableObject {
@Published var search = "" {
didSet { cows = allCows.filter { search.isEmpty || $0.contains(search) } }
}
@Published var cows = allCows
@Published var selectedCow : String?
}
흠... 이럴 경우...
CowDetail 뷰가 생성될 때 @ObservedObject를 통해 ViewModel 객체를 전달받지만, 결국 ViewModel 객체를 참조하고 있는 곳은 어디에도 없기 때문에 언제든지 객체가 사라질 수 있습니다.
또한, 뷰가 refresh 될 때마다 CowDetailViewModel 인스턴스가 매번 생성되는 것도 문제가 될 수 있어요.
그래서 @ObservedObject -> @StateObject로 바꿔주면 좀 더 괜찮아지겠죠??
struct CowDetail: View {
@StateObject var viewModel : CowDetailViewModel
init(cow: String) {
_viewModel = .init(wrappedValue: CowDetailViewModel(cow: cow))
}
...
}
근데, 이 경우에도 문제가 있습니다.
List의 element를 클릭해도 CowDetail 뷰가 업데이트되지 않는 이슈가 생겨요. 😡
왜 그럴까요??
그 이유는 StateObject 때문입니다.
StateObject가 처음 초기화가 되면, 이후부터는 init이 동작하지 않기 때문이에요.
지금 발생하는 이슈는 onChange를 사용하면 해결은 가능합니다.
struct CowDetail: View {
@StateObject var viewModel : CowDetailViewModel
let cow : String
init(cow: String) {
self.cow = cow
_viewModel = .init(wrappedValue: CowDetailViewModel(cow: cow))
}
var body: some View {
Text(verbatim: viewModel.cow)
.font(.body.monospaced())
.navigationTitle("Cow Detail")
.onChange(of: cow) { newCow in viewModel.cow = newCow } 🤔
}
}
해결은.... 가능할 뿐... 그렇게 좋은 구조는 아니라는건 바로 알겠네요...허헣...;;;
# 3. ViewController
이럴 때, View 각각이 가지고 있는 ViewModel 들을 ViewController 형태로 바꿔준다면 조금 더 아름다운 구조를 가질 수 있습니다.
View는 뷰 rendering과 이벤트 처리를 담당하고, ViewController는 ViewController에 대응되는 View를 소유하고 이벤트가 왔을 때 동작하는 Action이 정의되어 있으면 됩니다.
(UIKit의 ViewController와 유사하죠??ㅎㅎ)
위 예시에서 보여드린 CowDetail(View)와 CowDetailViewModel(ViewModel)을 CowDetail(ViewController)로 합쳐볼게요.
// (1)
class CowDetail: ObservableObject {
// (2)
@Published var cow = ""
init(cow: String) { self.cow = cow }
// MARK: - Actions
// nothing
// MARK: - View
// (3)
struct ContentView: View {
// (4)
@ObservedObject var viewController : CowDetail
var body: some View {
Text(verbatim: viewController.cow)
.font(.body.monospaced())
.navigationTitle("Cow Detail")
}
}
}
코드 중 중요한 부분 몇 가지를 체크해볼게요.
(1) ViewController라는 개념을 표현하기 위해 CowDetail을 ObservableObject로 선언했습니다.
(2) 뷰 안에서 상태값을 선언하지 않는 대신, ViewController에서는 @Published 프로퍼티로 선언했습니다.
(3) ViewController의 뷰를 담당하는 ContentView를 내부에 선언했습니다.
(4) 연결된 뷰는 @ObservedObject를 통해서 ViewController를 전달받습니다. (이로써 뷰 <-> ViewController 간 상호작용이 가능해졌습니다.)
그럼 CowsOverview(View)와 CowsViewModel(ViewModel)도 바꿔볼까요??
class CowsOverview: ObservableObject {
@Published var search = "" {
didSet { cows = allCows.filter { search.isEmpty || $0.contains(search) } }
}
@Published var cows = allCows
@Published var detailViewController : CowDetail?
// MARK: - Actions
func showDetail(_ cow: String) {
detailViewController = CowDetail(cow: cow)
}
// MARK: - View
struct ContentView: View {
@ObservedObject var viewController : CowsOverview
var body: some View {
VStack {
if let presentedViewController = viewController.detailViewController {
CowDetail.ContentView(viewController: presentedViewController)
Text("Selection")
.font(.footnote)
Divider()
}
List(viewController.cows, id: \.self) { cow in
Button(action: { viewController.showDetail(cow) }) {
Text(verbatim: cow)
.font(.body.monospaced())
}
}
}
.searchable(text: $viewController.search)
.navigationTitle("Cows Overview")
}
}
}
👍 👍
ViewController 구조로 수정했으니 처음 시작이 되는 Root ViewController 설정이 필요하겠죠??
첫 시작이니, @ObservedObject가 아닌 @StateObject로 객체를 생성해주면 됩니다.
struct ContentView: View {
@StateObject var sceneViewController = CowsOverview()
var body: some View {
NavigationView {
CowsOverview.ContentView(viewController: sceneViewController)
}
}
}
👍 👍 👍
## 참고
- https://www.alwaysrightinstitute.com//viewcontroller/
이번 글은 여기서 마무리.
'SwiftUI' 카테고리의 다른 글
PreferenceKey (0) | 2022.05.30 |
---|---|
Environment (0) | 2022.05.29 |
복잡한 Navigation Flow 처리 (feat. Combine) (0) | 2022.05.10 |
MotionScape (0) | 2022.05.06 |
Dismiss Presented View (0) | 2022.04.25 |