Last active
December 3, 2020 16:43
-
-
Save fumiyasac/fec115ccaeb1cf963b153777255bd05b to your computer and use it in GitHub Desktop.
ライブラリなしでメディアアプリでよく見る無限スクロールするタブの動きを実装したUIサンプルの紹介 ref: https://qiita.com/fumiyasac@github/items/af4fed8ea4d0b94e6bc4
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
class ArticleViewController: UIViewController { | |
// カテゴリーの一覧データ | |
private let categoryList: [String] = ArticleMock.getArticleCategories() | |
// 現在表示しているViewControllerのタグ番号 | |
private var currentCategoryIndex: Int = 0 | |
// ページングして表示させるViewControllerを保持する配列 | |
private var targetViewControllerLists: [UIViewController] = [] | |
// ContainerViewにEmbedしたUIPageViewControllerのインスタンスを保持する | |
private var pageViewController: UIPageViewController? | |
// MARK: - Override | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
// MEMO: InterfaceBuilderでNavigationBarの背景色を#ff6060 / Trunslucentをfalseとする | |
setupNavigationBarTitle("サンプル記事一覧") | |
removeBackButtonText() | |
setupPageViewController() | |
} | |
// Segueに設定したIdentifierから接続されたViewControllerを取得する | |
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { | |
switch segue.identifier { | |
// ContainerViewで接続されたViewController側に定義したプロトコルを適用する | |
case "CategoryScrollTabViewContainer": | |
let vc = segue.destination as! CategoryScrollTabViewController | |
vc.delegate = self | |
default: | |
break | |
} | |
} | |
// MARK: - Private Function | |
private func setupPageViewController() { | |
// UIPageViewControllerで表示させるViewControllerの一覧を配列へ格納する | |
let _ = categoryList.enumerated().map{ (index, categoryName) in | |
let sb = UIStoryboard(name: "Article", bundle: nil) | |
let vc = sb.instantiateViewController(withIdentifier: "CategoryScrollContents") as! CategoryScrollContentsViewController | |
vc.view.tag = index | |
vc.setDescription(text: categoryName) | |
vc.setArticlesByCategoryId(articles: ArticleMock.getArticlesBy(categoryId: index)) | |
targetViewControllerLists.append(vc) | |
} | |
// ContainerViewにEmbedしたUIPageViewControllerを取得する | |
for childVC in children { | |
if let targetVC = childVC as? UIPageViewController { | |
pageViewController = targetVC | |
} | |
} | |
// UIPageViewControllerDelegate & UIPageViewControllerDataSourceの宣言 | |
pageViewController!.delegate = self | |
pageViewController!.dataSource = self | |
// 最初に表示する画面として配列の先頭のViewControllerを設定する | |
pageViewController!.setViewControllers([targetViewControllerLists[0]], direction: .forward, animated: false, completion: nil) | |
} | |
// 配置されているタブ表示のUICollectionViewの位置を更新する | |
// MEMO: ContainerViewで配置しているViewControllerの親子関係を利用する | |
private func updateCategoryScrollTabPosition(isIncrement: Bool) { | |
for childVC in children { | |
if let targetVC = childVC as? CategoryScrollTabViewController { | |
targetVC.moveToCategoryScrollTab(isIncrement: isIncrement) | |
} | |
} | |
} | |
} | |
// MARK: - UIPageViewControllerDelegate | |
extension ArticleViewController: UIPageViewControllerDelegate { | |
// ページが動いたタイミング(この場合はスワイプアニメーションに該当)に発動する処理を記載するメソッド | |
// (実装例)http://c-geru.com/as_blind_side/2014/09/uipageviewcontroller.html | |
// (実装例に関する解説)http://chaoruko-tech.hatenablog.com/entry/2014/05/15/103811 | |
// (公式ドキュメント)https://developer.apple.com/reference/uikit/uipageviewcontrollerdelegate | |
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { | |
// スワイプアニメーションが完了していない時には処理をさせなくする | |
if !completed { return } | |
// ここから先はUIPageViewControllerのスワイプアニメーション完了時に発動する | |
if let targetViewControllers = pageViewController.viewControllers { | |
if let targetViewController = targetViewControllers.last { | |
// Case1: UIPageViewControllerで表示する画面のインデックス値が左スワイプで 0 → 最大インデックス値 | |
if targetViewController.view.tag - currentCategoryIndex == -categoryList.count + 1 { | |
updateCategoryScrollTabPosition(isIncrement: true) | |
// Case2: UIPageViewControllerで表示する画面のインデックス値が右スワイプで 最大インデックス値 → 0 | |
} else if targetViewController.view.tag - currentCategoryIndex == categoryList.count - 1 { | |
updateCategoryScrollTabPosition(isIncrement: false) | |
// Case3: UIPageViewControllerで表示する画面のインデックス値が +1 | |
} else if targetViewController.view.tag - currentCategoryIndex > 0 { | |
updateCategoryScrollTabPosition(isIncrement: true) | |
// Case4: UIPageViewControllerで表示する画面のインデックス値が -1 | |
} else if targetViewController.view.tag - currentCategoryIndex < 0 { | |
updateCategoryScrollTabPosition(isIncrement: false) | |
} | |
// 受け取ったインデックス値を元にコンテンツ表示を更新する | |
currentCategoryIndex = targetViewController.view.tag | |
} | |
} | |
} | |
} | |
// MARK: - UIPageViewControllerDataSource | |
extension ArticleViewController: UIPageViewControllerDataSource { | |
// 逆方向にページ送りした時に呼ばれるメソッド | |
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { | |
// インデックスを取得する | |
guard let index = targetViewControllerLists.index(of: viewController) else { | |
return nil | |
} | |
// インデックスの値に応じてコンテンツを動かす | |
if index <= 0 { | |
return targetViewControllerLists.last | |
} else { | |
return targetViewControllerLists[index - 1] | |
} | |
} | |
// 順方向にページ送りした時に呼ばれるメソッド | |
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { | |
// インデックスを取得する | |
guard let index = targetViewControllerLists.index(of: viewController) else { | |
return nil | |
} | |
// インデックスの値に応じてコンテンツを動かす | |
if index >= targetViewControllerLists.count - 1 { | |
return targetViewControllerLists.first | |
} else { | |
return targetViewControllerLists[index + 1] | |
} | |
} | |
} | |
// MARK: - CategoryScrollTabDelegate | |
extension ArticleViewController: CategoryScrollTabDelegate { | |
// タブ側のViewControllerで選択されたインデックス値とスクロール方向を元に表示する位置を調整する | |
func moveToCategoryScrollContents(selectedCollectionViewIndex: Int, targetDirection: UIPageViewController.NavigationDirection, withAnimated: Bool) { | |
// UIPageViewControllerに設定した画面の表示対象インデックス値を設定する | |
// MEMO: タブ表示のUICollectionViewCellのインデックス値をカテゴリーの個数で割った剰余 | |
currentCategoryIndex = selectedCollectionViewIndex % categoryList.count | |
// 表示対象インデックス値に該当する画面を表示する | |
// MEMO: メインスレッドで実行するようにしてクラッシュを防止する対策を施している | |
DispatchQueue.main.async { | |
if let targetPageViewController = self.pageViewController { | |
targetPageViewController.setViewControllers([self.targetViewControllerLists[self.currentCategoryIndex]], direction: targetDirection, animated: withAnimated, completion: nil) | |
} | |
} | |
} | |
} |
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
final class CategoryScrollTabViewCell: UICollectionViewCell { | |
・・・(省略)・・・ | |
// MARK: - Class Function | |
// カテゴリー表示用の下線の幅を算出する | |
class func calculateCategoryUnderBarWidthBy(title: String) -> CGFloat { | |
// テキストの属性を設定する | |
var categoryTitleAttributes = [NSAttributedString.Key : Any]() | |
categoryTitleAttributes[NSAttributedString.Key.font] = UIFont( | |
name: AppConstant.CATEGORY_FONT_NAME, | |
size: AppConstant.CATEGORY_FONT_SIZE | |
) | |
// 引数で渡された文字列とフォントから配置するラベルの幅を取得する | |
let categoryTitleLabelSize = CGSize( | |
width: .greatestFiniteMagnitude, | |
height: AppConstant.CATEGORY_FONT_HEIGHT | |
) | |
let categoryTitleLabelRect = title.boundingRect( | |
with: categoryTitleLabelSize, | |
options: .usesLineFragmentOrigin, | |
attributes: categoryTitleAttributes, | |
context: nil) | |
return ceil(categoryTitleLabelRect.width) | |
} | |
・・・(省略)・・・ | |
} |
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
// ボタン押下時の軽微な振動を追加する | |
private let buttonFeedbackGenerator: UIImpactFeedbackGenerator = { | |
let generator: UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) | |
generator.prepare() | |
return generator | |
}() | |
// impactOccurred()メソッドを実行することで「コツッ」とした感じの端末フィードバックを発火する | |
buttonFeedbackGenerator.impactOccurred() |
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
import UIKit | |
final class CategoryScrollTabViewFlowLayout: UICollectionViewFlowLayout { | |
// 参考1: 下記のリンクで紹介されていたTIPSを元に実装しました | |
// https://uruly.xyz/carousel-infinite-scroll-3/ | |
// 参考2: UICollectionViewのlayoutAttributeの変更タイミングに関する記事 | |
// https://qiita.com/kazuhiro4949/items/03bc3d17d3826aa197c0 | |
// 参考3: UICollectionViewFlowLayoutのサブクラスを利用したスクロールの停止位置算出に関する記事 | |
// https://dev.classmethod.jp/smartphone/iphone/collection-view-layout-cell-snap/ | |
// 該当のセルのオフセット値を計算するための値(スクリーンの幅 - UICollectionViewに配置しているセルの幅) | |
private let horizontalTargetOffsetWidth: CGFloat = UIScreen.main.bounds.width - AppConstant.CATEGORY_CELL_WIDTH | |
// UICollectionViewをスクロールした後の停止位置を返すためのメソッド | |
// MEMO: UICollectionViewのLayoutAttributeを調整して、中央に表示されるように調整している | |
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { | |
// 配置されているUICollectionViewを取得する | |
guard let conllectionView = self.collectionView else { | |
assertionFailure("UICollectionViewが配置されていません。") | |
return CGPoint.zero | |
} | |
// UICollectionViewのオフセット値を元に該当のセルの情報を取得する | |
var offsetAdjustment: CGFloat = CGFloat(MAXFLOAT) | |
let horizontalOffest: CGFloat = proposedContentOffset.x + horizontalTargetOffsetWidth / 2 | |
let targetRect = CGRect( | |
x: proposedContentOffset.x, | |
y: 0, | |
width: conllectionView.bounds.size.width, | |
height: conllectionView.bounds.size.height | |
) | |
// 配置されているUICollectionViewのlayoutAttributesを元にして停止させたい位置を算出する | |
guard let layoutAttributes = super.layoutAttributesForElements(in: targetRect) else { | |
assertionFailure("配置したUICollectionViewにおいて該当セルにおけるlayoutAttributesを取得できません。") | |
return CGPoint.zero | |
} | |
for layoutAttribute in layoutAttributes { | |
let itemOffset = layoutAttribute.frame.origin.x | |
if abs(itemOffset - horizontalOffest) < abs(offsetAdjustment) { | |
offsetAdjustment = itemOffset - horizontalOffest | |
} | |
} | |
return CGPoint( | |
x: proposedContentOffset.x + offsetAdjustment, | |
y: proposedContentOffset.y | |
) | |
} | |
} |
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
var visibleIndexPathList: [IndexPath] = [] | |
for cell in categoryScrollTabCollectionView.visibleCells { | |
if let visibleIndexPath = categoryScrollTabCollectionView.indexPath(for: cell) { | |
visibleIndexPathList.append(visibleIndexPath) | |
print("現在画面内に見えているセルのインデックス値:", visibleIndexPath) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment