LoginSignup
64

More than 1 year has passed since last update.

RxSwiftとUIライブラリの表現を組み合わせたサンプル紹介

Last updated at Posted at 2018-12-12

1. はじめに

皆様お疲れ様です。RxSwift AdventCalendarの13日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。
1年前にほんの少しだけ実務の中で触れた経験や自分の学習の中でRxSwiftで書かれた簡単なサンプルの写経をした際の重要と感じた部分や理解の上でのポイントを自分なりにまとめた記事を書いて以来、めっきりとRxSwiftから遠ざかってしまいました...。
今回はRxSwiftの実装を改めて思い出すと同時に、実際のUIに当てはめた形でRxSwift+MVVMパターンの構成のサンプルアプリの作成に取り組んでみましたので、その実装記録をまとめました。

Githubでのサンプルコード:

サンプルの全体的な動きの動画:

※ 久しぶりにRxSwiftを利用した実装をしたので色々と至らない点もあるかと思いますが、「もっとこうした方が良い」というご意見があったり「この実装はあまりよろしくない」等のご意見等が御座いましたらIssueやPullRequest等をお送り頂けますと幸いです!

補足資料に関して:

今回の内容につきましては、potatotips #57 (iOS/Android開発Tips共有会)にて登壇の際に利用した資料を下記のリンクにて共有しておりますので、こちらも是非ご活用頂けますと幸いです。

2. 今回のサンプル概要について

今回のサンプルに関してはAPI通信ないしはXcodeプロジェクト内部に格納したJSONファイルから必要なデータを取得して、画面に表示するサンプルになります。基本的には下記のような形で 「View ⇄ ViewModel ⇄ Model」 という処理の流れを作りViewModelの状態変化や結果を元にして表示すべき画面の状態を組み立てていくような形になります。特に今回のサンプルについては、UIの構築や更新等を伴う処理が中心となるのでRxSwiftのObserverパターン・Driverパターンを積極的に活用していく形の実装になるかと思います。

mvvm.png

またこのサンプルにおけるUI実装に関しては、全体のレイアウトやアニメーション表現に関わる部分で美しい表現でありながらも、今回のサンプル実装以外での応用した活用やカスタマイズがしやすそうなUIライブラリをいくつか利用しています。RxSwiftの処理とは合わせていない、別途UIライブラリを利用した要素のデザインや配置位置の決定といった初期設定に関する処理を行なっている部分がある点にはご注意下さい。

サンプルのキャプチャ画像その1:

capture1.png

サンプルのキャプチャ画像その2:

capture2.png

環境やバージョンについて:

  • Xcode12.3
  • Swift5.3
  • MacOS Big Sur (Ver11.1)

使用したDeveloper API:

今回のサンプルではTOP画面やフリーワード記事検索画面のニュース記事表示に関しては、「New York Times」の公開APIの中でも記事を検索するための 「Article Search API」 を利用しております。このAPIの利用方法やリクエスト・レスポンス等の詳細は下記のリンクをご参考にして頂ければと思います。

※ すべて英語のドキュメントにはなりますが、API定義書をはじめ開発者が利用するための情報は結構充実している印象です。

使用ライブラリ:

(1) Rx関連処理を行うために必要なもの

ライブラリ名 ライブラリの機能概要
RxSwift & RxCocoa FRP (Functional Reactive Programming)を実現するためのライブラリ

(2) APIへの非同期通信とJSONの解析を行うために必要なもの

ライブラリ名 ライブラリの機能概要
SwiftyJSON JSONデータの解析用ライブラリ
Alamofire HTTPないしはHTTPSのネットワーク通信用のライブラリ

(3) UI表現をするために必要なもの

ライブラリ名 ライブラリの機能概要
Floaty Androidの様なフローティングメニューを実現するためのUIライブラリ
DeckTransition Apple Musicの様なハーフモーダル表示を実現するためのUIライブラリ
AnimatedCollectionViewLayout UICollectionViewを動かす際に様々な動きをつけるためのUIライブラリ
FontAwesome.swift 「Font Awesome」アイコンを利用するためのライブラリ
BTNavigationDropdownMenu UINavigationBarにドロップダウンメニューを表示するためのUIライブラリ
Toast-Swift Androidの様なToast型のポップアップ表示をするためのUIライブラリ

Podfile内の設定は下記のようになります。今回はAPI通信やJSONの解析処理に関してもそれぞれライブラリを用いておりますが、実際に活用する際にはURLSessionやCodableといった、予めiOS側で用意されているAPIを利用するものを利用する形で収めた方がよりベターな実装ができるのではないかと思います。

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'RxSwiftUIExample' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!

  # Reactive Framework
  pod 'RxSwift'
  pod 'RxCocoa'

  # Utility
  pod 'SwiftyJSON'
  pod 'Alamofire'

  # UI
  pod 'Floaty'
  pod 'DeckTransition', '~> 2.0'
  pod 'AnimatedCollectionViewLayout'
  pod 'FontAwesome.swift'
  pod 'BTNavigationDropdownMenu'
  pod 'Toast-Swift', '~> 4.0.0'
end

今回紹介したUI関連の処理を行うライブラリについては、Swift4.2に対応かつライブラリのREADMEにリファレンス等が記載されていたり、実装サンプルも一緒に収録されているものがありますので、この記事で紹介した実装以外でのUI実装においても十分に活用できると思います。

Storyboardの構成:

一番最初に表示されるTOP画面については、一番上に配置しているカルーセル型のサムネイル画像表示部分と最新ニュースを表示する部分についてはそれぞれContainerViewで画面を切り分けておき、ContainerViewの高さを変更する場合等、何らかの処理結果を親のViewControllerへ伝える必要がある場合には子のViewControllerに定義したプロトコルを活用しています。
また、TOP画面のおおもととなるMainViewController.swiftに対応する画面の方にフローティングメニューに関連する処理や最新のニュースを表示しているContainerViewの高さ調整処理を記載しています。

storyboard.png

3. RxSwift + MVVMパターンを利用した実装や画面構成に関する解説

まずはAPIからのデータの取得処理を伴わない、プロジェクト内のJSONファイルから画面表示の内容を組み立てていく処理に関する実装部分の解説になります。この部分に関してポイントとなる部分は、ViewModel内のプロパティの状態変化とUIの表示変化が結びつく形で実装する点にあるかと思います。このサンプル内で紹介しているコードは比較的シンプルなものにはなりますが、UI状態の細かな変化や振る舞いに関しても考慮ができる様な形に予め実装しておくことで、今後ViewModel内で取りうる状態や表示に関するUI要素が増えた際にもある程度柔軟に対処できる様にしておくと良いかと思います。

※ 以降の処理で利用するJSONファイルの形式に関しては下記の様な形式になっています。

// ID(Int型), タイトルや概要等の画面に表示する内容(String型), アセットに予め追加しているファイル名(String型)をまとめた要素を配列にしています。
[
    {
        "id": 1,
        "title": "タイトルや概要が入ります。",
        "image_name": "アセットに追加している画像名が入ります。"
    },
    ・・・(省略)・・・
]

★3-1. カルーセル状のサムネイル画像表示する画面の処理に関する解説

まずは、カルーセル状のサムネイル画像表示する画面の処理に関して見ていきましょう。基本的な構成としてはカルーセル型表示をするためにUICollectionViewをベースにして構築します。サムネイル画像の表示切り替えについては左右に配置したボタンで行うものとし、現在表示しているサムネイルに紐づくインデックス値が最大値・最小値に該当する場合には表示状態についても考慮できる様な構成にします。

キューブ状に配置したサムネイル画像を回転させる表現については、「ライブラリ: AnimatedCollectionViewLayout」 を利用しています。(ライブラリ導入や活用方法に関しては後述するコードやライブラリに記載されているREADMEを参考にして頂ければと思います。)

carousel.png

  • Model: 取得したJSONを定義した型に該当する形にする。初期化時のJSONの解析処理についてはDecodableプロトコルを適用しています。

  • ViewModel: 初期化の際にJSONから作成したデータをObservable<[FeaturedModel]>型のデータとして保持しておき、UICollectionViewと紐づけられる形にする。また現在の表示しているインデックス値やボタンの表示・非表示に関するステータスをBehaviorRelay<Int>型またはBehaviorRelay<Bool>型でイベントを流せる形にしておくと共に、これらの値を更新するためのメソッドを定義しています。

  • ViewController: UIライブラリの初期設定やUI更新に関連する処理を適当な処理粒度でprivateなメソッドに切り出しておきます。viewDidLoadでは、RxSwiftを利用してViewModel内で定義しているViewController側で利用するためのプロパティとバインドさせることで、この値の変化に応じたUIの状態が更新される様にしています。

(1) Model部分のコードに関して:

FeaturedModel.swift
import Foundation

// MEMO: こちらのデータはJSONから生成する
struct FeaturedModel: Decodable {

    let id: Int
    let title: String
    let imageName: String

    private enum Keys: String, CodingKey {
        case id
        case title
        case imageName = "image_name"
    }

    // MARK: - Initializer

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id        = try container.decode(Int.self, forKey: .id)
        self.title     = try container.decode(String.self, forKey: .title)
        self.imageName = try container.decode(String.self, forKey: .imageName)
    }
}

(2) ViewModelModel部分のコードに関して:

FeaturedViewModel.swift
import Foundation
import RxSwift
import RxCocoa

class FeaturedViewModel {

    // 内部で利用するためのプロパティ
    private let featuredModelMaxCount: Int!

    // ViewController側で利用するためのプロパティ
    let featuredLists: Observable<[FeaturedModel]>!
    let shouldHidePreviousButton = BehaviorRelay<Bool>(value: true)
    let shouldHideNextButton = BehaviorRelay<Bool>(value: false)
    let currentIndex = BehaviorRelay<Int>(value: 0)

    // MARK: - Initializer

    init(data: Data) {

        // JSONファイルから表示用のデータを取得してFeaturedModelの型に合致するようにする
        let featuredModels = try! JSONDecoder().decode([FeaturedModel].self, from: data)

        // 表示用のデータの個数を反映する
        featuredModelMaxCount = featuredModels.count

        // 表示用のデータを反映する
        featuredLists = Observable<[FeaturedModel]>.just(featuredModels)
    }

    // MARK: - Function

    // 現在表示すべきインデックス値を変更する
    func updateCurrentIndex(isIncrement: Bool = true) {

        // 現在のcurrentIndex.valueに対して「+1」または「-1」を行う
        let targetIndex = adjustNewIndex(isIncrement: isIncrement)

        // 関連するプロパティの値を更新する
        shouldHidePreviousButton.accept((targetIndex == 0))
        shouldHideNextButton.accept((targetIndex == featuredModelMaxCount - 1))
        currentIndex.accept(targetIndex)
    }

    // MARK: - Private Function

    private func adjustNewIndex(isIncrement: Bool = true) -> Int {
        let newIndex = isIncrement ? currentIndex.value + 1 : currentIndex.value - 1
        if newIndex > featuredModelMaxCount - 1 {
            return featuredModelMaxCount - 1
        } else if newIndex < 0 {
            return 0
        } else {
            return newIndex
        }
    }
}

(3) ViewController部分のコードに関して:

FeaturedViewController.swift
import UIKit
import AnimatedCollectionViewLayout

import RxSwift
import RxCocoa

class FeaturedViewController: UIViewController {

    private let disposeBag = DisposeBag()

    @IBOutlet weak private var featuredCollectionView: UICollectionView!
    @IBOutlet weak private var previousButton: UIButton!
    @IBOutlet weak private var nextButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()

        // UIまわりの初期設定
        setupUserInterface()

        // ViewModelの初期化
        let featuredViewModel = FeaturedViewModel(data: getDataFromJSONFile())

        // RxSwiftでのUICollectionViewDelegateの宣言
        featuredCollectionView.rx.setDelegate(self).disposed(by: disposeBag)

        // 次へボタンを押下した場合の処理
        nextButton.rx.tap.asDriver().drive(onNext: { _ in
            featuredViewModel.updateCurrentIndex(isIncrement: true)
        }).disposed(by: disposeBag)

        // 前へボタンを押下した場合の処理
        previousButton.rx.tap.asDriver().drive(onNext: { _ in
            featuredViewModel.updateCurrentIndex(isIncrement: false)
        }).disposed(by: disposeBag)

        // 一覧データをUICollectionViewにセットする処理
        featuredViewModel.featuredLists.bind(to: featuredCollectionView.rx.items) { (collectionView, row, model) in
            let cell = collectionView.dequeueReusableCustomCell(with: FeaturedCollectionViewCell.self, indexPath: IndexPath(row: row, section: 0))
            cell.setCell(model)
            return cell
        }.disposed(by: disposeBag)

        // 現在のインデックス値が変更された場合の処理
        featuredViewModel.currentIndex.asDriver().drive(onNext: { [weak self] in
            self?.featuredCollectionView.scrollToItem(at: IndexPath(row: $0, section: 0), at: .centeredHorizontally, animated: true)
        }).disposed(by: disposeBag)

        // 次へボタンの表示状態を決定する
        featuredViewModel.shouldHideNextButton.asDriver().drive(onNext: { [weak self] in
            self?.nextButton.isHidden = $0
        }).disposed(by: disposeBag)

        // 前へボタンの表示状態を決定する
        featuredViewModel.shouldHidePreviousButton.asDriver().drive(onNext: { [weak self] in
            self?.previousButton.isHidden = $0
        }).disposed(by: disposeBag)
    }

    // MARK: - Private Function

    private func setupUserInterface() {
        setupFeaturedCollectionView()
    }

    private func setupFeaturedCollectionView() {

        // UICollectionViewに関する初期設定
        featuredCollectionView.isScrollEnabled = false
        featuredCollectionView.showsHorizontalScrollIndicator = false
        featuredCollectionView.registerCustomCell(FeaturedCollectionViewCell.self)

        // UICollectionViewに付与するアニメーションに関する設定
        let layout = AnimatedCollectionViewLayout()
        layout.animator = CubeAttributesAnimator()
        layout.scrollDirection = .horizontal
        featuredCollectionView.collectionViewLayout = layout
    }

    // JSONファイルで定義されたデータを読み込んでData型で返す
    private func getDataFromJSONFile() -> Data {
        if let path = Bundle.main.path(forResource: "featured_datasources", ofType: "json") {
            return try! Data(contentsOf: URL(fileURLWithPath: path))
        } else {
            fatalError("Invalid json format or existence of file.")
        }
    }
}

// MARK: - UICollectionViewDelegateFlowLayout

extension FeaturedViewController: UICollectionViewDelegateFlowLayout {

    // タブ用のセルにおける矩形サイズを設定する
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return FeaturedCollectionViewCell.cellSize
    }
}

(4) ViewModelとUI部分の結び付きについて:

ViewModel内のプロパティの変化に紐づく画面UIの変化におけるお互いの関係性を理解する上で、重要だと感じる部分をまとめたものを下記の図解にまとめておきました。

carousel_point.png

★3-2. ドロップダウンメニューと連動した記事切り替えを伴う画面の処理に関する解説

次に、ドロップダウンメニューと連動した記事切り替えを伴う画面の処理に関して見ていきましょう。基本的な構成としてはUIScrollViewで表示している内容を、タイトル部分を押下することで表示されるドロップダウンメニューから該当の内容へ切り替えるような形になります。

※補足: 画面上部のサムネイル画像がパララックス表示している部分についてはUIScrollViewDelegateを利用し、今回はRxSwiftでの処理とは切り分けています。

ドロップダウンメニューから画面の表示を切り替える処理やUI表現については、「ライブラリ: BTNavigationDropdownMenu」 を利用しています。(ライブラリ導入や活用方法に関しては後述するコードやライブラリに記載されているREADMEを参考にして頂ければと思います。)

dropdown.png

  • Model: 取得したJSONを定義した型に該当する形にする。初期化時のJSONの解析処理についてはDecodableプロトコルを適用しています。

  • ViewModel: 初期化の際にJSONから作成したデータを別途privateなプロパティで保持しておき、ViewController側で利用するタイトルの一覧をObservable<[String]>型のデータを作成してドロップダウンメニューの設定の際に利用できるようにする。またドロップダウンメニューの選択処理で該当のデータを格納する変数selectedInformationBehaviorRelay<InformationModel?>型でイベントを流せる形にしておくと共に、これらの値を更新するためのメソッドを定義しています。

  • ViewController: UIライブラリの初期設定やUI更新に関連する処理を適当な処理粒度でprivateなメソッドに切り出しておきます。viewDidLoadでは、RxSwiftを利用してViewModel内で定義しているViewController側で利用するためのプロパティとバインドさせることで、この値の変化に応じたUIの状態が更新される様にしています。

(1) Model部分のコードに関して:

InformationModel.swift
import Foundation

// MEMO: こちらのデータはJSONから生成する
struct InformationModel: Decodable {

    let id: Int
    let title: String
    let summary: String
    let imageName: String

    private enum Keys: String, CodingKey {
        case id
        case title
        case summary
        case imageName = "image_name"
    }

    // MARK: - Initializer

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id        = try container.decode(Int.self, forKey: .id)
        self.title     = try container.decode(String.self, forKey: .title)
        self.summary   = try container.decode(String.self, forKey: .summary)
        self.imageName = try container.decode(String.self, forKey: .imageName)
    }
}

(2) ViewModelModel部分のコードに関して:

InformationViewModel.swift
import Foundation
import RxSwift
import RxCocoa

class InformationViewModel {

    // 内部で利用するためのプロパティ
    private let informationModelMaxCount: Int!
    private let informationLists: [InformationModel]!

    // ViewController側で利用するためのプロパティ
    let allTitles: Observable<[String]>!
    let selectedInformation = BehaviorRelay<InformationModel?>(value: nil)

    // MARK: - Initializer

    init(data: Data) {

        // JSONファイルから表示用のデータを取得してInformationModelの型に合致するようにする
        informationLists = try! JSONDecoder().decode([InformationModel].self, from: data)

        // タイトルの一覧を取得する
        allTitles = Observable<[String]>.just(informationLists.compactMap{ return $0.title })

        // 表示用のデータの個数を反映する
        informationModelMaxCount = informationLists.count

        // 最初に表示するのInformationModel要素を反映する
        selectedInformation.accept(informationLists.first)
    }

    // MARK: - Function

    // 表示したいインデックス値に該当する値(informationLists)を選択状態にする
    func switchSelectedInformation(indexPath: Int) {
        let targetIndex = adjustIndexPath(indexPath: indexPath)
        selectedInformation.accept(informationLists[targetIndex])
    }

    // MARK: - Private Function

    private func adjustIndexPath(indexPath: Int) -> Int {
        if 0...informationModelMaxCount - 1 ~= indexPath {
            return indexPath
        } else {
            return 0
        }
    }
}

(3) ViewController部分のコードに関して:

InformationViewController.swift
import UIKit
import BTNavigationDropdownMenu

import RxSwift
import RxCocoa

class InformationViewController: UIViewController {

    private let originalInformationTopImageHeight: CGFloat = 240
    private let disposeBag = DisposeBag()

    private var menuView: BTNavigationDropdownMenu!

    @IBOutlet weak private var informationScrollView: UIScrollView!
    @IBOutlet weak private var informationTopImageView: UIImageView!
    @IBOutlet weak private var informationTitleLabel: UILabel!
    @IBOutlet weak private var informationSummaryLabel: UILabel!

    // TOP画像において変更対象となるAutoLayoutの制約値
    @IBOutlet private weak var informationTopImageHeightConstraint: NSLayoutConstraint!
    @IBOutlet private weak var informationTopImageTopConstraint: NSLayoutConstraint!

    override func viewDidLoad() {
        super.viewDidLoad()

        // UIまわりの初期設定
        setupUserInterface()

        // ViewModelの初期化
        let informationViewModel = InformationViewModel(data: getDataFromJSONFile())

        // ドロップダウンメニューの初期化をする処理
        informationViewModel.allTitles.subscribe(onNext: { [weak self] in
            let targetTitles = $0.map{$0}
            self?.initializeDropDownMenuDataLists(targetViewModel: informationViewModel, targetTitles: targetTitles)
            self?.initializeDropDownMenuDecoration()
        }).disposed(by: disposeBag)

        // 選択された情報を表示する処理
        informationViewModel.selectedInformation.asDriver().drive(onNext: { [weak self] in
            self?.informationScrollView.setContentOffset(CGPoint.zero, animated: true)
            self?.displayInformation(targetModel: $0)
        }).disposed(by: disposeBag)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewDidAppear(animated)

        // メニューを表示した状態から前の画面へ戻る場合に対する考慮をする
        menuView.hide()
    }

    // MARK: - Private Function

    private func setupUserInterface() {
        setupNavigationBar(title: "")
        setupInformationScrollView()
        setupInformationTopImageView()
    }

    private func setupInformationScrollView() {

        // UIScrollViewに関する設定をする
        // NavigationBar分のスクロール位置がずれてしまわないようにする考慮は下記の通り:
        // 考慮する項目1. Information.storyboardにおいて「Adjust Scroll View Insets」のチェックを外す
        // 考慮する項目2. informationScrollViewのTopのAutoLayoutを「Information Scroll View.top = SafeArea.top」とする
        informationScrollView.delegate = self
    }

    private func setupInformationTopImageView() {

        // 初期状態時のトップ画像の高さや拡大モード等を設定する
        informationTopImageView.contentMode = .scaleAspectFill
        informationTopImageHeightConstraint.constant = originalInformationTopImageHeight
    }

    // ドロップダウンメニューに関する初期設定をする
    private func initializeDropDownMenuDataLists(targetViewModel: InformationViewModel, targetTitles: [String]) {

        // ドロップダウンメニューに関して必要な初期設定をする(リスト表示の部分でViewModelを利用する)
        menuView = BTNavigationDropdownMenu(navigationController: self.navigationController, containerView: self.navigationController!.view, title: BTTitle.index(0), items: targetTitles)
        self.navigationItem.titleView = menuView

        // ドロップダウンメニュー内のセルをタップした際は該当の情報を表示するためのViewModel側のメソッドを実行する
        menuView.didSelectItemAtIndexHandler = { (indexPath: Int) -> Void in
            targetViewModel.switchSelectedInformation(indexPath: indexPath)
        }
    }

    // ドロップダウンメニューに関するデザイン設定をする
    private func initializeDropDownMenuDecoration() {

        // 参考: セルの要素に関する設定
        menuView.cellHeight = 58
        menuView.cellBackgroundColor = .white
        menuView.cellSeparatorColor = UIColor(code: "#ccccc3")
        menuView.cellSelectionColor = UIColor(code: "#f7f7f7")
        menuView.cellTextLabelColor = .gray
        menuView.cellTextLabelFont = UIFont(name: AppConstant.COMMON_FONT_BOLD, size: AppConstant.COMMON_DROPDOWN_MENU_FONT_SIZE)
        menuView.cellTextLabelAlignment = .left
        menuView.shouldKeepSelectedCellColor = true

        // 参考: セルのアイコン表示に関する設定
        menuView.arrowPadding = 15
        menuView.checkMarkImage
            = UIImage.fontAwesomeIcon(name: .checkCircle, style: .solid, textColor: .gray, size: CGSize(width: 16.0, height: 16.0))

        // 参考: ナビゲーションバーのタイトル表示に関する設定
        menuView.navigationBarTitleFont = UIFont(name: AppConstant.COMMON_FONT_BOLD, size: AppConstant.COMMON_NAVIGATION_FONT_SIZE)

        // 参考: ドロップダウンメニュー表示に関する設定
        menuView.animationDuration = 0.24
        menuView.maskBackgroundColor = .black
        menuView.maskBackgroundOpacity = 0.72
    }

    // 受け取ったInformationModelの情報を表示する
    private func displayInformation(targetModel: InformationModel?) {
        if let model = targetModel {

            informationTopImageView.image = UIImage(named: model.imageName)
            informationTitleLabel.text = model.title

            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.lineSpacing = 6
            var attributes = [NSAttributedString.Key : Any]()
            attributes[NSAttributedString.Key.paragraphStyle] = paragraphStyle
            attributes[NSAttributedString.Key.font] = UIFont(name: AppConstant.COMMON_FONT_NORMAL, size: 14.0)
            attributes[NSAttributedString.Key.foregroundColor] = UIColor(code: "#333333")
            informationSummaryLabel.attributedText = NSAttributedString(string: model.summary, attributes: attributes)
        }
    }

    // JSONファイルで定義されたデータを読み込んでData型で返す
    private func getDataFromJSONFile() -> Data {
        if let path = Bundle.main.path(forResource: "information_datasources", ofType: "json") {
            return try! Data(contentsOf: URL(fileURLWithPath: path))
        } else {
            fatalError("Invalid json format or existence of file.")
        }
    }
}

// MARK: - UIScrollViewDelegate

extension InformationViewController: UIScrollViewDelegate {

    // スクロールが実行された際にトップ画像に視差効果を付与する
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        informationTopImageTopConstraint.constant = min(scrollView.contentOffset.y, 0)
        informationTopImageHeightConstraint.constant = max(0, originalInformationTopImageHeight - scrollView.contentOffset.y)
    }
}

(4) ViewModelとUI部分の結び付きについて:

ViewModel内のプロパティの変化に紐づく画面UIの変化におけるお互いの関係性を理解する上で、重要だと感じる部分をまとめたものを下記の図解にまとめておきました。

dropdown_point.png

上記で紹介した2つの実装におけるポイントとしては、ViewModel内のプロパティの変化を監視できる状態にすることで、値が新たに代入されるまたは変更される際に該当のUI要素に関する処理を実行するような形で仕込んでおく点になります。画面表示用のViewControllerクラスにおけるviewDidLoad内の処理を、RxSwift利用することでViewModelが基準となるような形にすることや、ボタン等の変化アクションとViewModel内のメソッド実行をうまく結びつけることで、「この値が変化する事で何が起こるか」 の見通しがよくすることもできる様に思います。

もし、ViewModelの状態が取り得るパターンが多岐に渡るような場合は、別途Enum等に状態を定義しておく等の工夫を施す等して各々の状態が整理できるような形にしておくとより良いかもしれませんね。

4. APIからのデータ取得処理を組み合わせたRxSwift + MVVMパターンを利用した実装に関する解説

次にAPI通信を伴う処理と組み合わせた処理に関する部分における部分についての解説になります。今回のサンプルでAPI通信を利用する処理に関してはさほど複雑なものはありませんが、成功時の処理はもちろん、失敗時(API通信の結果エラーが生じた場合)にはViewControllerにエラーが発生したことを伝えるAlertを表示する考慮までを最低限ではありますが実装しています。

★4-1. APIからのデータ取得処理をする部分のクラスに関する解説

今回の実装では該当のエンドポイントにアクセスして通信のステータスに応じた処理を行うためのクラスを下記のような形で別途用意しています。API通信の結果をハンドリングする部分においては、Single<JSON>とすることで、成功か失敗かのいずれかのイベントを1度だけ流すことを保証する形にしています。

NewYorkTimesProductionAPI.swif
import Foundation
import Alamofire
import SwiftyJSON

import RxSwift
import RxCocoa

class NewYorkTimesProductionAPI: NewYorkTimesAPI {

    private let manager = AF
    private let baseUrl = "https://api.nytimes.com/svc/search/v2/articlesearch.json"
    private let key = AppConstant.NEWYORKTIMES_API_KEY

    // MARK: - Functions

    // NewYorkTimesの最新ニュース一覧を取得する
    func getRecentNewsList(page: Int = 0) -> Single<JSON> {

        // APIにリクエストする際に必要なパラメーターを定義する
        let parameters: [String : Any] = [
            "api-key" : key,
            "sort"    : "newest",
            "fl"      : "web_url,pub_date,headline,byline",
            "page"    : page
        ]

        // APIへのリクエストを1度だけ送信して結果に応じた処理をする
        return Single<JSON>.create(subscribe: { singleEvent in
            self.manager.request(self.baseUrl, method: .get, parameters: parameters).validate().responseJSON { response in
                switch response.result {

                // APIからのレスポンスの取得成功時
                case .success(let response):
                    let res = JSON(response)
                    let json = res["response"]["docs"]
                    singleEvent(.success(json))

                // APIからのレスポンスの取得失敗時
                case .failure(let error):
                    singleEvent(.failure(error))
                }
            }
            return Disposables.create()
        })
    }

    // キーワードを元にNewYorkTimesの検索結果に紐づくニュース一覧を取得する
    func getSearchNewsList(keyword: String) -> Single<JSON> {

        // APIにリクエストする際に必要なパラメーターを定義する
        let parameters: [String : Any] = [
            "api-key" : key,
            "sort"    : "newest",
            "fl"      : "web_url,snippet,headline",
            "q"       : keyword,
        ]

        // APIへのリクエストを1度だけ送信して結果に応じた処理をする
        return Single<JSON>.create(subscribe: { singleEvent in
            self.manager.request(self.baseUrl, method: .get, parameters: parameters).validate().responseJSON { response in
                switch response.result {

                // APIからのレスポンスの取得成功時
                case .success(let response):
                    let res = JSON(response)
                    let json = res["response"]["docs"]
                    singleEvent(.success(json))

                // APIからのレスポンスの取得失敗時
                case .failure(let error):
                    singleEvent(.failure(error))
                }
            }
            return Disposables.create()
        })
    }
}

★4-2. ページネーションを伴って最新のニュースデータから10件ずつ取得して表示する処理に関する解説

この部分で実装している処理の概要に関するイメージ図は下記のような形になるかと思います。

api_request_1.png

  • Model: 取得したJSONを定義した型に該当する形にする。初期化時のJSONの解析処理についてはレスポンスが複雑だったのでSwiftyJSONを利用しています。

  • ViewModel: 初期化の際には前述のNewYorkTimesProductionAPI.swiftのインスタンスを渡す様にする。ViewController側でgetRecentNews()メソッドを実行すると、成功時にはBehaviorRelay<[RecentNewsModel]>型の変数recentNewsListsにデータを格納する形にする。また「最初の10件を取得 → 次の10件を取得 → ...」と取得して表示する動きを実現できるような形にしています。(APIでのデータ取得処理結果や状態に関するプロパティについてもBehaviorRelay<[Bool]>型で定義しています。)

  • ViewController: UIライブラリの初期設定やUI更新に関連する処理を適当な処理粒度でprivateなメソッドに切り出しておきます。viewDidLoadでは、RxSwiftを利用してViewModel内で定義しているViewController側で利用するためのプロパティとバインドさせることで、この値の変化に応じたUIの状態が更新される様にすると共に、MainViewController.swiftに配置しているRecentNewsViewController.swiftを表示しているContainerViewの高さをRecentNewsViewControllerDelegateを利用して調整しています。

(1) Model部分のコードに関して:

RecentNewsModel.swift
import Foundation
import SwiftyJSON

// MEMO: New York Timesの公開APIのレスポンスが複雑なのでJSONの解析にはSwiftyJSONを利用している
struct RecentNewsModel {

    let newsTitle: String
    let newsWebUrlString: String
    let newsByLine: String
    let newsDate: String

    init(json: JSON) {

        // New York Timesの公開APIから必要なものを取得した上で初期化処理を行う
        // 確認URL: http://developer.nytimes.com/article_search_v2.json#/Console/GET/articlesearch.json
        self.newsTitle        = json["headline"]["main"].string       ?? ""
        self.newsWebUrlString = json["web_url"].string                ?? ""
        self.newsByLine       = json["byline"]["organization"].string ?? "--"

        // 日付についてはIOS8601形式の文字列を変換して初期化処理を行う
        if let newsDate = json["pub_date"].string {
            self.newsDate = NewsDateFormatter.getDateStringFromAPI(apiDateString: newsDate)
        } else {
            self.newsDate = "--"
        }
    }
}

(2) ViewModelModel部分のコードに関して:

RecentNewsViewModel.swift
import Foundation
import SwiftyJSON

import RxSwift
import RxCocoa

class RecentNewsViewModel {

    private let newYorkTimesAPI: NewYorkTimesAPI!
    private let disposeBag = DisposeBag()

    private var targetPage = 0

    // ViewController側で利用するためのプロパティ
    let isLoading = BehaviorRelay<Bool>(value: false)
    let isError = BehaviorRelay<Bool>(value: false)
    let recentNewsLists = BehaviorRelay<[RecentNewsModel]>(value: [])

    // MARK: - Initializer

    init(api: NewYorkTimesAPI) {
        newYorkTimesAPI = api
    }

    // MARK: - Function

    func getRecentNews() {

        // リクエスト開始時の処理
        executeStartRequestAction()

        // ニュース記事のデータを取得する処理を実行する
        newYorkTimesAPI.getRecentNewsList(page: targetPage).subscribe(

            // JSON取得が成功した場合の処理
            onSuccess: { json in
                let targetNewsList = self.getRecentNewsModelListsBy(json: json)
                self.executeSuccessResponseAction(newList: targetNewsList)
            },

            // JSON取得が失敗した場合の処理
            onError: { error in
                self.executeErrorResponseAction()
                print("Error: ", error.localizedDescription)
            }

        ).disposed(by: disposeBag)
    }

    // MARK: - Private Function

    private func executeStartRequestAction() {
        isLoading.accept(true)
        isError.accept(false)
    }

    private func executeSuccessResponseAction(newList: [RecentNewsModel]) {
        recentNewsLists.accept(recentNewsLists.value + newList)
        targetPage += 1
        isLoading.accept(false)
    }

    private func executeErrorResponseAction() {
        isError.accept(true)
        isLoading.accept(false)
    }

    // レスポンスで受け取ったJSONから表示に必要なものを詰め直す
    private func getRecentNewsModelListsBy(json: JSON) -> [RecentNewsModel] {
        return json.map{ RecentNewsModel(json: $0.1) }
    }
}

(3) ViewController部分のコードに関して:

RecentNewsViewController.swift
import UIKit
import DeckTransition

import RxSwift
import RxCocoa

protocol RecentNewsViewControllerDelegate: NSObjectProtocol {

    // このViewControllerを表示するためのContainerViewの高さを更新する
    func updateContainerViewHeight(_ height: CGFloat)
}

class RecentNewsViewController: UIViewController {

    private let disposeBag = DisposeBag()

    // RecentNewsViewControllerDelegateの宣言
    weak var delegate: RecentNewsViewControllerDelegate?

    @IBOutlet weak private var recentNewsTableView: UITableView!
    @IBOutlet weak private var showNextPageButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()

        // UIまわりの初期設定
        setupUserInterface()

        // ViewModelの初期化
        let recentNewsViewModel = RecentNewsViewModel(api: NewYorkTimesProductionAPI())

        // 初回表示分のニュースを取得する
        recentNewsViewModel.getRecentNews()

        // 次の10件を表示するボタンを押下した場合の処理
        showNextPageButton.rx.tap.asDriver().drive(onNext: { _ in
            recentNewsViewModel.getRecentNews()
        }).disposed(by: disposeBag)

        // UITableViewに配置されたセルをタップした場合の処理
        recentNewsTableView.rx.itemSelected.subscribe(onNext: { [weak self] indexPath in
            let recentNews = recentNewsViewModel.recentNewsLists.value[indexPath.row]
            self?.showNewsWebPage(newsUrlString: recentNews.newsWebUrlString)
        }).disposed(by: disposeBag)

        // 一覧データをUITableViewにセットする処理
        recentNewsViewModel.recentNewsLists.asObservable().bind(to: recentNewsTableView.rx.items) { (tableView, row, model) in
            let cell = tableView.dequeueReusableCustomCell(with: RecentNewsTableViewCell.self)
            cell.setCell(model)
            return cell
        }.disposed(by: disposeBag)

        // 一覧データが追加された場合の処理
        recentNewsViewModel.recentNewsLists.asDriver().drive(onNext: { [weak self] in
            self?.updateRecentNewsTableViewHeightBy(dataCount: $0.count)
        }).disposed(by: disposeBag)

        // 読み込み状態が更新された場合の処理
        recentNewsViewModel.isLoading.asDriver().drive(onNext: { [weak self] in
            self?.updateshowNextPageButtonStatusBy(result: $0)
        }).disposed(by: disposeBag)

        // エラー状態が更新された場合の処理
        recentNewsViewModel.isError.asDriver().drive(onNext: { [weak self] in
            self?.showResponseErrorAlert(result: $0)
        }).disposed(by: disposeBag)
    }

    // MARK: - Private Function

    private func setupUserInterface() {
        setupRecentNewsTableView()
    }

    private func setupRecentNewsTableView() {
        recentNewsTableView.rowHeight = RecentNewsTableViewCell.cellHeight
        recentNewsTableView.delaysContentTouches = false
        recentNewsTableView.registerCustomCell(RecentNewsTableViewCell.self)
    }

    // 読み込みボタンの状態を更新する処理
    private func updateshowNextPageButtonStatusBy(result: Bool) {
        let buttonText = result ? "Now Loading ..." : "↓ More Next 10 News"
        self.showNextPageButton.setTitle(buttonText, for: .normal)
        self.showNextPageButton.isEnabled = !result
        self.showNextPageButton.alpha = result ? 0.3 : 1
    }

    // 親のViewControllerでContainerViewの高さ制約を更新する処理
    private func updateRecentNewsTableViewHeightBy(dataCount: Int) {
        let showNextPageButtonHeight = CGFloat(48.0)
        let allCellsHeight = CGFloat(dataCount) * RecentNewsTableViewCell.cellHeight
        let containerViewHeight = allCellsHeight + showNextPageButtonHeight
        self.delegate?.updateContainerViewHeight(containerViewHeight)
    }

    // ニュースの詳細をWebviewで表示する処理
    private func showNewsWebPage(newsUrlString: String) {
        let sb = UIStoryboard(name: "NewsWebPage", bundle: nil)
        let vc = sb.instantiateInitialViewController() as! NewsWebPageViewController
        let delegate = DeckTransitioningDelegate()
        vc.setSelectedNewsUrlString(targetNewsUrlString: newsUrlString)
        vc.transitioningDelegate = delegate
        vc.modalPresentationStyle = .custom
        self.present(vc, animated: true, completion: nil)
    }

    // エラー時のアラートを表示する処理
    private func showResponseErrorAlert(result: Bool) {
        if result {
            let errorTitle = "Error Occured!"
            let errorMessage = "New York Times API Response Error. Please try again."
            showAlertWith(title: errorTitle, message: errorMessage)
        }
    }

    private func showAlertWith(title: String, message: String, completionHandler: (() -> ())? = nil) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default, handler: { _ in
            completionHandler?()
        })
        alert.addAction(okAction)
        self.present(alert, animated: true, completion: nil)
    }
}

★4-3. フリーワードに該当する記事をインクリメンタルサーチで10件取得して表示する処理に関する解説

この部分で実装している処理の概要に関するイメージ図は下記のような形になるかと思います。

api_request_2.png

  • Model: 取得したJSONを定義した型に該当する形にする。初期化時のJSONの解析処理についてはレスポンスが複雑だったのでSwiftyJSONを利用しています。

  • ViewModel: 初期化の際には前述のNewYorkTimesProductionAPI.swiftのインスタンスを渡す様にする。ViewController側でgetSearchNews(keyword: String)メソッドを実行すると、成功時にはBehaviorRelay<[SearchNewsModel]>型の変数searchNewsListsに検索文字列に合致する最大10件のデータを格納する形にしています。(APIでのデータ取得処理結果や状態に関するプロパティについてもBehaviorRelay<[Bool]>型で定義しています。)

  • ViewController: UIライブラリの初期設定やUI更新に関連する処理を適当な処理粒度でprivateなメソッドに切り出しておきます。viewDidLoadでは、RxSwiftを利用してViewModel内で定義しているViewController側で利用するためのプロパティとバインドさせることで、この値の変化に応じたUIの状態が更新される様にすると共に、変数searchBarTextの文字列長さが3未満の場合にはgetSearchNews(keyword: String)メソッド実行しない様な考慮をしています。(別途UIまわりの処理で必要なUISearchBarDelegateUIGestureRecognizerDelegateについても記載しています。)

(1) Model部分のコードに関して:

SearchNewsModel.swift
import Foundation
import SwiftyJSON

// MEMO: New York Timesの公開APIのレスポンスが複雑なのでJSONの解析にはSwiftyJSONを利用している
struct SearchNewsModel {

    let newsTitle: String
    let newsWebUrlString: String
    let newsSnippet: String

    init(json: JSON) {

        // New York Timesの公開APIから必要なものを取得した上で初期化処理を行う
        // 確認URL: http://developer.nytimes.com/article_search_v2.json#/Console/GET/articlesearch.json
        self.newsTitle        = json["headline"]["main"].string ?? ""
        self.newsWebUrlString = json["web_url"].string          ?? ""
        self.newsSnippet      = json["snippet"].string          ?? "--"
    }
}

(2) ViewModelModel部分のコードに関して:

SearchNewsViewModel.swift
import Foundation
import SwiftyJSON

import RxSwift
import RxCocoa

class SearchNewsViewModel {

    private let newYorkTimesAPI: NewYorkTimesAPI!
    private let disposeBag = DisposeBag()

    let isLoading = BehaviorRelay<Bool>(value: false)
    let isError = BehaviorRelay<Bool>(value: false)
    let searchNewsLists = BehaviorRelay<[SearchNewsModel]>(value: [])

    // MARK: - Initializer

    init(api: NewYorkTimesAPI) {
        newYorkTimesAPI = api
    }

    // MARK: - Function

    func getSearchNews(keyword: String) {

        // リクエスト開始時の処理
        executeStartRequestAction()

        // ニュース記事のデータを取得する処理を実行する
        newYorkTimesAPI.getSearchNewsList(keyword: keyword).subscribe(

            // JSON取得が成功した場合の処理
            onSuccess: { json in
                let targetNewsList = self.getSearchNewsModelListsBy(json: json)
                self.executeSuccessResponseAction(newList: targetNewsList)
            },

            // JSON取得が失敗した場合の処理
            onError: { error in
                self.executeErrorResponseAction()
                print("Error: ", error.localizedDescription)
            }

        ).disposed(by: disposeBag)
    }

    // MARK: - Private Function

    private func executeStartRequestAction() {
        isLoading.accept(true)
        isError.accept(false)
    }

    private func executeSuccessResponseAction(newList: [SearchNewsModel]) {
        searchNewsLists.accept(newList)
        isLoading.accept(false)
    }

    private func executeErrorResponseAction() {
        isError.accept(true)
        isLoading.accept(false)
    }

    // レスポンスで受け取ったJSONから表示に必要なものを詰め直す
    private func getSearchNewsModelListsBy(json: JSON) -> [SearchNewsModel] {
        return json.map{ SearchNewsModel(json: $0.1) }
    }
}

(3) ViewController部分のコードに関して:

SearchViewController.swift
import UIKit
import DeckTransition

import RxSwift
import RxCocoa

class SearchViewController: UIViewController {

    private let disposeBag = DisposeBag()

    private var tapGestureRecognizer: UITapGestureRecognizer!
    private var keywordSearchBar: KeywordSearchBar!

    @IBOutlet weak private var searchTableView: UITableView!

    // 検索ボックスの値変化を監視対象にする(テキストが空っぽの場合はデータ取得を行わない)
    private var searchBarText: Observable<String> {

        // MEMO: 3文字未満のキーワードの場合は受け付けない & APIリクエストの際に0.5秒のバッファを持たせる
        return keywordSearchBar.rx.text
            .filter { $0 != nil }
            .map { $0! }
            .filter { $0.count >= 3 }
            .debounce(.milliseconds(500), scheduler: MainScheduler.instance)
            .distinctUntilChanged()

        // 疑問: 今回の様な形だとどっちが良いのだろうか...?
        // .debounce(.milliseconds(500), scheduler: MainScheduler.instance) vs .throttle(.milliseconds(500), scheduler: MainScheduler.instance)
        // https://qiita.com/dekatotoro/items/be22a241335382ecc16e
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // UIまわりの初期設定
        setupUserInterface()

        // ViewModelの初期化
        let searchNewsViewModel = SearchNewsViewModel(api: NewYorkTimesProductionAPI())

        // RxSwiftでのUICollectionViewDelegateの宣言
        searchTableView.rx.setDelegate(self).disposed(by: disposeBag)

        // UITableViewに配置されたセルをタップした場合の処理
        searchTableView.rx.itemSelected.subscribe(onNext: { [weak self] indexPath in
            let searchNews = searchNewsViewModel.searchNewsLists.value[indexPath.row]
            self?.showNewsWebPage(newsUrlString: searchNews.newsWebUrlString)
        }).disposed(by: disposeBag)

        // 一覧データをUITableViewにセットする処理
        searchNewsViewModel.searchNewsLists.asObservable().bind(to: searchTableView.rx.items) { (tableView, row, model) in
            let cell = tableView.dequeueReusableCustomCell(with: SearchNewsTableViewCell.self)
            cell.setCell(model)
            return cell
        }.disposed(by: disposeBag)

        // 読み込み状態が更新された場合の処理
        searchNewsViewModel.isLoading.asDriver().drive(onNext: { [weak self] in
            self?.searchTableView.isUserInteractionEnabled = !$0
        }).disposed(by: disposeBag)

        // エラー状態が更新された場合の処理
        searchNewsViewModel.isError.asDriver().drive(onNext: { [weak self] in
            self?.showResponseErrorAlert(result: $0)
        }).disposed(by: disposeBag)

        // 検索すべき入力テキストが決定された際に実行する
        searchBarText.subscribe(onNext: {
            searchNewsViewModel.getSearchNews(keyword: $0)
        }).disposed(by: disposeBag)
    }

    // MEMO: UISearchBarを継承したクラスをtitleViewへ追加した時はPop遷移で当該画面から戻る際に黒色のスペースが生じるのでこの対応をしています。
    // https://stackoverflow.com/a/47976999/4652214
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        navigationController?.view.setNeedsLayout()
        navigationController?.view.layoutIfNeeded()
    }

    // MARK: - Private Function

    // スクロールすると検索フォームのフォーカスを外す
    @objc private func searchBarUnforcus() {
        keywordSearchBar.resignFirstResponder()
    }

    private func setupUserInterface() {

        // MEMO: Viewの表示エリアをUINavigationBarの下まで伸ばす対応をしています。
        // → この設定がないとUITableViewの表示が一瞬だけガタンとなる感じになります。
        // 参考:
        // https://qiita.com/Yaruki00/items/1ca29e9f26578f33c80e
        self.extendedLayoutIncludesOpaqueBars = true

        setupNavigationBar(title: "")
        setupKeywordSearchBar()
        setupSearchTableView()
    }

    private func setupKeywordSearchBar() {

        // キーボード表示時にUITableViewのタップ処理をさせないためにUITapGestureRecognizerを作成する
        tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(searchBarUnforcus))
        tapGestureRecognizer.delegate = self

        // NavigationBarに設置するSearchBarを作成する
        keywordSearchBar = KeywordSearchBar()
        keywordSearchBar.placeholder = "Please input keyword."
        keywordSearchBar.delegate = self

        // titleViewプロパティにSearchBarを入れる
        self.navigationItem.titleView = keywordSearchBar
    }

    private func setupSearchTableView() {

        // UITableViewの初期設定をする
        searchTableView.rowHeight = 60.0
        searchTableView.registerCustomCell(SearchNewsTableViewCell.self)

        // StatusBarのタップによるスクロールを防止する
        searchTableView.scrollsToTop = false

        // ボタンのタップとスクロールの競合を防止する
        searchTableView.delaysContentTouches = false
    }

    // ニュースの詳細をWebviewで表示する処理
    private func showNewsWebPage(newsUrlString: String) {
        let sb = UIStoryboard(name: "NewsWebPage", bundle: nil)
        let vc = sb.instantiateInitialViewController() as! NewsWebPageViewController
        let delegate = DeckTransitioningDelegate()
        vc.setSelectedNewsUrlString(targetNewsUrlString: newsUrlString)
        vc.transitioningDelegate = delegate
        vc.modalPresentationStyle = .custom
        self.present(vc, animated: true, completion: nil)
    }

    // エラー時のアラートを表示する処理
    private func showResponseErrorAlert(result: Bool) {
        if result {
            let errorTitle = "Error Occured!"
            let errorMessage = "New York Times API Response Error. Please try again."
            showAlertWith(title: errorTitle, message: errorMessage)
        }
    }

    private func showAlertWith(title: String, message: String, completionHandler: (() -> ())? = nil) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default, handler: { _ in
            completionHandler?()
        })
        alert.addAction(okAction)
        self.present(alert, animated: true, completion: nil)
    }
}

// MARK: - UIScrollViewDelegate

extension SearchViewController: UIScrollViewDelegate {

    // UITableViewのスクロール処理を実行した場合にはSearchBarのフォーカスを外す
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        searchBarUnforcus()
    }
}

// MARK: - UIGestureRecognizerDelegate

extension SearchViewController : UIGestureRecognizerDelegate {

    // キーボード表示時にUITableViewのタップ処理をさせないためにUITapGestureRecognizerを有効にする
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        return true
    }
}

// MARK: - UISearchBarDelegate


extension SearchViewController: UISearchBarDelegate {

    // SearchBarでの入力を開始した場合は、キャンセルボタンをセットしてUITapGestureRecognizerを付与する
    func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
        searchBar.setShowsCancelButton(true, animated: true)
        self.view.addGestureRecognizer(tapGestureRecognizer)
        return true
    }

    // SearchBarでの入力を終了した場合は、キャンセルボタンをキャンセルしてUITapGestureRecognizerを削除する
    func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool {
        searchBar.setShowsCancelButton(false, animated: true)
        self.view.removeGestureRecognizer(tapGestureRecognizer)
        return true
    }

    // キャンセルボタンをタップした場合は、キーボードを隠す
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        searchBar.resignFirstResponder()
    }
}

(4) UINavigationController上に表示するUISearchBarを継承して作成したクラス:

この画面ではインクリメンタルサーチを実行するためのUISearchBarが、UINavigationControllerの上に表示されている形となっています。このUIを実現するための基本的な方針としましては、navigationBar.titleViewにUISearchBarのインスタンスを代入することで実現可能ではありますが、主な部分に関するデザイン調整ができるようにUISearchBarを継承したクラスを新しく作成することで対応しています。

※ こちらの対応では大まかな部分に関する調整は可能ですが、より細かくシビアな調整が必要になる場合には別の方法を検討した方が良さそうに個人的には感じています。

KeywordSearchBar.swift
import Foundation
import UIKit

// 参考: UISearchBarを継承したクラスを別途作成しNavigationBarのtitleViewに当てはめる対応をしています。
// https://stackoverflow.com/a/46945190
// 補足: iOS14ではこの部分が原因でAutoLayoutの警告が発生していたので下記メソッドは削除しています。
/*
private func setupKeywordSearchBar() {

    // iOS11以降の場合だけLayoutAnchorを利用して制約を付与する
    if #available(iOS 11.0, *) {
        self.translatesAutoresizingMaskIntoConstraints = false
        self.heightAnchor.constraint(equalToConstant: searchBarHeight).isActive = true
    }
}
*/

class KeywordSearchBar: UISearchBar {

    private let searchBarHeight: CGFloat = 44.0
    private let searchBarPaddingTop: CGFloat = 8.0

    override func layoutSubviews() {
        super.layoutSubviews()

        decorateKeywordSearchBar()
    }

    // MARK: - Private Functions

    private func decorateKeywordSearchBar() {

        // key名からテキストフィールド要素を取得する
        if let textField = self.value(forKey: "searchField") as? UITextField {

            // iOS11以降の場合だけ高さを書き換える
            if #available(iOS 11.0, *) {
                let textFieldHeight = searchBarHeight - searchBarPaddingTop * 2
                textField.frame = CGRect(x: textField.frame.origin.x, y: searchBarPaddingTop, width: textField.frame.width, height: textFieldHeight)
            }

            // テキストフィールド部分のデザインを設定する
            textField.backgroundColor = AppConstant.SEARCHBAR_TEXTFIELD_BACKGROUND_COLOR
            textField.tintColor = AppConstant.SEARCHBAR_TEXTFIELD_TINT_COLOR
            textField.font = UIFont(name: AppConstant.COMMON_FONT_NORMAL, size: AppConstant.SEARCHBAR_TEXTFIELD_FONT_SIZE)

            // プレースホルダ部分のデザインを設定する
            if let label = textField.value(forKey: "placeholderLabel") as? UILabel {
                label.textColor = AppConstant.SEARCHBAR_PLACEHOLDER_TINT_COLOR
                label.font = UIFont(name: AppConstant.COMMON_FONT_NORMAL, size: AppConstant.SEARCHBAR_PLACEHOLDER_FONT_SIZE)
            }
        }
    }
}

この部分の実装については、私自身もちょっと自信が持てない部分であるので、RxSwiftに詳しい方が見れば改善ができうる点等がありそうな部分かとは思いますが暖かく見守って頂けますと幸いです。煩雑な処理になりがちなAPI通信処理を伴う画面と連動する処理を、整理された形にできる点は改めて非常に有意義かつ強力なところであると感じています。

5. その他UI実装用ライブラリを利用した表現に関する解説

ここではRxSwift + MVVMパターンを利用した実装と直接関係はありませんが、UIライブラリを用いた実装を行なった箇所の紹介を軽くできればと思います。TOPの画面では他の画面に遷移するためのフローティングメニューボタンの実装に 「ライブラリ: Floaty」 を利用した実装をしています。このライブラリのメリットとしては、アニメーションが綺麗なことに加えて細かな要素に対しても柔軟にカスタマイズができる点にあるかと思います。

floating_menu.png

このサンプルでの実装部分をまとめると下記のような形になります。UIApplication.willResignActiveNotificationのライフサイクルが実行されるタイミングでは、Notificationを発行してメニューを非表示にするようにしています。この処理は再びフォアグラウンドで表示した際にメニュー表示が崩れてしまう現象やメニュー表示状態から戻れなくなることを防ぐ配慮をしています。

MainViewController.swift
class MainViewController: UIViewController {

    private let disposeBag = DisposeBag()

    @IBOutlet weak private var floatyMenuButton: Floaty!

    ・・・(省略)・・・

    override func viewDidLoad() {
        super.viewDidLoad()

        // UIまわりの初期設定
        setupUserInterface()

        // フォアグラウンドからバックグラウンドに移行する直前のタイミングでフロートボタン表示を戻す
        NotificationCenter.default.rx.notification(UIApplication.willResignActiveNotification, object: nil).subscribe(onNext:{ [weak self] _ in
            self?.floatyMenuButton.close()
        }).disposed(by: disposeBag)
    }

    // MARK: - Private Function

    private func setupUserInterface() {
        setupNavigationBar(title: "World News Archives")
        removeBackButtonText()
        setupFloatyMenuButton()
    }

    private func setupFloatyMenuButton() {

        // メニューボタンのデザインを設定する
        floatyMenuButton.buttonColor = AppConstant.COMMON_POINT_COLOR
        floatyMenuButton.plusColor = .white
        floatyMenuButton.overlayColor = UIColor.black.withAlphaComponent(0.67)
        floatyMenuButton.sticky = true

        // MenuButtonTypesの定義からボタンアイテムを配置する
        let _ = MenuButtonTypes.allCases.map {

            // ボタンアイテムを設定する
            let menuButtonCase = $0
            let item = FloatyItem()

            // ボタンアイテムのタップ時挙動を設定する
            item.handler = { _ in
                let sb = UIStoryboard(name: menuButtonCase.getStoryboardName(), bundle: nil)
                if let vc = sb.instantiateInitialViewController() {
                    self.navigationController?.pushViewController(vc, animated: true)
                }
            }

            // ボタンアイテムのデザインを設定する
            decorarteFloatyMenuButton(item: item, type: menuButtonCase)

            // ボタンアイテムを配置する
            floatyMenuButton.addItem(item: item)
         }
    }

    private func decorarteFloatyMenuButton(item: FloatyItem, type: MenuButtonTypes) {

        // アイコンの配置位置とサイズを設定する
        let itemOrigin = CGPoint(x: 7.0, y: 7.0)
        let itemSize = CGSize(width: 28.0, height: 28.0)

        // タイトル文字列を設定する
        item.title = type.getButtonName()

        // ボタンの色を設定する
        item.buttonColor = UIColor(code: "#333333", alpha: 0.5)

        // 表示ラベルのフォントを設定する
        item.titleLabel.textAlignment = .right
        item.titleLabel.font = UIFont(name: AppConstant.COMMON_FONT_BOLD, size: AppConstant.COMMON_NAVIGATION_FONT_SIZE)

        // ボタン右のアイコン表示を設定する
        item.iconImageView.tintColor = .white
        item.iconImageView.frame = CGRect(origin: itemOrigin, size: itemSize)
        item.iconImageView.image = UIImage.fontAwesomeIcon(name: type.getFontAwesomeIcon(), style: .solid, textColor: .white, size: itemSize)
    }

    ・・・(省略)・・・
}

フローティングメニュー内で表示する内容や遷移先画面に関する設定につきましては、下記のような形のEnumを定義して必要な値に関する項目をひとまとめにして管理をしておくと、将来的に項目が増えることがあった際にも柔軟に対応ができるかと思います。実装先のViewControllerでSwift4.2から追加されたallCasesプロパティを利用できるようにCaseIterableプロトコルを適用しています。

MenuButtonTypes.swift
import Foundation
import FontAwesome_swift

enum MenuButtonTypes: CaseIterable {
    case search
    case information

    // MARK: -  Function

    func getStoryboardName() -> String {
        switch self {
        case .search:
            return "Search"
        case .information:
            return "Information"
        }
    }

    func getFontAwesomeIcon() -> FontAwesome {
        switch self {
        case .search:
            return .search
        case .information:
            return .infoCircle
        }
    }

    func getButtonName() -> String {
        switch self {
        case .search:
            return "Search News for Keyword"
        case .information:
            return "View Information"
        }
    }
}

その他このサンプル内で活用しているUIライブラリを用いた実装に関する詳細については、ここでは割愛しますが適宜GithubのREADME等も併せて参考にして頂ければ幸いです。

6. 今回の実装にあたり参考にした資料まとめ

今回のサンプル実装において、全体設計やRxSwiftを活用した実装を考えていく上で参考にしたRxSwiftに関するブログ記事等のリンクを下記にまとめておきます。

1. RxSwift全般や必要な概念等における参考資料:

◉登壇資料:

◉ブログ記事:

◉Qiita記事:

2. MVVMパターンの実装における参考資料:

3. UI実装と紐づける処理における参考資料:

補足:

※ リンクで紹介している記事での実装でVariableを利用している部分については、適宜BehaviorRelay等に置き換えた上で考えると良いかと思います。

7. あとがき

今回の実装についてはRxSwiftの応用というよりも、UI実装と一緒に利用することでよりRxSwiftのメリットや感覚が掴めるのではないか?という仮説から生まれたものになります。API通信を伴う処理はもとより、現在の選択状態に応じたUI要素の表示を更新する処理に関してもRxSwiftを利用することによって、監視対象の値の状態に合わせたUI要素の状態をハンドリングする部分の処理を上手にまとめることができるので、データの現在状態に紐づくUI表示状態が多岐に渡る様なUIを構築する局面においてはとても有意義であると感じています。またRxSwiftと一緒に活用することが多いMVVMパターンのアーキテクチャやDataBindingに関しても実際にUI実装を伴うサンプルと照らし合わせてみることで、以前に取り組んだ際にはぼんやりとしていた部分が腹落ちすることができたとも感じています。

個人的にはRxSwiftを利用した実装については、まだまだ理解が浅いので到底使いこなせる所までは到達していませんが、今後はUI実装サンプルを作成する際に活用できそうな場合には活用していく等、いざ実務で携わる機会があった場合に素早くキャッチアップができるように備えて置きたいと思います。

補足

2021.01.10:

解説記事の内容及びサンプルコード内容をXcode12.3 & Swift5.3へ対応するものに差し替えてアップデートしました。主な変更点は下記の通りです。

  1. APIからのデータ取得処理を実施しているNewYorkTimesProductionAPI.swiftのコードにおいて、Alamofire5.x系へのバージョンアップに起因する部分の対応した対応と、RxSwift6.0へのバージョンアップに起因するSingle<T>を利用した部分の処理に変更を加えています。
  2. 検索画面を構築するNewYorkTimesProductionAPI.swiftのコードにおいて、UI調整に関する処理に関する追記及びコメントを追記しています。
  3. UINavigationController上に表示するUISearchBarを継承して作成したクラスKeywordSearchBar.swiftに関するコードと簡単な説明を追記しました。

RxSwift6.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
64