20 July 2023

기존 프로젝트에는 UIKit 기반으로 구성되어있다고 가정해봅시다. 이 경우, SwiftUI는 해당 프로젝트에 부분적으로 밖에 적용할 수 없습니다. 적용되어 있는 아키텍처에는 SwiftUI를 넣기 어렵기 때문입니다.

만약 SwiftUI를 UIViewController의 View 역할만 담당한다면, View는 SwiftUI로 작성하고, Life Cycle은 기존 UIKit을 유지할 수 있지 않을까요?

그렇다면 MVVM, Viper, RIBs 등의 아키텍처와 SwiftUI는 공존할 수 있어 보입니다.

SwiftUI의 View는 ObservableObject를 채택한 클래스로부터 발행된 값을 전달받아 화면을 그리고, View에서 발생된 이벤트를 ObservableObject를 채택한 클래스에 전달해주면 되지 않을까요?

그러면 View와 Interactor 간의 데이터 전달이 가능해집니다.


Interactor에서는 기존 Presenter에 상태값을 전달하면, Presenter에서 SwiftUI의 ViewModel에 상태값을 다시 전달하고, ViewModel에서 상태값을 SwiftUI의 View에 전달하여 화면을 그립니다.

즉, Interactor -> Presenter -> ViewModel -> View 순으로 상태값이 전달됩니다.

SwiftUI의 View에서 발생된 이벤트를 ViewModel에 전달하면 ViewModel은 Interactor에 전달합니다.

View -> ViewModel -> Interactor 순으로 이벤트가 전달됩니다.

Presenter는 View에 상태값을 전달하는 역할에만 충실히 하도록 설계한다면, 코드의 복잡도가 낮아질 수 있습니다.

앞에서 이야기한 구조를 작성해봅시다.

RIBs + SwiftUI

Interactor, Presenter, ViewModelView 간의 전달되는 StateAction을 정의합니다.

/// FileName : HomeViewStateAction.swift

struct HomeViewState {
    var title: String
    var desc: String
}

enum HomeViewAction {
    case viewDidLoad
    case tap1
    case tap2
}

다음으로, PresentableListener, Presenter(ViewController)를 정의합니다.

/// FileName : HomeViewController.swift

import SwiftUI
import UIKit

protocol HomePresentableListener: AnyObject {
    func request(action: HomeViewAction)
}

final class HomeViewController: UIViewController, HomePresentable, HomeViewControllable {
    weak var listener: HomePresentableListener?
}

여기까지는 일반적인 RIBs에서 사용하는 방식과 비슷합니다.

다음으로, ViewModel, View를 정의합니다.

/// FileName : HomeView.swift

import SwiftUI

class HomeViewModel: ObservableObject {
    typealias State = HomeViewState
    typealias Action = HomeViewAction

    weak var listener: HomePresentableListener?

    @Published var state: State

    init(listener: HomePresentableListener? = nil,
         state: State) {
        self.listener = listener
        self.state = state
    }

    func update(state: State) {
        self.state = state
    }
    
    func request(action: Action) {
        listener?.request(action: action)
    }
}

struct HomeView: View {
    @ObservedObject var viewModel: HomeViewModel

    var body: some View {
        HStack {
            Spacer()
            VStack(alignment: .center) {
                Spacer()

                Button("Tap1 Action Button") {
                    viewModel.request(action: .tap1)
                }
                
                Button("Tap2 Action Button") {
                    viewModel.request(action: .tap2)
                }

                Spacer()
                    .frame(height: 10)

                Text(viewModel.state.title)
                    .font(.title)
                    .border(.gray)

                Spacer()
                    .frame(height: 10)

                Text(viewModel.state.desc)
                    .font(.title)
                    .border(.gray)

                Spacer()
            }
            Spacer()
        }
        .border(Color.blue)
        .padding()
    }
}

ViewModelPresentableListener를 weak 변수로 가지고 있습니다. PresentableListenerInteractor로 weak를 통해 순환참조를 하지 않도록 주의합니다. 또한, ViewModelPresentableListener를 통해 Interactor에 View에서 발생한 액션을 전달할 수 있습니다.

다시 Presenter(ViewController)로 돌아가서, ViewrootView로 가지는 UIHostingControllerViewController에 추가합니다.

/// FileName : HomeViewController.swift

import SwiftUI
import UIKit

protocol HomePresentableListener: AnyObject {
    func request(action: HomeViewAction)
}

final class HomeViewController: UIViewController, HomePresentable, HomeViewControllable {
    weak var listener: HomePresentableListener?

    private lazy var viewModel = HomeViewModel(listener: listener, state: .init(title: "Hello", desc: "World"))

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Hello World"

        let rootView = HomeView(viewModel: viewModel)
        let contentVC = UIHostingController(rootView: rootView)
        addChild(contentVC)
        contentVC.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(contentVC.view)
        contentVC.didMove(toParent: self)

        NSLayoutConstraint.activate([
            contentVC.view.topAnchor.constraint(equalTo: view.topAnchor),
            contentVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            contentVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            contentVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        
        listener?.request(action: .viewDidLoad)
    }

    func update(state: HomeViewState) {
        viewModel.update(state: state)
    }
}

ViewController에서는 ViewModel을 가지고 있어 상태값을 ViewModel에 전달하여 View를 갱신하도록 할 수 있습니다. 또한, listener에 값이 할당되면, ViewModelPresentableListener에도 할당을 하여 ViewModel에서 Interactor를 호출할 수 있도록 합니다.

위 ViewController의 viewDidLoad 함수 내부를 정리하도록 합시다.

/// FileName : HomeViewController.swift

final class HomeViewController: UIViewController, HomePresentable, HomeViewControllable {
    ...
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Hello World"

        HomeView(viewModel: viewModel)
            .attach(to: self)

        listener?.request(action: .viewDidLoad)
    }
}

extension View {
    func attach(to parentViewController: UIViewController) {
        let contentVC = UIHostingController(rootView: self)
        let parentVC = parentViewController

        parentVC.addChild(contentVC)
        contentVC.view.translatesAutoresizingMaskIntoConstraints = false
        parentVC.view.addSubview(contentVC.view)
        contentVC.didMove(toParent: parentVC)
        
        NSLayoutConstraint.activate([
            contentVC.view.topAnchor.constraint(equalTo: parentVC.view.topAnchor),
            contentVC.view.bottomAnchor.constraint(equalTo: parentVC.view.bottomAnchor),
            contentVC.view.leadingAnchor.constraint(equalTo: parentVC.view.leadingAnchor),
            contentVC.view.trailingAnchor.constraint(equalTo: parentVC.view.trailingAnchor),
        ])
    }
}

다음으로, Interactor에서 HomePresentableListener를 채택한 코드를 구현해봅시다.

/// FileName : HomeInteractor.swift
import RIBs
import RxSwift

protocol HomeRouting: ViewableRouting {}

protocol HomePresentable: Presentable {
    var listener: HomePresentableListener? { get set }
    func update(state: HomeViewState)
}

protocol HomeListener: AnyObject {}

final class HomeInteractor: PresentableInteractor<HomePresentable>, HomeInteractable, HomePresentableListener {
    weak var router: HomeRouting?
    weak var listener: HomeListener?

    override init(presenter: HomePresentable) {
        super.init(presenter: presenter)
        presenter.listener = self
    }
    
    func request(action: HomeViewAction) {
        let state: HomeViewState
        switch action {
        case .viewDidLoad:
            state = .init(title: "ViewDidLoad Action",
                          desc: "Number \(Int.random(in: 0 ... 5))")
        case .tap1:
            state = .init(title: "Tap1 Action",
                          desc: "Tap1 Number \(Int.random(in: 0 ... 5))")
        case .tap2:
            state = .init(title: "Tap2 Action",
                          desc: "Tap2 Number \(Int.random(in: 0 ... 5))")
        }
        presenter.update(state: state)
    }
}

Interactor에서 상태값을 만들어 Presentable 프로토콜에서 정의한 update(state:) 함수를 호출하여 Presentable를 채택한 ViewController에 전달합니다. 그러면 ViewController에서는 ViewModel에 다시 전달하여 View를 갱신하게 합니다.



ViewModel은 상태값을 전달받아 View에 전달하여 화면을 갱신하고, View의 이벤트를 받아 다시 PresentableListener에 전달하므로, Preview 작성시 해당 작업을 쉽게 구현할 수 있습니다.

/// FileName : HomeView.swift

#if DEBUG
struct HomeView_Previews: PreviewProvider {
    typealias State = HomeViewState
    typealias Action = HomeViewAction
    typealias ViewModel = HomeViewModel

    class Listener: HomePresentableListener {
        var viewModel: ViewModel?

        func request(action: Action) {
            let state: State
            switch action {
            case .viewDidLoad:
                state = .init(title: "Preview ViewDidLoad Action",
                              desc: "Number \(Int.random(in: 0 ... 5))")
            case .tap1:
                state = .init(title: "Preview Tap1 Action",
                              desc: "Tap1 Number \(Int.random(in: 0 ... 5))")
            case .tap2:
                state = .init(title: "Preview Tap2 Action",
                              desc: "Tap2 Number \(Int.random(in: 0 ... 5))")
            }
            viewModel?.update(state: state)
        }
    }
    
    static let listener = Listener()
    
    static var previews: some View {
        let state = State(title: "Hello", desc: "World")
        let vm = HomeViewModel(listener: listener, state: state)
        let view = HomeView(viewModel: vm)
        listener.viewModel = vm
        
        return view
    }
}
#endif


정리

  • UIViewController의 View 역할을 SwiftUI의 View로 대신하면 기존 아키텍처에 SwiftUI와 공존이 가능

참고자료

전체코드

HomeViewStateAction.swift

/// FileName : HomeViewStateAction.swift
import Foundation

struct HomeViewState {
    var title: String
    var desc: String
}

enum HomeViewAction {
    case viewDidLoad
    case tap1
    case tap2
}

HomeInteractor.swift

/// FileName : HomeInteractor.swift
import RIBs
import RxSwift

protocol HomeRouting: ViewableRouting {}

protocol HomePresentable: Presentable {
    var listener: HomePresentableListener? { get set }
    func update(state: HomeViewState)
}

protocol HomeListener: AnyObject {}

final class HomeInteractor: PresentableInteractor<HomePresentable>, HomeInteractable, HomePresentableListener {
    weak var router: HomeRouting?
    weak var listener: HomeListener?

    override init(presenter: HomePresentable) {
        super.init(presenter: presenter)
        presenter.listener = self
    }
    
    func request(action: HomeViewAction) {
        let state: HomeViewState
        switch action {
        case .viewDidLoad:
            state = .init(title: "ViewDidLoad Action",
                          desc: "Number \(Int.random(in: 0 ... 5))")
        case .tap1:
            state = .init(title: "Tap1 Action",
                          desc: "Tap1 Number \(Int.random(in: 0 ... 5))")
        case .tap2:
            state = .init(title: "Tap2 Action",
                          desc: "Tap2 Number \(Int.random(in: 0 ... 5))")
        }
        presenter.update(state: state)
    }
}

HomeViewController.swift

/// FileName : HomeViewController.swift
import UIKit
import SwiftUI

protocol HomePresentableListener: AnyObject {
    func request(action: HomeViewAction)
}

final class HomeViewController: UIViewController, HomePresentable, HomeViewControllable {
    weak var listener: HomePresentableListener?
    
    lazy var viewModel = HomeViewModel(listener: listener, state: .init(title: "Hello", desc: "World"))
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = "Hello World"
        
        HomeView(viewModel: viewModel)
            .attachTo(ViewController: self)
        
        listener?.request(action: .viewDidLoad)
    }
    
    func update(state: HomeViewState) {
        viewModel.update(state: state)
    }
}

extension View {
    func attachTo(ViewController parentViewController: UIViewController) {
        let contentVC = UIHostingController(rootView: self)
        let parentVC = parentViewController

        parentVC.addChild(contentVC)
        contentVC.view.translatesAutoresizingMaskIntoConstraints = false
        parentVC.view.addSubview(contentVC.view)
        contentVC.didMove(toParent: parentVC)
        
        NSLayoutConstraint.activate([
            contentVC.view.topAnchor.constraint(equalTo: parentVC.view.topAnchor),
            contentVC.view.bottomAnchor.constraint(equalTo: parentVC.view.bottomAnchor),
            contentVC.view.leadingAnchor.constraint(equalTo: parentVC.view.leadingAnchor),
            contentVC.view.trailingAnchor.constraint(equalTo: parentVC.view.trailingAnchor),
        ])
    }
}

HomeView.swift

/// FileName: HomeView.swift
import Foundation
import SwiftUI

class HomeViewModel: ObservableObject {
    typealias State = HomeViewState
    typealias Action = HomeViewAction

    weak var listener: HomePresentableListener?

    @Published var state: State

    init(listener: HomePresentableListener? = nil,
         state: State)
    {
        self.listener = listener
        self.state = state
    }

    func update(state: State) {
        self.state = state
    }

    func request(action: Action) {
        listener?.request(action: action)
    }
}

struct HomeView: View {
    @ObservedObject var viewModel: HomeViewModel

    var body: some View {
        HStack {
            Spacer()
            VStack(alignment: .center) {
                Spacer()

                Button("Tap1 Action Button") {
                    viewModel.request(action: .tap1)
                }
                
                Button("Tap2 Action Button") {
                    viewModel.request(action: .tap2)
                }

                Spacer()
                    .frame(height: 10)

                Text(viewModel.state.title)
                    .onChange(of: viewModel.state.title) { _ in
                        print("title changed to \(viewModel.state.desc)!")
                    }
                    .font(.title)
                    .border(.gray)

                Spacer()
                    .frame(height: 10)

                Text(viewModel.state.desc)
                    .onChange(of: viewModel.state.desc) { _ in
                        print("desc changed to \(viewModel.state.desc)!")
                    }
                    .font(.title)
                    .border(.gray)

                Spacer()
            }
            Spacer()
        }
        .border(Color.blue)
        .padding()
    }
}

#if DEBUG
struct HomeView_Previews: PreviewProvider {
    typealias State = HomeViewState
    typealias Action = HomeViewAction
    typealias ViewModel = HomeViewModel

    class Listener: HomePresentableListener {
        var viewModel: ViewModel? {
            didSet { viewModel?.listener = self }
        }

        func request(action: Action) {
            let state: State
            switch action {
            case .viewDidLoad:
                state = .init(title: "Preview ViewDidLoad Action",
                              desc: "Number \(Int.random(in: 0 ... 5))")
            case .tap1:
                state = .init(title: "Preview Tap1 Action",
                              desc: "Tap1 Number \(Int.random(in: 0 ... 5))")
            case .tap2:
                state = .init(title: "Preview Tap2 Action",
                              desc: "Tap2 Number \(Int.random(in: 0 ... 5))")
            }
            viewModel?.update(state: state)
        }
    }
    
    static let listener = Listener()
    
    static var previews: some View {
        let state = State(title: "Hello", desc: "World")
        let view = HomeView(viewModel: .init(state: state))
        listener.viewModel = view.viewModel
        
        return view
    }
}
#endif