Created
December 23, 2020 16:10
-
-
Save fumiyasac/8ab5d467f107596587d98f264866cdfd to your computer and use it in GitHub Desktop.
UI実装であると嬉しいレシピブックまかない編掲載サンプル変更点
This file contains 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
// UI実装であると嬉しいレシピブックまかない編掲載サンプル変更点 | |
// 1. 掲載サンプル第2章に関する変更点のご紹介 | |
// Parchmentを利用したタブ型UI表示に関連する処理の変更点 | |
// PagingViewControllerDelegate及びPagingViewControllerInfiniteDataSourceの変更に加えて以前に<T>記載があった部分に変更があります。 | |
final class ArticleViewController: UIViewController { | |
// MEMO: ライブラリ「Parchment」におけるタブ要素データを格納する配列 | |
private var articleCategoryPageItemSet: [ArticleCategoryPageItem] = [] | |
// MEMO: ライブラリ「Parchment」におけるタブ要素で表示する画面を格納する配列 | |
private var articleCategoryViewControllerSet: [ArticleCategoryViewController] = [] | |
// MEMO: ライブラリ「Parchment」におけるタブ要素で表示対象の画面を格納する | |
private var targetPagingViewController: PagingViewController! | |
@IBOutlet weak private var screenView: UIView! | |
// MARK: - Override | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
setupNavigationBarTitle("特選記事集サンプル") | |
setupPagingViewController() | |
} | |
// MARK: - Private Function | |
private func setupPagingViewController() { | |
// タブ要素データとタブ要素で表示する画面を組み立てる | |
for (_, patternType) in ArticleCategoryPattern.allCases.enumerated() { | |
articleCategoryPageItemSet.append(ArticleCategoryPageItem(type: patternType, index: patternType.rawValue)) | |
} | |
for _ in ArticleCategoryPattern.allCases { | |
articleCategoryViewControllerSet.append(ArticleCategoryViewController.make()) | |
} | |
// MEMO: ライブラリ「Parchment」における見た目(PagingOptions)の調整処理 | |
targetPagingViewController = PagingViewController() | |
targetPagingViewController.register(ArticleCategoryPageItemTabView.self, for: ArticleCategoryPageItem.self) | |
targetPagingViewController.menuItemSize = .fixed(width: 150, height: 44) | |
targetPagingViewController.font = UIFont(name: "HiraKakuProN-W3", size: 12.0)! | |
targetPagingViewController.selectedFont = UIFont(name: "HiraKakuProN-W3", size: 12.0)! | |
targetPagingViewController.textColor = UIColor.lightGray | |
targetPagingViewController.selectedTextColor = UIColor.black | |
targetPagingViewController.indicatorColor = UIColor.black | |
targetPagingViewController.borderColor = UIColor(code: "#dddddd") | |
targetPagingViewController.indicatorOptions = .visible(height: 2, zIndex: 0, spacing: UIEdgeInsets.zero, insets: UIEdgeInsets.zero) | |
// MEMO: ライブラリ「Parchment」で定義されているプロトコルの適用 | |
// → コードでのAutoLayoutを利用してスクリーンとなるView要素の中に配置する | |
screenView.addSubview(targetPagingViewController.view) | |
targetPagingViewController.view.translatesAutoresizingMaskIntoConstraints = false | |
targetPagingViewController.view.topAnchor.constraint(equalTo: screenView.topAnchor).isActive = true | |
targetPagingViewController.view.leftAnchor.constraint(equalTo: screenView.leftAnchor).isActive = true | |
targetPagingViewController.view.rightAnchor.constraint(equalTo: screenView.rightAnchor).isActive = true | |
targetPagingViewController.view.bottomAnchor.constraint(equalTo: screenView.bottomAnchor).isActive = true | |
// 表示対象のViewControllerをparentViewControllerの子として登録する | |
self.addChild(targetPagingViewController) | |
targetPagingViewController.didMove(toParent: self) | |
// MEMO: ライブラリ「Parchment」で定義されているプロトコルの適用 | |
// → UIPageViewControllerのものと似ているがこれはライブラリで提供されているもの | |
targetPagingViewController.infiniteDataSource = self | |
targetPagingViewController.delegate = self | |
// 初期表示状態の設定 | |
targetPagingViewController.reloadMenu() | |
targetPagingViewController.select(pagingItem: articleCategoryPageItemSet[0]) | |
} | |
} | |
// MARK: - PagingViewControllerDelegate | |
extension ArticleViewController: PagingViewControllerDelegate { | |
// 表示要素を切り替えるトランジションの完了状態を検知するための処理 | |
func pagingViewController(_ pagingViewController: PagingViewController, didScrollToItem pagingItem: PagingItem, startingViewController: UIViewController?, destinationViewController: UIViewController, transitionSuccessful: Bool) { | |
// MEMO: スワイプアニメーションが完了していない時には処理をさせなくする | |
if !transitionSuccessful { return } | |
// MEMO: スワイプアニメーションが完了したら表示対象のインデックス値を更新する | |
if let targetItem = pagingItem as? ArticleCategoryPageItem { | |
targetPagingViewController.select(pagingItem: articleCategoryPageItemSet[targetItem.index]) | |
} | |
} | |
} | |
// MARK: - PagingViewControllerInfiniteDataSource | |
extension ArticleViewController: PagingViewControllerInfiniteDataSource { | |
// タブ要素データ内のインデックス値に該当する画面を表示するための処理 | |
func pagingViewController(_: PagingViewController, viewControllerFor pagingItem: PagingItem) -> UIViewController { | |
let item = pagingItem as! ArticleCategoryPageItem | |
return articleCategoryViewControllerSet[item.index] | |
} | |
// ページ要素を移動した際にタブ要素データ内のインデックス値が+1増加する場合における処理 | |
func pagingViewController(_ : PagingViewController, itemAfter pagingItem: PagingItem) -> PagingItem? { | |
// MEMO: PagingViewControllerInfiniteDataSourceを利用しているが、無限スクロールを適用しないのでこの形にする点に注意 | |
if let item = pagingItem as? ArticleCategoryPageItem, | |
let index = articleCategoryPageItemSet.firstIndex(of: item), | |
let targetItem = articleCategoryPageItemSet[safe: index + 1] { | |
return targetItem | |
} else { | |
return nil | |
} | |
} | |
// ページ要素を移動した際にタブ要素データ内のインデックス値が-1減少する場合における処理 | |
func pagingViewController(_: PagingViewController, itemBefore pagingItem: PagingItem) -> PagingItem? { | |
// MEMO: PagingViewControllerInfiniteDataSourceを利用しているが、無限スクロールを適用しないのでこの形にする点に注意 | |
if let item = pagingItem as? ArticleCategoryPageItem, | |
let index = articleCategoryPageItemSet.firstIndex(of: item), | |
let targetItem = articleCategoryPageItemSet[safe: index - 1] { | |
return targetItem | |
} else { | |
return nil | |
} | |
} | |
} | |
// MARK: - Fileprivate Array Extension | |
// MEMO: このファイル内のみで利用できるExtensionの定義 | |
fileprivate extension Array { | |
// MARK: - Subscript | |
subscript (safe index: Index) -> Element? { | |
// MEMO: 任意の配列要素に含まれないインデックスを指定した場合にnilを返すようにする | |
// ※ 配列での「index out of range」を少しでも防止するために用いる | |
return indices.contains(index) ? self[index] : nil | |
} | |
} | |
// 2. 掲載サンプル第3章に関する変更点のご紹介 | |
// Combineを利用したViewModelの処理に関するテストコードを新規追加しました | |
class MockAPIRequestManager { | |
// MARK: - Singleton Instance | |
static let shared = MockAPIRequestManager() | |
private init() {} | |
// MARK: - Enum | |
private enum FileName: String { | |
case banners = "banners" | |
case recommends = "recommends" | |
case photos = "photo" | |
case keywords = "keywords" | |
} | |
} | |
// MARK: - APIRequestManagerProtocol | |
extension MockAPIRequestManager: APIRequestManagerProtocol { | |
// MARK: - Function | |
func getBanners() -> Future<[Banner], APIError> { | |
if let path = getStubFilePath(jsonFileName: MockAPIRequestManager.FileName.banners.rawValue) { | |
let data = try! Data(contentsOf: URL(fileURLWithPath: path)) | |
return Future { promise in | |
do { | |
let hashableObjects = try JSONDecoder().decode([Banner].self, from: data) | |
promise(.success(hashableObjects)) | |
} catch { | |
promise(.failure(APIError.error(error.localizedDescription))) | |
} | |
} | |
} else { | |
fatalError("Invalid json format or existence of file.") | |
} | |
} | |
func getRecommends() -> Future<[Recommend], APIError> { | |
if let path = getStubFilePath(jsonFileName: MockAPIRequestManager.FileName.recommends.rawValue) { | |
let data = try! Data(contentsOf: URL(fileURLWithPath: path)) | |
return Future { promise in | |
do { | |
let hashableObjects = try JSONDecoder().decode([Recommend].self, from: data) | |
promise(.success(hashableObjects)) | |
} catch { | |
promise(.failure(APIError.error(error.localizedDescription))) | |
} | |
} | |
} else { | |
fatalError("Invalid json format or existence of file.") | |
} | |
} | |
func getPhotoList(perPage: Int) -> Future<[PhotoList], APIError> { | |
let targetFileName = MockAPIRequestManager.FileName.photos.rawValue + String(perPage) | |
if let path = getStubFilePath(jsonFileName: targetFileName) { | |
let data = try! Data(contentsOf: URL(fileURLWithPath: path)) | |
return Future { promise in | |
do { | |
let hashableObjects = try JSONDecoder().decode([PhotoList].self, from: data) | |
promise(.success(hashableObjects)) | |
} catch { | |
promise(.failure(APIError.error(error.localizedDescription))) | |
} | |
} | |
} else { | |
fatalError("Invalid json format or existence of file.") | |
} | |
} | |
func getKeywords() -> Future<[Keyword], APIError> { | |
if let path = getStubFilePath(jsonFileName: MockAPIRequestManager.FileName.keywords.rawValue) { | |
let data = try! Data(contentsOf: URL(fileURLWithPath: path)) | |
return Future { promise in | |
do { | |
let hashableObjects = try JSONDecoder().decode([Keyword].self, from: data) | |
promise(.success(hashableObjects)) | |
} catch { | |
promise(.failure(APIError.error(error.localizedDescription))) | |
} | |
} | |
} else { | |
fatalError("Invalid json format or existence of file.") | |
} | |
} | |
// MARK: - Private Function | |
// プロジェクト内にBundleされているStub用のJSONのファイルパスを取得する | |
private func getStubFilePath(jsonFileName: String) -> String? { | |
return Bundle.main.path(forResource: jsonFileName, ofType: "json") | |
} | |
} | |
import XCTest | |
import Combine | |
@testable import UICollectionViewCompositionalLayoutExample | |
class UICollectionViewCompositionalLayoutExampleTests: XCTestCase { | |
private let photoViewModel: PhotoViewModel = PhotoViewModel(api: MockAPIRequestManager.shared) | |
private let searchViewModel: SearchViewModel = SearchViewModel(api: MockAPIRequestManager.shared) | |
private var cancellables: [AnyCancellable] = [] | |
override func setUp() {} | |
override func tearDown() {} | |
// MARK: - Function | |
// Case1: PhotoViewModelにおけるデータの取得処理に関するテスト | |
func testPhotoViewModel() { | |
// バナーデータの取得処理に関するテスト | |
executeFetchBannersTest(photoViewModel: photoViewModel) | |
// おすすめデータの取得処理に関するテスト | |
executeFetchRecommendsTest(photoViewModel: photoViewModel) | |
// フォトデータの取得処理に関するテスト | |
executeFetchPhotosTest(photoViewModel: photoViewModel) | |
} | |
// Case1-1: PhotoViewModelにおけるバナーデータの取得処理に関するテスト | |
private func executeFetchBannersTest(photoViewModel: PhotoViewModel) { | |
// ViewModelのOutputから取得できたデータを格納するための変数 | |
var receivedBanners: [Banner] = [] | |
// サブスレッドでViewModelのInputを実行する | |
let expectation: XCTestExpectation? = self.expectation(description: "fetchBannersTest") | |
DispatchQueue.global().async { | |
photoViewModel.inputs.fetchBannersTrigger.send() | |
DispatchQueue.main.async { | |
expectation?.fulfill() | |
} | |
} | |
// ViewModelのOutputから取得できたデータを格納する処理 | |
photoViewModel.outputs.banners | |
.subscribe(on: RunLoop.main) | |
.sink( | |
receiveValue: { banners in | |
receivedBanners = banners | |
} | |
) | |
.store(in: &cancellables) | |
// ViewModelのOutputより取得できた値に関するテスト | |
waitForExpectations(timeout: 1.0, handler: { _ in | |
let receivedBannersCount = receivedBanners.count | |
XCTAssertEqual(3, receivedBannersCount, "バナーデータは合計3件取得できること") | |
let firstData = receivedBanners.first | |
XCTAssertEqual(1, firstData?.id, "1番目のidが正しい値であること") | |
XCTAssertEqual("金沢の歴史的文化遺産と秋の風景", firstData?.title, "1番目のtitleが正しい値であること") | |
}) | |
} | |
// Case1-2: PhotoViewModelにおけるおすすめデータの取得処理に関するテスト | |
private func executeFetchRecommendsTest(photoViewModel: PhotoViewModel) { | |
// ViewModelのOutputから取得できたデータを格納するための変数 | |
var receivedRecommends: [Recommend] = [] | |
// サブスレッドでViewModelのInputを実行する | |
let expectation: XCTestExpectation? = self.expectation(description: "fetchRecommendsTest") | |
DispatchQueue.global().async { | |
photoViewModel.inputs.fetchRecommnendsTrigger.send() | |
DispatchQueue.main.async { | |
expectation?.fulfill() | |
} | |
} | |
// ViewModelのOutputから取得できたデータを格納する処理 | |
photoViewModel.outputs.recommends | |
.subscribe(on: RunLoop.main) | |
.sink( | |
receiveValue: { recommends in | |
receivedRecommends = recommends | |
} | |
) | |
.store(in: &cancellables) | |
// ViewModelのOutputより取得できた値に関するテスト | |
waitForExpectations(timeout: 1.0, handler: { _ in | |
let receivedRecommendsCount = receivedRecommends.count | |
XCTAssertEqual(12, receivedRecommendsCount, "おすすめデータは合計12件取得できること") | |
let firstData = receivedRecommends.first | |
XCTAssertEqual(1, firstData?.id, "1番目のidが正しい値であること") | |
XCTAssertEqual("No.1(魚市場)", firstData?.title, "1番目のtitleが正しい値であること") | |
XCTAssertEqual("食彩", firstData?.category, "1番目のcategoryが正しい値であること") | |
}) | |
} | |
// Case1-3: PhotoViewModelにおけるフォトデータの取得処理に関するテスト | |
private func executeFetchPhotosTest(photoViewModel: PhotoViewModel) { | |
// ViewModelのOutputから取得できたデータを格納するための変数 | |
var receivedPhotos: [Photo] = [] | |
var pageCount: Int = 0 | |
for i in 1...3 { | |
// カウント用の値を変数へ格納する | |
pageCount = i | |
// サブスレッドでViewModelのInputを実行する | |
let expectation: XCTestExpectation? = self.expectation(description: "fetchPhotosTest\(i)") | |
DispatchQueue.global().async { | |
photoViewModel.inputs.fetchPhotosTrigger.send() | |
DispatchQueue.main.async { | |
expectation?.fulfill() | |
} | |
} | |
} | |
// ViewModelのOutputから取得できたデータを格納する処理 | |
photoViewModel.outputs.photos | |
.subscribe(on: RunLoop.main) | |
.sink( | |
receiveValue: { photos in | |
receivedPhotos = photos | |
} | |
) | |
.store(in: &cancellables) | |
// ViewModelのOutputより取得できた値に関するテスト | |
waitForExpectations(timeout: 1.0, handler: { _ in | |
let expectedPhotosCount = 5 * pageCount | |
let receivedPhotosCount = receivedPhotos.count | |
XCTAssertEqual(expectedPhotosCount, receivedPhotosCount, "取得できたフォトデータの総数(5,10,15)が期待するものと一致すること") | |
}) | |
} | |
// Case2: SearchViewModelにおけるデータの取得処理に関するテスト | |
func testSearchViewModel() { | |
// キーワードデータの取得処理に関するテスト | |
executeFetchKeywordsTest(searchViewModel: searchViewModel) | |
} | |
// Case2-1: SearchViewModelにおけるおすすめデータの取得処理に関するテスト | |
private func executeFetchKeywordsTest(searchViewModel: SearchViewModel) { | |
// ViewModelのOutputから取得できたデータを格納するための変数 | |
var receivedKeywords: [Keyword] = [] | |
// サブスレッドでViewModelのInputを実行する | |
let expectation: XCTestExpectation? = self.expectation(description: "fetchKeywordsTest") | |
DispatchQueue.global().async { | |
searchViewModel.inputs.fetchKeywordTrigger.send() | |
DispatchQueue.main.async { | |
expectation?.fulfill() | |
} | |
} | |
// ViewModelのOutputから取得できたデータを格納する処理 | |
searchViewModel.outputs.keywords | |
.subscribe(on: RunLoop.main) | |
.sink( | |
receiveValue: { keywords in | |
receivedKeywords = keywords | |
} | |
) | |
.store(in: &cancellables) | |
// ViewModelのOutputより取得できた値に関するテスト | |
waitForExpectations(timeout: 1.0, handler: { _ in | |
let receivedKeywordsCount = receivedKeywords.count | |
XCTAssertEqual(15, receivedKeywordsCount, "キーワードデータは合計15件取得できること") | |
let firstData = receivedKeywords.first | |
XCTAssertEqual(1, firstData?.id, "1番目のidが正しい値であること") | |
XCTAssertEqual("倶利伽羅峠", firstData?.keyword, "1番目のkeywordが正しい値であること") | |
XCTAssertEqual("くりからとうげ", firstData?.kana, "1番目のkanaが正しい値であること") | |
}) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment