🐋

iOS | アプリのホーム画面でよく見かける「カルーセルバナー」の作り方

2022/07/31に公開

作りたいもの

いろんなアプリでよく見るやつです。

完成版はこちら。
https://github.com/yudai0308/CarouselBannerViewSample

使う技術

今回はこれらの技術を使って実装してみます。
iOS 13 以上をターゲットにする必要があるので設定をご確認ください。

  • UIKit
  • CollectionView
  • UICollectionViewCompositionalLayout
  • UICollectionViewDiffableDataSource
  • NSDiffableDataSourceSnapshot

UICollectionViewCompositionalLayout はバナーカルーセルやグリッド表示、リスト表示など、様々なビューを簡単に組み合わせることができる実装方法です。今回はカルーセルバナーの下にいろんなビューを追加していくことを前提としています。シンプルにバナーカルーセルだけを作りたい場合は UICollectionViewCompositionalLayout を使わずに、より簡単に実装する方法がありそうな気がしますので、あえて今回のやり方を採用する必要はないかなと思います。

ご注意

  • iOS 初めたてのため、もし間違っているところがありましたらご指摘いただけると助かります🙇‍♂️
  • ところどころ強制アンラップしているところがありますが、必要に応じて適切に処理していただければと思います🙇‍♂️

⚠️ 追記 ⚠️

実装時は気づかなかったのですが、iOS 14.5 未満で orthogonalScrollingBehavior を使った横スクロースを実装した場合、最後のセルがうまく表示できないバグがあるようです。本記事の内容をそのまま実装すると再現するので、バージョンを上げるなどの対応が必要かと思います。
(自分の場合、バージョンを上げてもうまく動かずでした。。もし何かわかる方がいらっしゃればコメントいただけると幸いです。)


最後のセルを表示すると、隣のセルが見える。。

https://qiita.com/tosh_3/items/38f53a00ad8c976fa476#ios14のorthogonalscrollingbehavior問題

作り方

1. プロジェクトの作成

2. NavigationBar と CollectionView の追加

NavigationBar はオプションなのでなくても大丈夫です。
Cell は後ほどコード上で追加するので storyboard からは削除してください。
(CollectionView もコード上で追加してもいいのだけど、、何がベストなのだろう。。)

3. ViewController で CollectionView を取得する

storyboard 上の CollectionView と ViewController を @IBOutlet でつなぎます。

ViewController.swift
class ViewController: UIViewController {
    @IBOutlet weak var collectionView: UICollectionView!
    
    override func viewDidLoad() {
        super.viewDidLoad()

    }
}

4. サンプル画像を用意する

適当な画像を5つ用意して Assets に入れます。
(私はここから適当に5枚用意しました。)

次に ViewController クラスのメンバ変数として images を定義します。先ほど追加した画像を UIImage の配列として定義します。

ViewController.swift
class ViewController: UIViewController {
    @IBOutlet weak var collectionView: UICollectionView!
    
    let images = [
        UIImage(named: "building"),
        UIImage(named: "flowers"),
        UIImage(named: "mountains"),
        UIImage(named: "dog"),
        UIImage(named: "boat")
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()

    }
}

5. Cell を作って CollectionView に登録する

今回は CarouselBannerCell という名前のクラスを作成しました。
Cell の中身は UIImageView のみです。

CarouselBannerCell.swift
import UIKit

class CarouselBannerCell : UICollectionViewCell {
    static let id = "CarouselBannerCell"
    
    var imageView: UIImageView!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        imageView = UIImageView(frame: CGRect(origin: .zero, size: CGSize(width: self.bounds.width, height: 200)))
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        self.addSubview(imageView)
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
}

上で作った Cell を CollectionView に登録して認識させます。

ViewController.swift
class ViewController: UIViewController {
    @IBOutlet weak var collectionView: UICollectionView!
    
    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.register(CarouselBannerCell.self, forCellWithReuseIdentifier: CarouselBannerCell.id)
    }
}

6. Section と Item を特定するための enum を作成

UICollectionViewDiffableDataSource や NSDiffableDataSourceSnapshot を扱う場合、Section と Item を特定するための型を指定する必要があります。例えば UICollectionViewDiffableDataSource<Int, String> とした場合、Section は Int で特定、Item は String で特定することになります。

Int や String で管理することも可能ですが、より管理しやすくするため、専用の enum を ViewController 内に定義します。

ViewController.swift
class ViewController: UIViewController {
    ...

    enum SectionType: Int {
        // 0 番目の Section ではカルーセルバナーを表示させるという
	// 指示を出すために Int を持たせています
        case carouselBanner = 0
     }

     enum SectionItem: Hashable {
         case carouselBannerCell(UIImage)
     }

     override func viewDidLoad() {
          ...
     }
}

今回は Section、Item 1つずつ定義しましたが、種類が増えた場合は enum に定義を追加すれば OK です。

7. DataSource ファイルの作成

ViewController+DataSource.swift という名前で新しい swift ファイルを作成します。中身は ViewController の extention です。ひとまず UICollectionViewDiffableDataSource や NSDiffableDataSourceSnapshot は名前が長いので typealias で短くしてしまいます。

ViewController+DataSource.swift
extension ViewController {
    typealias DataSource = UICollectionViewDiffableDataSource<SectionType, SectionItem>
    typealias Snapshot = NSDiffableDataSourceSnapshot<SectionType, SectionItem>
}

8. Section の作成

バナーカルーセルを表示する Section を作成するためのメソッド carouselBannerSection() を定義します。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    func carouselBannerSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(200.0))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        
        let section = NSCollectionLayoutSection(group: group)
	// .groupPaging の設定により横スクロールできる
        section.orthogonalScrollingBehavior = .groupPaging
        
        return section
    }
}

9. sectionProvider() を定義

CollectionView に Section の作り方を教えてあげる必要があるので、sectionProvider を定義します。「0 番目のセクションがきたら BannerCarousel のセクションを表示してね」という内容で定義します。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    func sectionProvider(sectionIndex: Int, environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? {
        switch sectionIndex {
	// SectionType.carouselBanner.rawValue = 0 です
        case SectionType.carouselBanner.rawValue:
            return self.carouselBannerSection()
        default:
            fatalError()
        }
    }
}

10. cellProvier() を定義

UICollectionViewDiffableDataSource を生成する際、第2引数で cellProvider が必要になるため、定義してしまいます。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    func cellProvider(collectionView: UICollectionView, indexPath: IndexPath, sectionItem :SectionItem) -> UICollectionViewCell {
        switch sectionItem {
        case .carouselBannerCell(let image):
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CarouselBannerCell.id, for: indexPath) as! CarouselBannerCell
            cell.imageView.image = image
            return cell
        }
    }
}

11. UICollectionViewCompositionalLayout を生成して CollectionView にセットする

第2引数には上で定義した sectionProvider を指定します。

ViewController.swift
class ViewController: UIViewController {
    ...
    
    override func viewDidLoad() {
          ...
	  
	  collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
     }
}

12. UICollectionViewDiffableDataSource を生成して CollectionView にセットする

第2引数には上で定義した cellProvider を指定します。

ViewController.swift
class ViewController: UIViewController {
    ...
    
    var dataSource: DataSource!

    override func viewDidLoad() {
        ...
	  
	dataSource = DataSource(collectionView: collectionView, cellProvider: cellProvider)
	collectionView.dataSource = dataSource
     }
}

13. updateSnapshot() を定義

CollectionView のデータを画面に反映するとき、以前は CollectionView#reloadData() を使っていましたが、UICollectionViewDiffableDataSource を使う場合は DataSource#apply() の引数に snapshot を渡してあげることで画面に反映させます。

ここではデータを画面に反映させるためのメソッドを定義します。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    func updateSnapshot(sectionData: Dictionary<SectionType, [SectionItem]>) {
        var snapshot = Snapshot()
        let sections = Array(sectionData.keys)
        snapshot.appendSections(sections)
        for data in sectionData {
            snapshot.appendItems(data.value, toSection: data.key)
        }
        dataSource.apply(snapshot)
    }
}

SectionType と SectionItem 配列を持った Dictionary を引数にもらって、Section ごとに反映するデータを突っ込んでいます。Section が増えてもこの方法でうまく反映できるはずです(試していませんが。。)。

14. ViewController から updateSnapshot() を呼ぶ

表示する画像の分だけ SectionItem.carouselBannerCell を作って updateSnapshot() に渡してあげます。

ViewController.swift
class ViewController: UIViewController {
    ...
    
    let images = [
        UIImage(named: "building"),
        UIImage(named: "flowers"),
        UIImage(named: "mountains"),
        UIImage(named: "dog"),
        UIImage(named: "boat")
    ]
    
    override func viewDidLoad() {
        ...
	
        let sectionItems = images.map { SectionItem.carouselBannerCell($0!) }
        let sectionData = [SectionType.carouselBanner: sectionItems]
        updateSnapshot(sectionData: sectionData)
     }
}

ここまでで、とりあえずカルーセルは完成できた思います。
アプリを起動させて動作を確認してみてください。

15. CarouselBannerFooter を作って CollectionView に登録

次にカルーセルのすぐ下に PageControl を置きます。
新規でもう1つ Section を追加して表示させることもできますが、今回は Section にフッターとして NSCollectionLayoutBoundarySupplementaryItem を追加することで実装してみます。

まずは Section に追加するフッターの View を作成します。

CarouselBannerFooter.swift
class CarouselBannerFooter: UICollectionReusableView {
    static let id = "CarouselBannerFooter"
    static let kind = "CarouselBannerFooterKind"
    
    var pageControl: UIPageControl!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        pageControl = UIPageControl(frame: CGRect(origin: .zero, size: self.bounds.size))
        pageControl.pageIndicatorTintColor = UIColor.systemGray4
        pageControl.currentPageIndicatorTintColor = UIColor.gray
	// デフォルトでは PageControl がタップできるが、不要なので外しておく
	pageControl.isUserInteractionEnabled = false
        self.addSubview(pageControl)
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
}

CollectionView から dequeue するときに id が必要になりますが、SupplementaryView を取り出すときは elementKind も必要になるため定義しておきます。

これを CollectionView に登録します。

ViewController.swift
class ViewController: UIViewController {
    ...
    
    override func viewDidLoad() {
        ...
	  
        collectionView.register(CarouselBannerFooter.self, forSupplementaryViewOfKind: CarouselBannerFooter.kind, withReuseIdentifier: CarouselBannerFooter.id)
	
	...
     }
}

16. DataSource 内に supplementaryViewProvider() を作る

Cell や Section の Provider を作ったのと同じように、supplementaryViewProvider() も定義します。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    func supplementaryViewProvider(collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) -> UICollectionReusableView {
        switch elementKind {
        case CarouselBannerFooter.kind:
            let carouselBannerFooter = collectionView.dequeueReusableSupplementaryView(ofKind: elementKind, withReuseIdentifier: CarouselBannerFooter.id, for: indexPath) as? CarouselBannerFooter
	    carouselBannerFooter?.pageControl.numberOfPages = images.count
            return carouselBannerFooter!
        default:
            return UICollectionReusableView()
        }
    }
}

17. DataSource に supplementaryViewProvider を登録する

ViewController.swift
class ViewController: UIViewController {
    ...
    
    override func viewDidLoad() {
        ...
	
        dataSource.supplementaryViewProvider = supplementaryViewProvider
	
	...
     }
}

18. Section にフッターをセットする

carouselBannerSection() 内で、Section にフッターをセットします。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    func carouselBannerSection() -> NSCollectionLayoutSection {
        ...
	
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .groupPaging
        
	// ここから追加
        let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50.0))
        let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: CarouselBannerFooter.kind, alignment: .bottom)
        section.boundarySupplementaryItems = [sectionFooter]
        
        return section
    }
}

ここまでで、とりあえず PageControl が表示されたと思います。

19. CarouselBannerFooter を ViewController のメンバ変数として扱う

次に、ページが変更されたとき PageControl の位置も変更されるように実装を行います。
ページ変更時、CarouselBannerFooter へアクセスできるように、CarouselBannerFooter を ViewController のメンバ変数として定義しておきます。

ViewController.swift
class ViewController: UIViewController {
    ...
    
    var carouselBannerFooter: CarouselBannerFooter?
    
    ...
}

carouselBannerFooter を取り出すとき、ローカル変数ではなくメンバ変数に代入します。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    func supplementaryViewProvider(collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) -> UICollectionReusableView {
        switch elementKind {
        case CarouselBannerFooter.kind:
            // let carouselBannerFooter = collectionView.dequeueReusableSupplementaryView(ofKind: elementKind, withReuseIdentifier: CarouselBannerFooter.id, for: indexPath) as? CarouselBannerFooter
	    // ローカル変数ではなくメンバ変数に代入
	    carouselBannerFooter = collectionView.dequeueReusableSupplementaryView(ofKind: elementKind, withReuseIdentifier: CarouselBannerFooter.id, for: indexPath) as? CarouselBannerFooter
            carouselBannerFooter?.pageControl.numberOfPages = images.count
            return carouselBannerFooter!
        default:
            return UICollectionReusableView()
        }
    }
}

20. ページが変更されたことを検知する

PageControl の位置を変えるためには、ページの位置が変更されたことを検知して通知する必要があります。

Section のスクロール検知には visibleItemsInvalidationHandler() が使えます。アイテムの x 軸の位置とコンテンツの幅からページが変更されたかどうかを判断します。
コールバック用に DidChangePage という typealias を定義して carouselBannerSection() の引数として受け取ります。ページが変更されたらこのコールバックを発火させます。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    typealias DidChangePage = (Int) -> Void
    
    ...
    
    func carouselBannerSection(didChangePage: @escaping DidChangePage) -> NSCollectionLayoutSection {
        ...
	
        section.boundarySupplementaryItems = [footer]
        
	// スクロールを検知
        section.visibleItemsInvalidationHandler = { (visibleItems, offset, env) in
            guard offset.x >= 0 else { return }
            let contentWidth = env.container.contentSize.width
	    // アイテムの x 軸の位置 / コンテンツ幅(今回は画面幅)の余りが 0 のとき、ページが変更されたと判断できる
            if (offset.x.remainder(dividingBy: contentWidth) == 0.0) {
                let page = offset.x / contentWidth
                didChangePage(Int(page))
            }
        }
        
        return section
    }
}

コンテンツ幅(画面幅)が 300 の場合、各アイテムの x 軸の位置は 0 → 300 → 600... という感じでコンテンツ幅の倍数になるはずです。よって「アイテムの x 軸の位置/コンテンツ幅 の余り = 0」のとき、ページは変更されたと判断できます。

(この場合、ページが完全に次の画像に切り替わったタイミングで通知されますが、どのタイミングで通知するかは議論が分かれるところかと思うので、いろいろとカスタマイズしてみてください。)

21. ページが変更されたときのコールバックを定義する

最後に、ページが変更されたとき PageControl の位置を変更する処理を実装します。

ViewController+DataSource.swift
extension ViewController {
    ...
    
    func sectionProvider(sectionIndex: Int, environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? {
        switch sectionIndex {
        case SectionType.carouselBanner.rawValue:
	    // 引数に DidChangePage のコールバックを追加
            return self.carouselBannerSection { page in
                self.carouselBannerFooter?.pageControl.currentPage = page
            }
        default:
            fatalError()
        }
    }
    
    ...
}

これでカルーセルがスワイプされたら PageControl の位置も変更されるようになるかと思います。
アプリを実行して試してみてください。

Discussion