UIHostingControllerのすすめ(実践編)
はじめに
UIHostingControllerのすすめ の実践編です。
実際に画面遷移をして値などを画面間でやりとりする下記のようなアプリを実装してみます。
ローカル DB から本を取得して一覧表示して本を選択したら詳細画面に遷移するアプリです。
構成
こんな感じの構成。

HostingController
下記を持っています。
- View
 さわるのは初期化時のみでその後は直接さわることはない
- ViewModel
 値を更新して View を更新する
- PresentationModel
 表示用にデータを加工したり、Model 経由でデータ取得をする
基本的には下記の流れになるはず。
- 
PresentationModelでデータ加工
- 
ViewModelに値渡す
- 
View更新
ボタン押下などのイベント処理は下記の流れ。
- 
Viewボタン押下
- 
ViewModelのSubject.send()で通知
- 
HostingControllerでsinkで受信
- 画面遷移もしくは PresentationModel経由で値取得など
View
ViewModel のみを持ち ViewModel の値を表示するだけ。
ViewModel
View に表示する値とイベント送信用の PassthroughSubject を持つ。
基本的には String や Int の集まりになるはず。
Model の通信処理で取得したものをそのまま使うと不要なものも多いので View 専用の Struct を作成する(下のソースで言うと View 内で定義している Item)。これをやっていると Previews が使いやすい。
PresentationModel
Model を持つ。ViewModel に渡すための値の加工をする。Model を介して通信処理などを行う。
画面遷移時に遷移元 HositingController で生成するのでここで Model の差し替えもできるはず。
Model
UI 関連以外のもの全般。
実装
Storyboard を使って遷移は Segue にしましたがとくに Stroyboard を使わなくても実現できそうです。
ナビゲーションバーの表示に関しては NavigationView は使わずに HostingController 側で実装するようにしました(View 側でやると表示の更新タイミングがちょっとだけ遅い気がしたので)。
ソース
Storyboard

import SwiftUI
import Combine
import SDWebImageSwiftUI
final class BookListHostingController: UIHostingController<BookListView> {
    var presentationModel: BookListPresentationModel!
    private let viewModel = BookListViewModel()
    private var subscriptions = Set<AnyCancellable>()
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder, rootView: .init(viewModel: viewModel))
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        presentationModel = .init(.init(.init(repository: BookRepository())))
        title = presentationModel.screenTitle
        viewModel.items = presentationModel.fetchItems()
        viewModel.$selectedId
            .dropFirst()
            .compactMap { $0 }
            .sink { id in
                let book = self.presentationModel.fetchBook(byId: id)
                self.performSegue(withIdentifier: "showDetail", sender: book)
            }
            .store(in: &subscriptions)
    }
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if let destination = segue.destination as? BookHostingController,
           let book = sender as? Book {
            destination.presentationModel = .init(.init(.init(book: book)))
        }
    }
}
final class BookListPresentationModel {
    struct Dependency {
        let repository: BookRepository
    }
    init(_ dependency: Dependency) {
        self.repository = dependency.repository
    }
    private let repository: BookRepository
    private var books: [Book] = []
    let screenTitle = "一覧"
    func fetchItems() -> [BookListView.Item] {
        books = repository.fetchBooks()
        return books.map {
            BookListView.Item(id: $0.id,
                              imageURL: URL(string: $0.imageURL)!,
                              bookTitle: $0.title)
        }
    }
    func fetchBook(byId id: String) -> Book? {
        return books.first { $0.id == id }
    }
}
final class BookListViewModel: ObservableObject {
    var items: [BookListView.Item]
    @Published var selectedId: String? = nil
    init(items: [BookListView.Item] = []) {
        self.items = items
    }
}
struct BookListView: View {
    struct Item {
        let id: String
        let imageURL: URL
        let bookTitle: String
    }
    private let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 2)
    @ObservedObject var viewModel: BookListViewModel
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns) {
                ForEach(viewModel.items, id: \.id) { item in
                    VStack {
                        AnimatedImage(url: item.imageURL)
                        Text(item.bookTitle)
                    }.onTapGesture {
                        viewModel.selectedId = item.id
                    }
                }
            }
        }
    }
}
struct BookListView_Previews: PreviewProvider {
    static var previews: some View {
        BookListView(viewModel: .init(items: [
            .init(
            id: "a",
            imageURL: .init(string: "https://images-na.ssl-images-amazon.com/images/P/B009WNS33M.09.MZZZZZZZ")!,
            bookTitle: "戦国妖狐 3巻 (コミックブレイド)"
            ),
            .init(
            id: "b",
            imageURL: .init(string: "https://images-na.ssl-images-amazon.com/images/P/B009WNS33M.09.MZZZZZZZ")!,
            bookTitle: "戦国妖狐 3巻 (コミックブレイド)"
            )
        ]))
    }
}
import SwiftUI
import Combine
import SDWebImageSwiftUI
final class BookHostingController: UIHostingController<BookView> {
    var presentationModel: BookPresentationModel!
    private let viewModel = BookViewModel()
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder, rootView: .init(viewModel: viewModel))
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        title = presentationModel.screenTitle
        viewModel.item = presentationModel.makeItem()
    }
}
final class BookViewModel: ObservableObject {
    var item: BookView.Item
    init(item: BookView.Item = .init(title: "", imageURL: nil, purchaseDate: "", publicationDate: "")) {
        self.item = item
    }
}
struct BookView: View {
    struct Item {
        let title: String
        let imageURL: URL?
        let purchaseDate: String
        let publicationDate: String
    }
    @ObservedObject var viewModel: BookViewModel
    var body: some View {
        VStack {
            Text(viewModel.item.title)
            AnimatedImage(url: viewModel.item.imageURL!)
            Text(viewModel.item.purchaseDate)
            Text(viewModel.item.publicationDate)
        }
    }
}
final class BookPresentationModel {
    struct Dependency {
        let book: Book
    }
    init(_ dependency: Dependency) {
        self.book = dependency.book
    }
    var screenTitle: String {
        return book.title
    }
    private let book: Book
    private let dateFormatter: DateFormatter = {
        let df = DateFormatter()
        df.dateFormat = "yyyy/MM/dd"
        return df
    }()
    func makeItem() -> BookView.Item {
        return .init(title: book.titlePronunciation,
                     imageURL: URL(string: book.imageURL)!,
                     purchaseDate: "購入日: \(book.purchaseDate.flatMap { dateFormatter.string(from: $0) } ?? "")",
                     publicationDate: "出版日: \(book.publicationDate.flatMap { dateFormatter.string(from: $0) } ?? "")")
    }
}
struct BookView_Previews: PreviewProvider {
    static var previews: some View {
        BookView(viewModel: .init(item: .init(
            title: "戦国妖狐 3巻 (コミックブレイド)",
            imageURL: .init(string: "https://images-na.ssl-images-amazon.com/images/P/B009WNS33M.09.MZZZZZZZ")!,
            purchaseDate: "購入日: 2022/01/17",
            publicationDate: "出版日: 2018/11/05")))
    }
}
単体テスト
単体テストは Model と PresentationModel については書けるようになっているのでこの2つのテストをすれば問題ないはず。
Previews
Previews に関しては ViewModel でイニシャライザを宣言しているので String などの値を設定してやるだけで表示されるようになっているはず。
利点
- 遷移は今まで通りの実装でできるので快適
- Previews が使える
- もう XIB などのコードレビューがいらない
- プレゼンテーション部分のテストができる
欠点
- 
HostingControllerが膨れがち
- 
ViewModelをView側で更新しないようチームで認識共有は必要
- Previews でナビゲーションバーはみれない
- 
@Bindingはないので遷移先で値更新する場合は遷移元にデリゲートなどで通知する必要がある
おわりに
わりと快適に使えるんじゃないかなというのはできましたが実務では使ったことないので大規模になるとわかりません!個人開発レベルならこれでやろうかなと思います。
もっとこうした方がいいなどあれば教えていだだけると幸いです🙇♂️



Discussion