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パターンを積極的に活用していく形の実装になるかと思います。
またこのサンプルにおけるUI実装に関しては、全体のレイアウトやアニメーション表現に関わる部分で美しい表現でありながらも、今回のサンプル実装以外での応用した活用やカスタマイズがしやすそうなUIライブラリをいくつか利用しています。RxSwiftの処理とは合わせていない、別途UIライブラリを利用した要素のデザインや配置位置の決定といった初期設定に関する処理を行なっている部分がある点にはご注意下さい。
サンプルのキャプチャ画像その1:
サンプルのキャプチャ画像その2:
環境やバージョンについて:
- 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の高さ調整処理を記載しています。
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を参考にして頂ければと思います。)
-
Model: 取得したJSONを定義した型に該当する形にする。初期化時のJSONの解析処理については
Decodable
プロトコルを適用しています。 -
ViewModel: 初期化の際にJSONから作成したデータを
Observable<[FeaturedModel]>
型のデータとして保持しておき、UICollectionViewと紐づけられる形にする。また現在の表示しているインデックス値やボタンの表示・非表示に関するステータスをBehaviorRelay<Int>
型またはBehaviorRelay<Bool>
型でイベントを流せる形にしておくと共に、これらの値を更新するためのメソッドを定義しています。 -
ViewController: UIライブラリの初期設定やUI更新に関連する処理を適当な処理粒度で
private
なメソッドに切り出しておきます。viewDidLoad
では、RxSwiftを利用してViewModel内で定義しているViewController側で利用するためのプロパティ
とバインドさせることで、この値の変化に応じたUIの状態が更新される様にしています。
(1) Model部分のコードに関して:
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部分のコードに関して:
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部分のコードに関して:
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の変化におけるお互いの関係性を理解する上で、重要だと感じる部分をまとめたものを下記の図解にまとめておきました。
★3-2. ドロップダウンメニューと連動した記事切り替えを伴う画面の処理に関する解説
次に、ドロップダウンメニューと連動した記事切り替えを伴う画面の処理に関して見ていきましょう。基本的な構成としてはUIScrollViewで表示している内容を、タイトル部分を押下することで表示されるドロップダウンメニューから該当の内容へ切り替えるような形になります。
※補足: 画面上部のサムネイル画像がパララックス表示している部分についてはUIScrollViewDelegate
を利用し、今回はRxSwiftでの処理とは切り分けています。
ドロップダウンメニューから画面の表示を切り替える処理やUI表現については、「ライブラリ: BTNavigationDropdownMenu」 を利用しています。(ライブラリ導入や活用方法に関しては後述するコードやライブラリに記載されているREADMEを参考にして頂ければと思います。)
-
Model: 取得したJSONを定義した型に該当する形にする。初期化時のJSONの解析処理については
Decodable
プロトコルを適用しています。 -
ViewModel: 初期化の際にJSONから作成したデータを別途
private
なプロパティで保持しておき、ViewController側で利用するタイトルの一覧をObservable<[String]>
型のデータを作成してドロップダウンメニューの設定の際に利用できるようにする。またドロップダウンメニューの選択処理で該当のデータを格納する変数selectedInformation
をBehaviorRelay<InformationModel?>
型でイベントを流せる形にしておくと共に、これらの値を更新するためのメソッドを定義しています。 -
ViewController: UIライブラリの初期設定やUI更新に関連する処理を適当な処理粒度で
private
なメソッドに切り出しておきます。viewDidLoad
では、RxSwiftを利用してViewModel内で定義しているViewController側で利用するためのプロパティ
とバインドさせることで、この値の変化に応じたUIの状態が更新される様にしています。
(1) Model部分のコードに関して:
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部分のコードに関して:
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部分のコードに関して:
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の変化におけるお互いの関係性を理解する上で、重要だと感じる部分をまとめたものを下記の図解にまとめておきました。
上記で紹介した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度だけ流すことを保証する形にしています。
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件ずつ取得して表示する処理に関する解説
この部分で実装している処理の概要に関するイメージ図は下記のような形になるかと思います。
-
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部分のコードに関して:
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部分のコードに関して:
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部分のコードに関して:
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件取得して表示する処理に関する解説
この部分で実装している処理の概要に関するイメージ図は下記のような形になるかと思います。
-
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まわりの処理で必要なUISearchBarDelegate
やUIGestureRecognizerDelegate
についても記載しています。)
(1) Model部分のコードに関して:
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部分のコードに関して:
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部分のコードに関して:
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を継承したクラスを新しく作成することで対応しています。
※ こちらの対応では大まかな部分に関する調整は可能ですが、より細かくシビアな調整が必要になる場合には別の方法を検討した方が良さそうに個人的には感じています。
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」 を利用した実装をしています。このライブラリのメリットとしては、アニメーションが綺麗なことに加えて細かな要素に対しても柔軟にカスタマイズができる点にあるかと思います。
このサンプルでの実装部分をまとめると下記のような形になります。UIApplication.willResignActiveNotification
のライフサイクルが実行されるタイミングでは、Notification
を発行してメニューを非表示にするようにしています。この処理は再びフォアグラウンドで表示した際にメニュー表示が崩れてしまう現象やメニュー表示状態から戻れなくなることを防ぐ配慮をしています。
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
プロトコルを適用しています。
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記事:
- RxSwift 用語解説
- RxSwift再入門
- オブザーバーパターンから始めるRxSwift入門
- RxSwift 3.3.0で追加された3つのUnit(Single, Maybe, Completable)
2. MVVMパターンの実装における参考資料:
- RxSwiftによるMVVMパターン
- iOSアプリ開発におけるRxSwiftの活用
- RxSwift with MVVM
- Creating an IOS app with MVVM and RxSwift in minutes
- ViewModel in RxSwift world
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へ対応するものに差し替えてアップデートしました。主な変更点は下記の通りです。
- APIからのデータ取得処理を実施している
NewYorkTimesProductionAPI.swift
のコードにおいて、Alamofire5.x系へのバージョンアップに起因する部分の対応した対応と、RxSwift6.0へのバージョンアップに起因するSingle<T>
を利用した部分の処理に変更を加えています。 - 検索画面を構築する
NewYorkTimesProductionAPI.swift
のコードにおいて、UI調整に関する処理に関する追記及びコメントを追記しています。 - UINavigationController上に表示するUISearchBarを継承して作成したクラス
KeywordSearchBar.swift
に関するコードと簡単な説明を追記しました。
RxSwift6.0に関する変更点につきましては下記の記事等を参考にしながら、変更点を確認してみるとより理解が深まるかと思います。