UIHostingControllerのすすめ(実践編)

2022/05/09に公開

Xcode-13.1

はじめに

UIHostingControllerのすすめ の実践編です。

実際に画面遷移をして値などを画面間でやりとりする下記のようなアプリを実装してみます。

https://twitter.com/am103141592/status/1523294926238523392

ローカル DB から本を取得して一覧表示して本を選択したら詳細画面に遷移するアプリです。

構成

こんな感じの構成。

HostingController

下記を持っています。

  • View
    さわるのは初期化時のみでその後は直接さわることはない
  • ViewModel
    値を更新して View を更新する
  • PresentationModel
    表示用にデータを加工したり、Model 経由でデータ取得をする

基本的には下記の流れになるはず。

  1. PresentationModel でデータ加工
  2. ViewModel に値渡す
  3. View 更新

ボタン押下などのイベント処理は下記の流れ。

  1. View ボタン押下
  2. ViewModelSubject.send() で通知
  3. HostingControllersink で受信
  4. 画面遷移もしくは PresentationModel 経由で値取得など

View

ViewModel のみを持ち ViewModel の値を表示するだけ。

ViewModel

View に表示する値とイベント送信用の PassthroughSubject を持つ。

基本的には StringInt の集まりになるはず。

Model の通信処理で取得したものをそのまま使うと不要なものも多いので View 専用の Struct を作成する(下のソースで言うと View 内で定義している Item)。これをやっていると Previews が使いやすい。

PresentationModel

Model を持つ。ViewModel に渡すための値の加工をする。Model を介して通信処理などを行う。

画面遷移時に遷移元 HositingController で生成するのでここで Model の差し替えもできるはず。

Model

UI 関連以外のもの全般。

実装

Storyboard を使って遷移は Segue にしましたがとくに Stroyboard を使わなくても実現できそうです。

ナビゲーションバーの表示に関しては NavigationView は使わずに HostingController 側で実装するようにしました(View 側でやると表示の更新タイミングがちょっとだけ遅い気がしたので)。

ソース

Storyboard

BookListHostingController
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巻 (コミックブレイド)"
            )
        ]))
    }
}
BookHostingController
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")))
    }
}

単体テスト

単体テストは ModelPresentationModel については書けるようになっているのでこの2つのテストをすれば問題ないはず。

Previews

Previews に関しては ViewModel でイニシャライザを宣言しているので String などの値を設定してやるだけで表示されるようになっているはず。

利点

  • 遷移は今まで通りの実装でできるので快適
  • Previews が使える
  • もう XIB などのコードレビューがいらない
  • プレゼンテーション部分のテストができる

欠点

  • HostingController が膨れがち
  • ViewModelView 側で更新しないようチームで認識共有は必要
  • Previews でナビゲーションバーはみれない
  • @Binding はないので遷移先で値更新する場合は遷移元にデリゲートなどで通知する必要がある

おわりに

わりと快適に使えるんじゃないかなというのはできましたが実務では使ったことないので大規模になるとわかりません!個人開発レベルならこれでやろうかなと思います。

もっとこうした方がいいなどあれば教えていだだけると幸いです🙇‍♂️

Discussion