0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Swift]RxSwift×MVVM ホットペッパーAPIを利用したアプリを作ってみた Part 3

Last updated at Posted at 2024-11-27

ホットペッパーAPIの実装 Part3

今回で第1回、2回と紹介してきたホットペッパーAPIを利用したアプリで最後にView側の実装について紹介をします。

Viewの実装コード

完成した時の仕様です。検索バーに入力したキーワード検索によってお店情報を一覧表示します。

アプリ起動時の画面 検索結果画面
スクリーンショット 2024-11-07 21.11.20.png スクリーンショット 2024-11-07 21.12.59.png

HomeViewController

検索バーに入力されたテキストから基づいて検索を行い、結果をTableViewに表示する機能を持っています。またエラー発生時にアラートを出すロジックをまとめています。

  • bindViewModelメソッドで、検索結果が自動的にテーブルへ表示されるようにし、エラーが発生した際にはアラートで知らせます
  • configureTableViewメソッドで、テーブルビューのカスタムセルをセットアップしています
  • showAlertメソッドで、エラーメッセージを表示するアラート(ポップアップウィンドウ)を表示するための関数を定義してます
HomeViewController
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点を考慮する必要があります

    1. 本来TableViewのセルにデータを設定するには、UITableViewDataSourceプロトコルを採用し、tableView(_:cellForRowAt:)メソッドでセルにデータを手動で設定する必要があります
    2. データが変更されるたびに、reloadData()を呼び出してビューを更新します

この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の拡張機能です。この拡張機能を使うことで検索ボタンがクリックされたときの処理を簡単に追加できます。この拡張機能を使用しない場合は以下の点を考慮する必要があります
    1. UISearchBarDelegatesearchBarSearchButtonClicked(_ 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にセットするようしてます。

HomeCell
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として流す

概要の確認だけでなく実際に実装をして試して使ってみるのが一番理解できると思うので、引続き学習を進めていこうと思いました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?