Skip to content

Instantly share code, notes, and snippets.

@fumiyasac
Last active December 27, 2018 07:16
Show Gist options
  • Save fumiyasac/205b0fadc7de2bc24a484fc25768fbaf to your computer and use it in GitHub Desktop.
Save fumiyasac/205b0fadc7de2bc24a484fc25768fbaf to your computer and use it in GitHub Desktop.
RxSwiftとUIライブラリの表現を組み合わせたサンプル紹介 ref: https://qiita.com/fumiyasac@github/items/e426d321fbb8ab846bb6
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)
}
}
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
}
}
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
}
}
}
# 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
// ID(Int型), タイトルや概要等の画面に表示する内容(String型), アセットに予め追加しているファイル名(String型)をまとめた要素を配列にしています。
[
{
"id": 1,
"title": "タイトルや概要が入ります。",
"image_name": "アセットに追加している画像名が入ります。"
},
・・・(省略)・・・
]
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)
}
}
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)
}
}
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
}
}
}
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)
}
・・・(省略)・・・
}
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"
}
}
}
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()
})
}
}
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 = "--"
}
}
}
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)
}
}
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) }
}
}
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 ?? "--"
}
}
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) }
}
}
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