ホットペッパーAPIの実装 Part3
今回で第1回、2回と紹介してきたホットペッパーAPIを利用したアプリで最後にView側の実装について紹介をします。
Viewの実装コード
完成した時の仕様です。検索バーに入力したキーワード検索によってお店情報を一覧表示します。
アプリ起動時の画面 | 検索結果画面 |
---|---|
HomeViewController
検索バーに入力されたテキストから基づいて検索を行い、結果をTableViewに表示する機能を持っています。またエラー発生時にアラートを出すロジックをまとめています。
-
bindViewModel
メソッドで、検索結果が自動的にテーブルへ表示されるようにし、エラーが発生した際にはアラートで知らせます -
configureTableView
メソッドで、テーブルビューのカスタムセルをセットアップしています -
showAlert
メソッドで、エラーメッセージを表示するアラート(ポップアップウィンドウ)を表示するための関数を定義してます
import UIKit
import RxSwift
import RxCocoa
final class HomeViewController: UIViewController, UISearchBarDelegate {
// MARK: - IBOutlets
@IBOutlet private weak var searchBar: UISearchBar!
@IBOutlet private weak var tableView: UITableView!
// MARK: - Properties
let viewModel = HomeViewModel()
private let disposeBag = DisposeBag()
// MARK: - View Life-Cycle Methods
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
configureTableView()
}
// MARK: - Other Methods
private func bindViewModel() {
// 検索結果のバインディング
viewModel.searchResults
.bind(to: tableView.rx.items(cellIdentifier: "HomeCell", cellType: HomeCell.self)) { row, cellViewModel, cell in
cell.configure(viewModel: cellViewModel)
}
.disposed(by: disposeBag)
// エラー発生時のアラート表示
viewModel.error
.asDriver(onErrorJustReturn: "")
.drive(onNext: { [weak self] errorMessage in
if !errorMessage.isEmpty {
self?.showAlert(message: errorMessage)
}
})
.disposed(by: disposeBag)
// Searchボタンが押されたときのイベントを監視
searchBar.rx.searchButtonClicked
.withLatestFrom(searchBar.rx.text.orEmpty)
.subscribe(onNext: { [weak self] text in
self?.viewModel.searchGourmet(with: text)
})
.disposed(by: disposeBag)
}
private func configureTableView() {
// カスタムセル
let nib = UINib(nibName: "HomeCell", bundle: nil)
tableView.register(nib, forCellReuseIdentifier: "HomeCell")
}
/// アラートを表示
private func showAlert(message: String) {
let alert = UIAlertController(title: "エラーが発生しました",
message: message,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true, completion: nil)
}
}
bindViewModelメソッド内の実装について
ここの処理はViewModel側のlet searchResults: BehaviorSubject<[HomeCellViewModel]> =BehaviorSubject(value: [])
からHomeViewControllerにデータを流して、検索結果の内容をTableViewに表示させる処理を行ってます。
ここの時点で既に表示させるだけのデータになっています。Part2で紹介したBehaviorSubject
は初期値を持たせているプロパティなので、アプリ起動時は何もTableViewには表示がされていない状態になっています。ここに検索結果を取得すると自動的にデータが流れ表示させる仕組みです。
viewModel.searchResults
.bind(to: tableView.rx.items(cellIdentifier: "HomeCell", cellType: HomeCell.self)) { row, cellViewModel, cell in
cell.configure(viewModel: cellViewModel)
}
.disposed(by: disposeBag)
.bindについて
.bind
はRxSwitが提供するObservable(発信元)のデータとLabelなどのUI要素を接続(バインド)するメソッドです。
「データが更新されると自動で画面も変わる仕組み」 を実現してくれるイメージです。
これがあることによってデータ変わる度にUIの更新を簡単にさせることができます。
身近な物に例えると音楽プレイヤーの音楽リストとスピーカーを繋ぐ接続端子やケーブルのようなイメージです。音楽リストにある聞きたい曲を選曲(Observable)し、スピーカー(UI要素)で聞きたいので、ケーブル(.bind)で繋いで音楽を聴けるようにするイメージです。
RxSwiftを利用するプロジェクトにおいては必須と言えるメソッドかと考えます。
viewModel.searchResultsの処理について
-
tableView.rx.items
はRxSwiftの拡張機能です。この拡張機能を使用しない場合は以下の2点を考慮する必要があります- 本来TableViewのセルにデータを設定するには、
UITableViewDataSourceプロトコル
を採用し、tableView(_:cellForRowAt:)メソッド
でセルにデータを手動で設定する必要があります - データが変更されるたびに、reloadData()を呼び出してビューを更新します
- 本来TableViewのセルにデータを設定するには、
この2点を手動で行わず簡潔に実装でき、且つデータが更新されると自動的に反応するリアクティブプログラミングを実現してくれています。
続けて中の処理を紹介します。
-
cellIdentifierで指定したセル(この場合はHomeCell)を使って、searchResultsから得られたデータ(cellViewModel)を設定しています。後ほど紹介するHomeCellファイルの中に
configure
メソッドがあり、これが各UI要素にデータを入れている処理を行っています -
row, cellViewModel, cellは、UITableViewの各セルにデータをバインドするために必要な3つの引数です
- 行番号でセルの位置を特定する (row)
- 各セルに表示するデータ (cellViewModel)
- データを反映するためのセル (cell)
今回row
を使ってないですが、使った場合の実装は下記のようなパターンが考えられます。例えばCellの偶数行だけ背景色を変更したい場合の処理で、rowで特定の行を選択して実装をカスタマイズできるようになっています。
searchResults
.bind(to: tableView.rx.items(cellIdentifier: "HomeCell", cellType: HomeCell.self)) { row, cellViewModel, cell in
cell.configure(with: cellViewModel)
// 奇数・偶数行で背景色を変更
cell.backgroundColor = (row % 2 == 0) ? UIColor.lightGray : UIColor.white
}
.disposed(by: disposeBag)
ViewModel.errorの処理について
エラーのデータのデータストリームを監視し、エラーのObservableがイベントとして発火されると感知してアラートを表示する処理を行っています。
-
.asDriver(onErrorJustReturn: "")
はVMから貰うデータはObservableな状態なので、Driver型に変換しています- (onErrorJustReturn: "") を使用することで、仮にエラーが発生した場合でもデフォルトの値(ここでは空文字 "")を返すため、エラーでストリームが中断されないようになっています。 ここがいわゆるDriver型はエラーを発生させない言われる肝になる部分だと考えます
-
.driver
でエラーの情報を取得しerrorMessage
でデータを更に流します。エラーが出ると.asDriverでデフォルトに指定した空文字が返却されるので、空であればアラートを発火させる処理を実装しています
searchBar.rx.searchButtonClickedの処理について
- searchBar.rx.searchButtonClickedもtableView同様、Rxの拡張機能です。この拡張機能を使うことで検索ボタンがクリックされたときの処理を簡単に追加できます。この拡張機能を使用しない場合は以下の点を考慮する必要があります
-
UISearchBarDelegate
のsearchBarSearchButtonClicked(_ searchBar: UISearchBar)
を追加して、検索ボタンがクリックされたときの処理を追加します
-
これをすることで実装は同じようにできますが、コードが多くなり複雑になってしまいますのでRx機能を使い簡潔に実装をすることができるようになると言うことです。
続けて実装の内容を纏めていきます。
-
withLatestFrom
は特定のObservableのイベントが発行されると、その指定したイベントの最新のデータを監視し取得します- 具体的にはテキストフィールドの値を監視し最新の値を取得しています。今回であれば「焼肉」や「鮮魚」など文字が入力されれば、リアルタイムで最新の値を取得することが可能です
-
orEmpty
を使うと、値がnilだった場合でも空文字""に変換されるため、データがない場合でもクラッシュしないようにしています
-
subscribe
でsearchBarがクリックされる度に、テキストフィールドに入力した検索ワードをsearchGourmet
メソッドに渡しデータを探して頂きます
configureTableViewメソッド内の実装について
tableViewに「HomeCell」というカスタムセルを登録する設定を行うためのメソッドです。
showAlertメソッド内の実装について
エラーが発火するとアラートが表示されるようにした実装です。
HomeCell
ここで各UIパーツに対してAPIデータを取得する処理を実装しています。
configure
メソッドで引数に(viewModel: HomeCellViewModel)と指定しています。ここは
HomeViewControllerで呼び出しを行う際に
viewModel.searchResults`で検索結果を取得したときにこのCell側のファイルに渡すよう指定してます。
そして受取りをしたら自動的にUIにセットするようしてます。
import UIKit
import RxSwift
import RxCocoa
final class HomeCell: UITableViewCell {
// MARK: - IBOutlets
/// 店名
@IBOutlet private weak var storeNameLabel: UILabel!
/// 住所
@IBOutlet private weak var addressLabel: UILabel!
/// ジャンル
@IBOutlet private weak var genreLabel: UILabel!
/// 予算
@IBOutlet private weak var budgetLabel: UILabel!
/// 定員
@IBOutlet private weak var capacityLabel: UILabel!
/// 営業時間
@IBOutlet private weak var openTimeLabel: UILabel!
/// URL
@IBOutlet private weak var urlTextView: UITextView! {
didSet {
urlTextView.textContainerInset = .zero
urlTextView.textContainer.lineFragmentPadding = 0
}
}
/// 画像
@IBOutlet private weak var shopImageView: UIImageView!
// MARK: - Properties
private let disposeBag = DisposeBag()
// MARK: - Other Methods
func configure(viewModel: HomeCellViewModel) {
viewModel.outputs.storeNameLabel
.drive(onNext: { storeName in
self.storeNameLabel.text = storeName
})
.disposed(by: disposeBag)
viewModel.outputs.addressLabel
.drive(onNext: { address in
self.addressLabel.text = address
})
.disposed(by: disposeBag)
viewModel.outputs.genreLabel
.drive(onNext: { genre in
self.genreLabel.text = genre
})
.disposed(by: disposeBag)
viewModel.outputs.budgetLabel
.drive(onNext: { budget in
self.budgetLabel.text = budget
})
.disposed(by: disposeBag)
viewModel.outputs.capacityLabel
.drive(onNext: { capacity in
self.capacityLabel.text = capacity
})
.disposed(by: disposeBag)
viewModel.outputs.openTimeLabel
.drive(onNext: { openTime in
self.openTimeLabel.text = openTime
})
.disposed(by: disposeBag)
viewModel.outputs.displayText
.drive(onNext: { display in
self.urlTextView.attributedText = display
})
.disposed(by: disposeBag)
viewModel.outputs.imageData
.drive(onNext: { date in
guard let imageDate = date else { return }
self.shopImageView.image = UIImage(data: imageDate)
})
.disposed(by: disposeBag)
}
}
.driveについて
.drive
はDriver型のデータを扱う際に使用します。具体的にはUI要素に安全にデータをバインド(接続)できるようにしています。
.drive(onNext:)
を使うことで、Driver<String>型
のデータを通常のString型
としてUIに渡してあげるようにしています。
.driveと.bindの違いと使い分けについて
この2つはどちらも同じようにデータをバインドできるメソッドですが似て非なる物です。
ここではどのような違いがあり、またどのように使い分けたらいいかについて説明します。
- .drive
- 主にUI要素のデータを接続する物に対して利用します
- Driverのデータを扱う場合に使うメソッドで、Driver型はエラーがない(何かしらデータを入れる)特性を持っているため、UIの更新に利用します
- .bind
- 主にObservableに対して使うメソッドで、汎用的に使用できます。UI要素以外でも今回であればホットペッパーAPIの検索結果をHomeCellViewModelへイベントデータを渡す時に使用しました
- 勿論Driverのデータでもバインドは可能ですがスレッドやエラーの保証がありません。この場合はエラーハンドリングの処理を手動で行わないといけない
使い分けとしては、
「UI要素に対してデータを入れる時には.driveを使用し、その他通信エラー処理を必要とするObservableのデータやUI以外のデータ処理は.bindを使用する。」
このように使い分ければいいと思います。
Part3のまとめ
今回でホットペッパーAPIを利用したアプリの紹介が最後になります。
RxSwiftのよく利用されるメソッドとMVVMの各ファイルで処理する役割を明確にすることができ今後に活かせそうだと思います。
RxSwiftでよく利用されるメソッドを中心に理解するため実装をしていますが、その他にもよく使われるメソッドとしては下記のようなものもあります。
-
filter
- 指定した条件を満たす要素のみを通過させる
-
distinctUntilChanged
- 連続した重複する値を除外する
-
combineLatest
- 複数のObservableの最新の値を組み合わせて新しいObservableを作成する
-
merge
- 複数のObservableを1つにマージして、いずれかから発行された要素を通過させる
-
zip
- 複数のObservableの要素をペアリングして、新しいObservableを作成する
-
take
- 指定した数の要素だけを通過させ、それ以降は無視する
-
startWith
- Observableの発行前に指定した初期値を発行する
-
concat
- 複数のObservableを順番に発行する
-
concatMap
- 各要素をObservableに変換し、前のObservableの完了後に次のObservableを実行する
-
.asObservable
- BehaviorRelayやDriverなどのObservableではない型を、一般的なObservable型に変換します
-
.skip
- 指定した数の最初の要素をスキップして、その後の要素のみをObservableとして流す
概要の確認だけでなく実際に実装をして試して使ってみるのが一番理解できると思うので、引続き学習を進めていこうと思いました。