Skip to content

Instantly share code, notes, and snippets.

@fumiyasac
Last active December 3, 2020 16:43
Show Gist options
  • Save fumiyasac/fec115ccaeb1cf963b153777255bd05b to your computer and use it in GitHub Desktop.
Save fumiyasac/fec115ccaeb1cf963b153777255bd05b to your computer and use it in GitHub Desktop.
ライブラリなしでメディアアプリでよく見る無限スクロールするタブの動きを実装したUIサンプルの紹介 ref: https://qiita.com/fumiyasac@github/items/af4fed8ea4d0b94e6bc4
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)
}
}
}
}
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)
}
・・・(省略)・・・
}
// ボタン押下時の軽微な振動を追加する
private let buttonFeedbackGenerator: UIImpactFeedbackGenerator = {
let generator: UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
return generator
}()
// impactOccurred()メソッドを実行することで「コツッ」とした感じの端末フィードバックを発火する
buttonFeedbackGenerator.impactOccurred()
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
)
}
}
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