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

Добрый день,

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

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

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

Введение

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

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

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

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

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

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

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

Compositional Layout

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

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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.

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

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

    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

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

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

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

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

Two Column Layout

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

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

    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.

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

    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-ю колонками.

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

    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 и проставлять нужное количество колонок для секции.

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

    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, а значит может быть, использован как элемент в другой группе, тем самым у нас есть возможность создавать вложенные группы.

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

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

    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
// ...

Compositional Layout в iOS 13. Основы — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

Заключение

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

Ссылки:

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр