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