Добрый день,
На практике 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 можно легко и быстро создавать экраны почти любой сложности.
В данной статье были рассмотрены основные классы и их взаимодействие с друг другом. В следующей статье рассмотрим более продвинутые техники создания лайаута.
Ссылки:
Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр