SwiftUI

SwiftUI에서 ViewController를?

Phililip
728x90

안녕하세요.

 

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/

 

Model View Controller for SwiftUI

Overall SwiftUI has been well received after its introduction. However, something most developers stumble upon quickly is how to structure non-trivial applications. One option is to just stick to MVC and get a reasonably clean architecture that isn’t ful

www.alwaysrightinstitute.com

 

 


 

이번 글은 여기서 마무리.

 

 

 

반응형

'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