🎨

【UIKit】CompositionalLayoutを使ったカルーセル

2024/02/22に公開

はじめに

UICollectionViewCompositionalLayoutを使って、要素ごとにページングするカルーセルを実装します。

https://github.com/skw398/UIKit-Carousel-CompositionalLayout

実装

NSCollectionLayoutSection

enum Section {
    
    case large, landscape, square
    
    private var horizontalInset: CGFloat { 16 }
    
    func layoutSection(frame: CGRect) -> NSCollectionLayoutSection {
        switch self {
        case .large:
            let itemSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalHeight(1)
            )
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            
            let width = frame.width - horizontalInset * 2
            let groupSize = NSCollectionLayoutSize(
                // 💡
                widthDimension: .absolute(width),
                heightDimension: .absolute(width * 0.7)
            )
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
            
            let layoutSection = NSCollectionLayoutSection(group: group)
            // 💡
            layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
            layoutSection.interGroupSpacing = 8
            
            return layoutSection
            
        case .landscape:
            // ...
            return layoutSection
            
        case .square:
            // ...
            return layoutSection
        }
    }
}
全体コード
enum Section {
    
    case large, landscape, square
    
    private var horizontalInset: CGFloat { 16 }
    
    func layoutSection(frame: CGRect) -> NSCollectionLayoutSection {
        switch self {
        case .large:
            let itemSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalHeight(1)
            )
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            
            let width = frame.width - horizontalInset * 2
            // 💡
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .absolute(width),
                heightDimension: .absolute(width * 0.7)
            )
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
            
            let layoutSection = NSCollectionLayoutSection(group: group)
            // 💡
            layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
            layoutSection.interGroupSpacing = 8
            
            return layoutSection
            
        case .landscape:
            let itemSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalHeight(1)
            )
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            
            let groupSize = NSCollectionLayoutSize(
                // 💡
                widthDimension: .fractionalWidth(0.6),
                heightDimension: .fractionalWidth(0.6 / 2)
            )
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
            
            let layoutSection = NSCollectionLayoutSection(group: group)
            // 💡
            layoutSection.orthogonalScrollingBehavior = .groupPaging
            layoutSection.interGroupSpacing = 8
            layoutSection.contentInsets = .init(
                top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset
            )
            
            return layoutSection
            
        case .square:
            let itemSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalHeight(1)
            )
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            // 💡
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(0.4),
                heightDimension: .fractionalWidth(0.4)
            )
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
            
            let layoutSection = NSCollectionLayoutSection(group: group)
            // 💡
            layoutSection.orthogonalScrollingBehavior = .groupPaging
            layoutSection.interGroupSpacing = 8
            layoutSection.contentInsets = .init(
                top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset
            )
            
            return layoutSection
        }
    }
}

3つのセクションそれぞれのNSCollectionLayoutSectionを作っています。

orthogonalScrollingBehavior.groupPagingCenteredまたは.groupPagingを設定することで、コンテンツが慣性スクロールせず、groupごとにページングされるカルーセルの挙動になります。

さらにカルーセルらしく隣の要素がちょっと見えるようにするために、以下を設定します。

  • orthogonalScrollingBehavior.groupPagingCenteredの場合

    • groupのNSCollectionLayoutSizewidthDimensionに割合を渡す。
      • ex. .fractionalWidth(0.9)
    • groupのNSCollectionLayoutSizewidthDimensionに固定値を渡す。
      • ex. .absolute(view.frame.width - 32)
  • orthogonalScrollingBehavior.groupPagingの場合

    • NSCollectionLayoutSectioncontentInsetsの水平方向を指定する。
      • ex. NSDirectionalEdgeInsets(top: 0, leading: 32, bottom: 0, trailing: 32)

今回はorthogonalScrollingBehavior.groupPagingのセクションと.groupPagingCenteredのセクションとで見えている隣の要素の部分のptを同じにしたいため、.groupPagingCenteredのセクションでは親の横幅を使ってabsolute(_:)を渡しています。

UIViewController

final class ViewController: UIViewController {

    private let sections: [Section] = [
        .large,
        .landscape,
        .square
    ]
    
    private lazy var collectionView: UICollectionView = {
        let config = UICollectionViewCompositionalLayoutConfiguration()
        config.interSectionSpacing = 32
        
        let collectionViewLayout = UICollectionViewCompositionalLayout(
            sectionProvider: { [weak self] (index, environment) in
                guard let self else { return nil }
                // 💡
                return self.sections[index].layoutSection(frame: self.view.frame)
            },
            configuration: config
        )

        let collectionView = UICollectionView(
            frame: .zero,
            // 💡
            collectionViewLayout: collectionViewLayout
        )
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.dataSource = self
        collectionView.register(
            CarouselContentCell.self, forCellWithReuseIdentifier: String(describing: CarouselContentCell.self)
        )
        
        return collectionView
    }()
    
    // ...
}
全体コード
final class ViewController: UIViewController {
    
    private let sections: [Section] = [
        .large,
        .landscape,
        .square
    ]
    
    private lazy var collectionView: UICollectionView = {
        let config = UICollectionViewCompositionalLayoutConfiguration()
        config.interSectionSpacing = 32
        
        let collectionViewLayout = UICollectionViewCompositionalLayout(
            sectionProvider: { [weak self] (index, environment) in
                guard let self else { return nil }
                // 💡
                return self.sections[index].layoutSection(frame: self.view.frame)
            },
            configuration: config
        )

        let collectionView = UICollectionView(
            frame: .zero,
            // 💡
            collectionViewLayout: collectionViewLayout
        )
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.dataSource = self
        collectionView.register(CarouselContentCell.self, forCellWithReuseIdentifier: String(describing: CarouselContentCell.self))
        
        return collectionView
    }()
    
    private let colors: [UIColor] = [.systemMint, .systemTeal, .systemCyan, .systemBlue, .systemIndigo]
    
    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(collectionView)
        
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
    }
}

extension ViewController: UICollectionViewDataSource {
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        sections.count
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        colors.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CarouselContentCell.self), for: indexPath) as? CarouselContentCell
        else { return .init() }
        cell.configure(backgroundColor: colors[indexPath.row])
        return cell
    }
}

UICollectionViewCompositionalLayoutsectionProviderの戻り値に、定義したNSCollectionLayoutSectionを返します。
UICollectionViewCompositionalLayoutを使ってUICollectionViewを初期化します。

UICollectionViewCell

全体コード
final class CarouselContentCell: UICollectionViewCell {
    
    private lazy var carouselContentView: UIView = {
        let carouselContentView = UIView()
        carouselContentView.translatesAutoresizingMaskIntoConstraints = false
        carouselContentView.layer.cornerRadius = 16
        return carouselContentView
    }()
    
    func configure(backgroundColor: UIColor) {
        carouselContentView.backgroundColor = backgroundColor
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        contentView.addSubview(carouselContentView)
        
        NSLayoutConstraint.activate([
            carouselContentView.topAnchor.constraint(equalTo: contentView.topAnchor),
            carouselContentView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            carouselContentView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            carouselContentView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

一般的なUICollectionViewCellの実装なので割愛します。

さいごに

UICollectionViewCompositionalLayoutを使って、プロパティの設定だけでカルーセルを実装することができました。

株式会社Never

Discussion