Last active
December 27, 2018 07:16
-
-
Save fumiyasac/205b0fadc7de2bc24a484fc25768fbaf to your computer and use it in GitHub Desktop.
RxSwiftとUIライブラリの表現を組み合わせたサンプル紹介 ref: https://qiita.com/fumiyasac@github/items/e426d321fbb8ab846bb6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ID(Int型), タイトルや概要等の画面に表示する内容(String型), アセットに予め追加しているファイル名(String型)をまとめた要素を配列にしています。 | |
[ | |
{ | |
"id": 1, | |
"title": "タイトルや概要が入ります。", | |
"image_name": "アセットに追加している画像名が入ります。" | |
}, | |
・・・(省略)・・・ | |
] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
・・・(省略)・・・ | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import Alamofire | |
import SwiftyJSON | |
import RxSwift | |
import RxCocoa | |
protocol NewYorkTimesAPI { | |
func getRecentNewsList(page: Int) -> Single<JSON> | |
func getSearchNewsList(keyword: String) -> Single<JSON> | |
} | |
class NewYorkTimesProductionAPI: NewYorkTimesAPI { | |
private let manager = Alamofire.SessionManager.default | |
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(.error(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(.error(error)) | |
} | |
} | |
return Disposables.create() | |
}) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 = "--" | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) } | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ?? "--" | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) } | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(0.5, scheduler: MainScheduler.instance) | |
.distinctUntilChanged() | |
} | |
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) | |
} | |
// MARK: - Private Function | |
// スクロールすると検索フォームのフォーカスを外す | |
@objc private func searchBarUnforcus() { | |
keywordSearchBar.resignFirstResponder() | |
} | |
private func setupUserInterface() { | |
setupNavigationBar(title: "") | |
setupKeywordSearchBar() | |
setupSearchTableView() | |
} | |
private func setupKeywordSearchBar() { | |
// キーボード表示時にUITableViewのタップ処理をさせないためにUITapGestureRecognizerを作成する | |
tapGestureRecognizer = UITapGestureRecognizer.init(target: self, action: #selector(searchBarUnforcus)) | |
tapGestureRecognizer.delegate = self | |
// NavigationBarに設置するSearchBarを作成する | |
keywordSearchBar = KeywordSearchBar() | |
keywordSearchBar.placeholder = "Please input keyword." | |
keywordSearchBar.delegate = self | |
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() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment