Skip to content

Instantly share code, notes, and snippets.

@fumiyasac
Created December 23, 2020 16:10
Show Gist options
  • Save fumiyasac/8ab5d467f107596587d98f264866cdfd to your computer and use it in GitHub Desktop.
Save fumiyasac/8ab5d467f107596587d98f264866cdfd to your computer and use it in GitHub Desktop.
UI実装であると嬉しいレシピブックまかない編掲載サンプル変更点
// 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