В этой статье хочется познакомиться к UICollectionViewCompositionalLayout и с его помощью создать UICollectionView с прокручивающимися секциями, а затем еще немного более их кастомизировать, чтобы показать как просто с помощью UICollectionViewCompositionalLayout мы можем создавать UICollectionView, которые выглядят именно так как нам необходимо.

Статья основана на документации apple Implementing Modern Collection Views

https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

Compositional layouts - это декларативный вид API, который позволяет нам создавать большие макеты путем объединения небольших групп макетов. Compositional layouts имеют иерархию, состоящую из Item, Group, Sections, and Layout.

Чтобы создать любой Compositional layouts, необходимо реализовать следующие четыре класса:

NSCollectionLayoutSize: Размеры ширины и высоты относятся к типу NSCollectionLayoutDimension, которые могут быть определены путем установки доли ширины/высоты макета (в процентах по отношению к его контейнеру) или путем установки абсолютных или расчетных размеров.

NSCollectionLayoutItem: Это ячейка нашего макета, которая отображается на экране в зависимости от размера.

NSCollectionLayoutGroup: содержит NSCollectionLayoutItem в горизонтальной, вертикальной или пользовательской формах.

NSCollectionLayoutSection: используется для инициализации секции путем передачи NSCollectionLayoutGroup. Секции в конечном итоге составляют СompositionalLayout.

Sections и items в СompositionalLayout соответствуют Sections и items класического СollectionViewDataSource. Группы, однако, не имеют эквивалента в СollectionViewDataSource и не отображают содержимое в виде элементов / ячеек. Они используются исключительно для описания расположения наших Items в рамках Section.

Давайте приступим к практике и создадим CollectionView cо скролящимися рядами. Мы увидим наскоолько просто это можно сделать с помощью СompositionalLayout по сравнению с традиционным подходом, когда для достижения подобного эффекта приходилось внедрять CollectionView в другие CollectionView.

Создадим класс ViewController, который сооответствует протоколу UICollectionViewDelegate:

class ViewController: UIViewController, UICollectionViewDelegate {

}

Добавим в него два свойства dataSource и collectionView:

var dataSource: UICollectionViewDiffableDataSource<Int, Int>! = nil
    
var collectionView: UICollectionView! = nil

Создадим класс ячейки для нашей коллекции:

class TextCell: UICollectionViewCell {
    let label = UILabel()
    static let reuseIdentifier = "text-cell-reuse-identifier"

    override init(frame: CGRect) {
        super.init(frame: frame)
        configure()
    }
    required init?(coder: NSCoder) {
        fatalError("not implemnted")
    }
}

В экстеншене зададим необходимые констрейнты:

extension TextCell {
    func configure() {
        label.translatesAutoresizingMaskIntoConstraints = false
        label.adjustsFontForContentSizeCategory = true
        contentView.addSubview(label)
        label.font = UIFont.preferredFont(forTextStyle: .caption1)
        let inset = CGFloat(10)
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset),
            label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset),
            label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset),
            label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset)
            ])
    }
}

Мы подошли непосредственно к созданию лэйаута. Вынесем его в отдельный метод:

func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

// Для начала создаем Item. Задаем ему ширину и высоту группы, 
// в который он будет помещен. Для этого задаем widthDimension и 
// heightDimension значения .fractionalWidth(1.0) и .fractionalHeight(1.0) соответственно.
// Так же задаем у Item отступы через свойство contentInsets.
            
        let item = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(1.0)))
            item.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)


// Создаем Group элементы которой распологаются горизонтально. 
// Задаем ей ширину 85% ширины экрана и высоту 40% высоты экрана.
// Добавляем в Group созданный выше Item
            
        let containerGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                                  heightDimension: .fractionalHeight(0.4)),
            subitems: [item])
            
// Создаем Section, передавая в инициализатор созданныую выше Group. 
// Для того чтобы секция имела взможность скролиться задаем ее свойство  
// orthogonalScrollingBehavior.
       
        let section = NSCollectionLayoutSection(group: containerGroup)
        section.orthogonalScrollingBehavior = .continuous

        return section

    }
     return layout
}

Теперь мы можем создать СollectionView, передав в инициализатор лэйаут, кторый возвращает созданный нами метод.

Вынемем создание и конфигурацию СollectionView в отдельный метод.

 func configureHierarchy() {
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.backgroundColor = .systemMint
        view.addSubview(collectionView)
        collectionView.delegate = self
 }

И вызовем его во viewDidLoad.

 override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.title = "Scrollable rows"
        configureHierarchy()
  }

Нам остался последний шаг. Создать и сконфигурировать DataSource.

func configureDataSource() {

// Создадим массив эмоджи, которые мы будем отображать в ячейках нашей таблицы

        let emojies = ["????", "????", "????", "????", "????", "????","????", "????", "????"]
   
// Создадим  CellRegistration с типом нашей кастомной ячейки. И сконфигурируем нашу ячейку.
   
        let cellRegistration = UICollectionView.CellRegistration<TextCell, Int> { (cell, indexPath, identifier) in
            
            cell.label.text = "\(emojies[indexPath.item])"
            cell.contentView.backgroundColor = .systemCyan
            cell.contentView.layer.borderColor = UIColor.black.cgColor
            cell.contentView.layer.borderWidth = 1
            cell.contentView.layer.cornerRadius = 8
            cell.label.textAlignment = .center
            cell.label.font = UIFont.systemFont(ofSize: 150)
        }
       
// Создадим  UICollectionViewDiffableDataSource передав в инициализатор collectionView.
 
        dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
            
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
        }
   
// Создадим  NSDiffableDataSourceSnapshot в котором укажем количество секций 
// и айтемов в секции.
// NSDiffableDataSourceSnapshot — это представление состояния данных во view 
// в определенный момент времени.

// DiffableDataSource используют Snapshot для предоставления данных 
// для коллекций и таблиц. Мы используем Snapshot для настройки 
// начального состояния данных, отображаемых в представлении, 
// мы так же используете Snapshot для отражения изменений в данных, 
// отображаемых в представлении.
// Данные в Snapshot состоят из sections  и items, которые мы хотим отобразить,
// в том порядке, который мы сами определяем. 
// Мы настраиваем вид коллекции, добавляя, удаляя или перемещая 
// sections и items.
  
        var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
        var identifierOffset = 0
        let itemsPerSection = 9
        for section in 0..<5 {
            snapshot.appendSections([section])
            let maxIdentifier = identifierOffset + itemsPerSection
            snapshot.appendItems(Array(identifierOffset..<maxIdentifier))
            identifierOffset += itemsPerSection
        }
        dataSource.apply(snapshot, animatingDifferences: false)
    }

Вызываем наш метод во viewDidLoad.

 override func viewDidLoad() {
        super.viewDidLoad()
        navigationItem.title = "Scrollable rows"
        configureHierarchy()
        configureDataSource()
    }

Полный код приложения теперь выглядит следующим образом:

import UIKit

class ViewController: UIViewController, UICollectionViewDelegate {
    
    var dataSource: UICollectionViewDiffableDataSource<Int, Int>! = nil
    var collectionView: UICollectionView! = nil

    override func viewDidLoad() {
        super.viewDidLoad()
        navigationItem.title = "Scrollable rows"
        configureHierarchy()
        configureDataSource()
    }
    
    func configureHierarchy() {
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.backgroundColor = .systemMint
        view.addSubview(collectionView)
        collectionView.delegate = self
    }
    
    func configureDataSource() {
        let emojies = ["????", "????", "????", "????", "????", "????","????", "????", "????"]
        
        let cellRegistration = UICollectionView.CellRegistration<TextCell, Int> { (cell, indexPath, identifier) in
            
            cell.label.text = "\(emojies[indexPath.item])"
            cell.contentView.backgroundColor = .systemCyan
            cell.contentView.layer.borderColor = UIColor.black.cgColor
            cell.contentView.layer.borderWidth = 1
            cell.contentView.layer.cornerRadius = 8
            cell.label.textAlignment = .center
            cell.label.font = UIFont.systemFont(ofSize: 150)
        }
        
        dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
            
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
        }
        
        var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
        var identifierOffset = 0
        let itemsPerSection = 9
        for section in 0..<5 {
            snapshot.appendSections([section])
            let maxIdentifier = identifierOffset + itemsPerSection
            snapshot.appendItems(Array(identifierOffset..<maxIdentifier))
            identifierOffset += itemsPerSection
        }
        dataSource.apply(snapshot, animatingDifferences: false)
    }
    
    func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

            
            let item = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(1.0)))
            item.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

            
            let containerGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                                  heightDimension: .fractionalHeight(0.4)),
                subitems: [item])
            
            
            let section = NSCollectionLayoutSection(group: containerGroup)
            section.orthogonalScrollingBehavior = .continuous

            return section

        }
        return layout
    }
}

class TextCell: UICollectionViewCell {
    let label = UILabel()
    static let reuseIdentifier = "text-cell-reuse-identifier"

    override init(frame: CGRect) {
        super.init(frame: frame)
        configure()
    }
    required init?(coder: NSCoder) {
        fatalError("not implemnted")
    }
}

extension TextCell {
    func configure() {
        label.translatesAutoresizingMaskIntoConstraints = false
        label.adjustsFontForContentSizeCategory = true
        contentView.addSubview(label)
        label.font = UIFont.preferredFont(forTextStyle: .caption1)
        let inset = CGFloat(10)
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset),
            label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset),
            label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset),
            label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset)
            ])
    }
}

Запускаем приложение и получаем CollectionView с рядами, которые можно скролить.

Но что если мы решили изменить дизайн и например объединять ячейки по три, две из которых расположены справа от первой и меньше по размеру.

С помощью UICollectionView Compositional Layout мы можем сделать это с написав лишь несколько строк кода. 

Во-первых, так как мы хотим уменьшить размер некоторых ячеек, давайте изменим размер шрифта в ячейки на 50 чтобы наши эмоджи аккуратно отображались в ячейках.

cell.label.font = UIFont.systemFont(ofSize: 50)

Во-вторых, в методе createLayout удалим создание item:

let item = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(1.0)))
            item.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

В-третьих, создадим leadingItem. Зададим ему ширину равную 70% ширины группы.

let leadingItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                                  heightDimension: .fractionalHeight(1.0)))
            leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

В-четвертых, создадим trailingItem. Зададим ему ширину равную 100% ширины группы и высоту равную 30%.

 let trailingItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(0.3)))
            trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

В-пятых, создадим trailingGroup. Зададим ей ширину равную 30% ширины группы (70% у нас будет занимает leadingItem, которому мы уже задали соответствующую ширину). В поле  subitem отмечаем, что группа у нас будет состоять из двух trailingItem.

let trailingGroup = NSCollectionLayoutGroup.vertical(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                                  heightDimension: .fractionalHeight(1.0)),
                subitem: trailingItem, count: 2)

В-шестых, создадим containerGroup. Зададим ей ширину 85% экрана и высоту 40%. Поместим в нее leadingItem и trailingGroup.

let containerGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                                  heightDimension: .fractionalHeight(0.4)),
                subitems: [leadingItem, trailingGroup])

Обновленный код метода createLayout выглядит следующим образом:

func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

            let leadingItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                                  heightDimension: .fractionalHeight(1.0)))
            leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            

            let trailingItem = NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(0.3)))
            trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

            let trailingGroup = NSCollectionLayoutGroup.vertical(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                                  heightDimension: .fractionalHeight(1.0)),
                subitem: trailingItem, count: 2)

            let containerGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                                  heightDimension: .fractionalHeight(0.4)),
                subitems: [leadingItem, trailingGroup])
            
            
            let section = NSCollectionLayoutSection(group: containerGroup)
            section.orthogonalScrollingBehavior = .continuous

            return section

        }
        return layout
    }

Запускаем приложение и теперь у нас более сложный и интересный вид коллекции, которого мы добились за шесть простых шагов.

Более подробно про CompositionalLayout и различные виды представлений, которые мы можем создавать с его помощью можно прочитать в документации Apple, а так же изучив демо приложение Apple, ссылка на которое так же находиться на странице документации.

https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

Комментарии (2)


  1. dvmald
    13.06.2023 13:29

    Насколько это актуально в эпоху SwiftUI?


    1. svgnovosibirsk Автор
      13.06.2023 13:29

      На проме огромное количество кода написано еще на Objective-C, так что UIKit будет актуален еще очень долго. Об эпохе SwiftUI, особенно в продакшене, речи пока не идет =)