Compositional Layout в iOS 13. Основы

    Добрый день,


    На практике iOS разработчик часто сталкивается с задачей показа большого количества информации в виде списка или в виде коллекции, как правило, для этого отлично подходят UITableView или UICollectionView. Также часто встречается задача реализации экрана, который представляет собой комбинацию списка и коллекции.


    В данной статье рассмотрим, какие новые возможности принесла iOS 13 для реализации этой задачи.



    Введение


    Представьте вам пришла задача реализовать экран, на котором информация может скролится как и вертикально, так и горизонтально, каждая секция имеет свой лайаут, например как в приложении AppStore.



    Как вы будете такое реализовывать?


    До iOS 13, скорее всего вы бы это делали так


    • Таблица, в каждой ячейке которой находится коллекция
    • Коллекция, в каждой ячейке которой находится другая коллекция
    • Коллекция, со своим кастомным лайаутом
    • Свой кастомный наследник UIScrollView

    Все эти решения содержат ряд проблем.


    • Это сложно реализовывать и поддерживать
    • Сложно добиться хорошей производительности
    • Сложно сделать анимацию
    • Сложно сделать поддержку self-sizing ячеек
    • Сложно сделать одновременную поддержку iPad и iPhone
    • Решение заточено только на определенный сценарий

    Разработчики крутились как могли и так было вплоть до iOS 12.


    Compositional Layout


    В iOS 13 Apple представила нам новый способ построения лайаута коллекции, он призван сильно упростить реализацию таких экранов — Compositional Layout, этот подход основан на 3-х принципах — компоновка, гибкость и скорость.


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



    Compositional Layout — использует декларативный подход, это значит он описывает как должен выглядеть лайаут, а не говорит как его сделать.


    Основные концепты так же включают в себя


    • Композиция маленьких смежных групп вместе
    • Смежные группы располагаются по линиям
    • Использование композиции вместо наследования

    Композиция маленьких смежных групп вместе — означает, что мы берем отдельные элементы и комбинируем их в группы, далее мы можем комбинировать получившиеся группы каким-то другим способом и получать уже новую группу. Таким образом мы строим наш лайаут, комбинируя его из разных групп элементов.


    Смежные группы располагаются по линиям — принцип grid-лайаута, как стандартный flow-лайаут располагается по линиям, так и группы располагаются по линиям.


    Использование композиции вместо наследования — наследование обладает некоторыми недостатками, поэтому лучше создать отдельный объект, сконфигурировать и использовать его.


    Давайте рассмотрим основные классы Compositional Layout. Для построения лайаута нам понадобятся эти 4 класса:


    • NSCollectionLayoutSize — определяет размер элемента;
    • NSCollectionLayoutItem — определяет элемент лайаута;
    • NSCollectionLayoutGroup — определяет группу элементов лайаута, сам по себе тоже является элементом лайута;
    • NSCollectionLayoutSection — определяет секцию для конкретной группы элементов;
    • UICollectionViewCompositionalLayout — определяет сам лайаут.

    Посмотрим на определение UICollectionViewCompositionalLayout.


    class UICollectionViewCompositionalLayout : UICollectionViewLayout { ... }

    Как вы видите UICollectionViewCompositionalLayout является наследником UICollectionViewLayout, а значит может использоваться при создании UICollectionView.


    List Layout


    Давайте рассмотрим как это работает на практике. Для этого создадим новым storyboard-проект, с пустым UIViewController, в методе viewDidLoad() будем создавать UICollectionView программно и в init(frame:, layout:) передадим наш объект UICollectionViewCompositionalLayout. Весь код можно будет найти по ссылке в конце статьи, а сейчас сосредоточимся на самом создании UICollectionViewCompositionalLayout.


    Для наглядности покажем, конфигурацию нашего лайаута. Справа покажем какого размера элементы лайаута, красный для ячейки, оранжевый для группы, желтый для секции.



    И сам метод создания лайаута.


        private func createLayout() -> UICollectionViewLayout {
            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(44))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
    
            let section = NSCollectionLayoutSection(group: group)
    
            let layout = UICollectionViewCompositionalLayout(section: section)
            return layout
        }
    
        private func configureHierarchy() {
            collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
            // ...
        }

    Объект itemSize определяет высоту и ширину элемента, в параметрах нужно указать размер для каждого измерения, за определение измерения отвечает класс NSCollectionLayoutDimension. Рассмотрим более подробно какие параметры мы можем указать.


    • fractionalWidth(_:), относительная величина, принимает значения от 0 до 1 включительно, определят ширину относительно ширины контейнера, 0.5 — ширина равна половине ширины группы (контейнера) элемента;
    • fractionalHeight(_: ), относительная величина, принимает значения от 0 до 1 включительно, определят высоту относительно высоты контейнера, 0.5 — высота равна половине высоты группы (контейнера) элемента;
    • absolute(_: ), абсолютная величина, указывает точный размер, например 44.0;
    • estimated(_:), приблизительная величина, точный размер будет известен на этапе рендеринга.

    В нашем случае itemSize имеет размеры widthDimension = .fractionalWidth(1.0), heightDimension = .fractionalHeight(1.0), что означает что его высота и ширина равна высоте и ширине группы, которая содержит этот элемент. После этого мы можем создать сам элемент и передать ему созданный размер.


    Далее аналогичным образом мы задаем размер для группы, widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44), означает что ширина группы будет равна ширине секции, которая содержит эту группу, а высота будет иметь абсолютное значение 44.0.


    Саму группу можно создать разными способами:


    • horizontal(layoutSize: , subitem: , count: ), определяет группу, которая будет иметь определенное количество элементов равного размера расположенных горизонтально;
    • horizontal(layoutSize:, subitems: ), определяет группу, которая будет повторять элементы до тех пор, пока не закончится место по горизонтали;
    • vertical(layoutSize:, subitem:, count:), определяет группу, которая будет иметь определенное количество элементов равного размера расположенных вертикально;
    • vertical(layoutSize:, subitems: ), определяет группу, которая будет повторять элементы до тех пор, пока не закончится место по вертикали;
    • custom(layoutSize:, itemProvider: ), определяет кастомную группу, где клиент указывает расположение элементов сам.

    Как возможно вы подметили, .horizontal и .vertical методы располагают элементы, как в обычном стеке, горизонтально и вертикально соответсвенно, .custom позволяет указывать расположение элементов самому, например можно написать логику, чтобы элементы располагались по диагонали.


    Наш способ создания группы NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) говорит о том, что элементы заполняются в горизонтальном порядке и до тех пор, пока есть место.


    Далее мы создаем секцию NSCollectionLayoutSection(group: group), которая будет содержать нашу созданную группу. И эту секцию мы будем использовать для создания нашего Compositional Layout.


    Выглядит неплохо, но сейчас это больше похоже на таблицу, чем на коллекцию. Как мы можем это изменить?


    Grid Layout



    И так, как нам добиться такого результата? Есть несколько способов это сделать, один из них это просто задать новый размер элемента в группе, чтобы элемент занимал не всю группу по ширине, а только ее четверть.


    let itemSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0/4.0), // <---
                heightDimension: .fractionalHeight(1.0))

    Получилось, теперь у нас всегда будет 4 колонки в независимости от девайса, его размера и его ориентации.



    Two Column Layout


    Есть другой способ, как можно установить количество колонок в коллекции, его можно задать явно при создании группы, тогда группа сама посчитает какой ширины должны быть ее элементы, чтобы удовлетворять условию, в этом случаем widthDimension элемента игнорируется.



    Как это описывается в коде.


        private func createLayout() -> UICollectionViewLayout {
            let spacing: CGFloat = 10
            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(44))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2) // <---
            group.interItemSpacing = .fixed(spacing)
    
            let section = NSCollectionLayoutSection(group: group)
            section.contentInsets = .init(top: spacing, leading: spacing, bottom: spacing, trailing: spacing)
            section.interGroupSpacing = spacing
    
            let layout = UICollectionViewCompositionalLayout(section: section)
            return layout
        }

    Так же мы добавили отступы между элементами в группе group.interItemSpacing = .fixed(spacing), отступы между группами в секции section.interGroupSpacing = spacing, а так же установили contentInset у секции section.contentInsets = .init(top: spacing, leading: spacing, bottom: spacing, trailing: spacing). Таким образом ячейка со всех сторон имеет отступ равый spacing.


    Как вы могли заметить, расстояние между элементами в группе interItemSpacing задается не с помощью скалярной величины, как расстояние между группами в секции interGroupSpacing, а как объект класса NSCollectionLayoutSpacing.


    class NSCollectionLayoutSpacing : NSObject, NSCopying {
        class func fixed(_ fixedSpacing: CGFloat) -> Self // i.e. ==
        class func flexible(_ flexibleSpacing: CGFloat) -> Self // i.e. >=
        // ...
    }

    • func fixed(_ fixedSpacing: ), задает фиксированое расстояние между элементами;
    • func flexible(_ flexibleSpacing:), задает не строгое расстояние между элементами, часто используется для того, чтобы заполнять свободное пространство в группе;

    Inset Items Grid Layout


    Как добиться того, чтобы ячейки были квадратными, а не прямоугольными? Как мы знаем, у квадрата ширина и высота равны, сделаем это методом указания относительных величин. Например, мы хотим иметь 5 колонок, поэтому можем указать, чтобы ширина элемента была равна 1.0/5.0 от ширины группы, а высота была равна высоте группы. Для группы же укажем чтобы она имела ширину секции, а высоту равную 1.0/5.0 ширины секции. Так как ширина группы совпадает с шириной секции, получим элементы с равной шириной и высотой.
    Так же покажем, что можно задавать .contentInsets и у NSCollectionLayoutItem.



    Как это описывается в коде.


        private func createLayout() -> UICollectionViewLayout {
            let spacing: CGFloat = 10
            let itemSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(0.2),
                heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = .init(top: spacing, leading: spacing, bottom: spacing, trailing: spacing)
    
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalWidth(0.2))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
    
            let section = NSCollectionLayoutSection(group: group)
            let layout = UICollectionViewCompositionalLayout(section: section)
            return layout
        }

    Distinct Sections Layout


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


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


    typealias UICollectionViewCompositionalLayoutSectionProvider = (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection?
    
    class UICollectionViewCompositionalLayout : UICollectionViewLayout {
       init(section: NSCollectionLayoutSection)
       init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)
       // ...
    }

    UICollectionViewCompositionalLayoutSectionProvider принимает 2 параметра, первый это номер секции, второй окружение, возвращает же блок лайаут для данной секции.


    Давайте определим 3 секции, для первой секции определим лайаут в виде списка, для второй в виде grid-лайаута с 3-я колонками, для третьей так же в виде grid-лайаута, но с 5-ю колонками.



    Как это описывается в коде.


        enum Section: Int, CaseIterable {
            case list
            case grid3
            case grid5
    
            var columnCount: Int {
                switch self {
                case .list:
                    return 1
                case .grid3:
                    return 3
                case .grid5:
                    return 5
                }
            }
        }
    
        private func createLayout() -> UICollectionViewLayout {
            let layout = UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
    
                guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
                let columns = sectionKind.columnCount
    
                let itemSize = NSCollectionLayoutSize(
                    widthDimension: .fractionalWidth(1.0),
                    heightDimension: .fractionalHeight(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)
                item.contentInsets = .init(top: 2, leading: 2, bottom: 2, trailing: 2)
    
                let groupHeight = columns == 1 ?
                    NSCollectionLayoutDimension.absolute(44) :
                    NSCollectionLayoutDimension.fractionalWidth(0.2)
    
                let groupSize = NSCollectionLayoutSize(
                    widthDimension: .fractionalWidth(1.0),
                    heightDimension: groupHeight)
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columns)
    
                let section = NSCollectionLayoutSection(group: group)
                section.contentInsets = .init(top: 20, leading: 20, bottom: 20, trailing: 20)
                return section
            }
            return layout
        }
    // ...

    Adaptive Sections Layout


    Если нам нужно сделать лайаут более адаптивным для секции, то для этих целях нам поможет NSCollectionLayoutEnvironment класс, он предоставляет размер контейнера и traitCollection.


    protocol NSCollectionLayoutEnvironment : NSObjectProtocol {
        var container: NSCollectionLayoutContainer { get }
        var traitCollection: UITraitCollection { get }
    }

    Если приложение запускается в сompact size-class по вертикали, как в лэндскейп ориентации айфона, то мы ходтим адаптировать лайаут таким образом, чтобы проставлялась фиксированная высота, дабы контент помещался на экране.


    Мы можем проверять traitCollection.verticalSizeClass у layoutEnvironment и проставлять нужную высоту для группы.


    Так же мы можем смотреть ширину в container.effectiveContentSize у layoutEnvironment и проставлять нужное количество колонок для секции.



    А вот так это будет выглядеть в лэндскейп ориентации.



    Как это описывается в коде.


        enum Section: Int, CaseIterable {
            case list
            case grid3
            case grid5
    
            func columnCount(for width: CGFloat) -> Int {
                let wideMode = width > 800
                switch self {
                case .list:
                    return wideMode ? 2 : 1
                case .grid3:
                    return wideMode ? 6 : 3
                case .grid5:
                    return wideMode ? 10 : 5
                }
            }
        }
    
        private func createLayout() -> UICollectionViewLayout {
            let layout = UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
    
                guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
                let columns = sectionKind.columnCount(for: layoutEnvironment.container.effectiveContentSize.width)
    
                let itemSize = NSCollectionLayoutSize(
                    widthDimension: .fractionalWidth(1.0),
                    heightDimension: .fractionalHeight(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)
                item.contentInsets = .init(top: 2, leading: 2, bottom: 2, trailing: 2)
    
                let groupHeight = layoutEnvironment.traitCollection.verticalSizeClass == .compact ?
                    NSCollectionLayoutDimension.absolute(44) :
                    NSCollectionLayoutDimension.fractionalWidth(0.2)
    
                let groupSize = NSCollectionLayoutSize(
                    widthDimension: .fractionalWidth(1.0),
                    heightDimension: groupHeight)
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columns)
    
                let section = NSCollectionLayoutSection(group: group)
                section.contentInsets = .init(top: 20, leading: 20, bottom: 20, trailing: 20)
                return section
            }
            return layout
        }
    
    // ...

    Nested Groups


    class NSCollectionLayoutGroup : NSCollectionLayoutItem, NSCopying { ... }

    Если взглянуть на определение класса NSCollectionLayoutGroup, то можно увидеть, что он наследник класса NSCollectionLayoutItem, а значит может быть, использован как элемент в другой группе, тем самым у нас есть возможность создавать вложенные группы.



    Как это описывается в коде.


        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 bottomNestedGroup = NSCollectionLayoutGroup.horizontal(
                    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                       heightDimension: .fractionalHeight(0.6)),
                    subitems: [leadingItem, trailingGroup])
    
                let topItem = NSCollectionLayoutItem(
                    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                   heightDimension: .fractionalHeight(0.3)))
                topItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
    
                let nestedGroup = NSCollectionLayoutGroup.vertical(
                    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                       heightDimension: .fractionalHeight(0.4)),
                    subitems: [topItem, bottomNestedGroup])
                let section = NSCollectionLayoutSection(group: nestedGroup)
                return section
    
            }
            return layout
        }

    Nested Groups Scrolling


    Как на счет горизонтального скрола? Легко! Для этого, всего лишь на всего, нужно проставить соответствующее значение у секции.


    // ...
    section.orthogonalScrollingBehavior = .continuous
    // ...


    Заключение


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


    Ссылки:


    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 5

      0
      Спасибо за отличный разбор нового инструмента!
      Очень жаль, что не всем удастся использовать его в продакшен в ближайшее в время.
      0
      Возможно ли с помощью UICollectionViewCompositionalLayout сделать waterfall/pinterest layout? Я попробовал, но не получается избавиться от пустот между ячейками.
        0

        Я думаю, что можно, хотя сам не пробовал. Попробуй поиграться с .flexible и .fixed параметрами у NSCollectionLayoutSpacing https://dou.ua/lenta/articles/ui-collection-view-compositional-layout/

          0
          Таки тоже не выходит. Например, если нужно сделать раскладку с 3 колонками фиксированной ширины, где высоты у ячеек разные (задаются через autolayout). Т.е в каждой колонке элементы помещаются друг за другом без пустот по высоте. Если делать группу с 3 элементами, где каждый имеет ширину 1/3, а высоту estimated, то они точно будут находиться все вровень на одной высоте Y. Если делать группу с одним элементом и шириной 1 / 3, то лейаут каждую новую группу все равно помещает на новой строке. Даже со всеми .flexible(0) edgeSpacing.

          Waterfall layout
          image

      Only users with full accounts can post comments. Log in, please.