Понять UICollectionViewLayout и создать макет сетки, где ячейки могут охватывать столбцы
Вступление
UICollectionView
- один из тех «продвинутых» компонентов UIKit, с которыми каждому разработчику iOS рано или поздно приходится бороться. Почему с UITableView
не так просто работать? Почему UICollectionViewCell
не соответствует размеру экрана? Как получить несколько столбцов? Это общие, но не тривиальные вопросы, которые когда-то возникали у всех.
В этом руководстве я познакомлю вас с UICollectionViewLayout
, ключом к пониманию того, как расположены ячейки в UICollectionView
. Затем мы увидим, как расширить этот класс, чтобы создать макет сетки, где мы можем легко сделать ячейки частью столбцов.
В этом руководстве я буду руководить вами, взяв этот образец проекта в качестве справки:
Что такое UICollectionViewLayout
Согласно документации Apple:
Задача объекта макета состоит в том, чтобы определять размещение ячеек, дополнительных представлений и декоративных представлений внутри границ представления коллекции и сообщать эту информацию представлению коллекции по запросу.
UICollectionViewLayout
- это элемент, отвечающий за размещение ячеек и дополнительных и декоративных видов внутри UICollectionView
. Это означает, что в отличие от UITableView
, где ширина ячейки совпадает с шириной ее контейнера, а ее высота может быть автоматически вычислена с помощью ограничений ячейки, UICollectionView
потребует особой обработки. Вот где приходит на помощь UICollectionViewLayout
.
Apple предоставляет простой подкласс UICollectionViewFlowLayout
, который используется по умолчанию для UICollectionView
. В этом макете
Элементы в представлении коллекции перетекают из одной строки или столбца (в зависимости от направления прокрутки) к следующей, причем каждая строка содержит столько ячеек, сколько может поместиться.
Размер ячейки может быть статическим estimatedItemSize
или может быть динамическим, если вы реализуете UICollectionViewDelegateFlowLayout
и реализуете:
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
В большинстве случаев этого макета может быть достаточно для достижения наших целей. Однако стоит изучить другие возможности и узнать, как расширить этот элемент. Мы сделаем это в следующем разделе.
Пример использования: GridCollectionViewLayout
Не так давно я работал разработчиком в газете. В этой индустрии главный экран - это открытая страница газеты, которая может выглядеть так:
Для работы с такой сеткой было бы более подходящим, если бы у нас был макет, который «понимал» строки и столбцы. Давай сделаем это!
Зайдите в репозиторий и скачайте стартовый проект. Запускаем и смотрим на сетку:
По умолчанию UICollectionViewFlowLayout
имеет estimatedItemSize
60x60. Клетки текут по оси X (если она установлена горизонтально). Когда свободного места больше нет, макет создает новую строку и так далее.
Давайте создадим наш собственный макет и определим некоторые настраиваемые переменные, которые помогут нам расположить ячейки вдоль представления коллекции:
В UICollectionViewLayout
есть 5 важных методов, которые мы должны переопределить:
prepare
: этот метод будет вызываться перед появлением представления коллекции и каждый раз, когда его макет становится недействительным. Здесь мы вычисляем и кешируем позицию каждой ячейки.collectionViewContentSize
: размер содержимого представления коллекции. Это будет зависеть от того, как мы раскладываем ячейки.invalidateLayout
: вызывается каждый раз, когда необходимо пересчитать макетlayoutAttributesForElements(in:)
: вернет элементы кеша, чей фрейм пересекает заданныйrect
layoutAttributesForItem(at:)
: вернет товары в указанныйindexPath
Мы будем упрощать работу, сосредоточимся только на ячейках и ограничим нашу сетку вертикальной ориентацией. Вы можете проверить окончательный проект, чтобы найти полную реализацию.
Реализация: collectionViewContentSize
Как мы уже упоминали, мы предполагаем, что сетка может просто прокручиваться по вертикальной оси. Следовательно, ширина содержимого будет статической и может быть определена как:
contentWidth = totalWidth - horizontalInsets
И в коде:
По высоте он будет динамическим, поэтому он будет зависеть от того, как мы размещаем ячейки, и, следовательно, будет обновлен в prepare
. Теперь мы можем переопределить collectionViewContentSize
и определить его как:
Реализация: подготовить
В этом методе мы перебираем все разделы и элементы и присваиваем рамку каждому отображаемому элементу. Прежде всего, объявите приватную переменную attributes
для хранения атрибутов каждого элемента:
private var attributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
И переопределить prepare
:
override func prepare() { super.prepare() // Implementation here }
Этот метод может вызываться несколько раз, поэтому мы должны убедиться, что элементы еще не кэшированы. Добавьте эти 3 предложения guard
внутрь prepare
:
guard let collectionView = collectionView else { return } guard attributes.isEmpty else { return } guard numberOfColumns > 0 else { return }
Затем выполните итерацию разделов и подготовьте элементы в каждом из них:
let numberOfSections = collectionView.numberOfSections (0..<numberOfSections).forEach { section in // 1. prepareCells(at: section) // 2. if section < numberOfSections - 1 { collectionViewContentHeight += sectionSpacing } }
- Мы будем использовать
prepareCells(at:)
для перебора элементов каждого раздела и подготовки атрибутов их ячеек. - Если раздел не последний, нам нужно немного места для разделения разделов между собой. Для этого мы увеличим
collectionViewContentHeight
Давайте теперь посмотрим на prepareCells(at:)
реализацию. Для проведения расчетов мы определим struct
, который нам поможет:
struct GridIndex { let row: Int let column: Int }
Кроме того, мы определим делегата для получения высоты каждой строки, высоты каждого элемента и диапазона столбцов каждого элемента. Ранее мы определили статическое значение по умолчанию для каждого из этих свойств, но мы хотим, чтобы они были динамическими.
Не забудьте объявить делегат внутри определения класса:
weak var delegate: GridCollectionViewLayoutDelegate?
Идите вперед и объявите функцию prepareCells(at:)
:
Вау, похоже, здесь много чего происходит. Итак, вот что происходит:
- Мы начинаем итерацию до тех пор, пока в разделе не останется больше элементов, то есть когда
itemIndex = itemCount
. Мы также вычисляемcolumnWidth
с учетомnumberOfColumns
и просимdelegate
наcolumnHeight
. availableSpan
определяется какnumberOfColumns
, и итерация начинается там, где мы пытаемся заполнить строку. Эту итерацию следует остановить, если в строке больше нет доступных элементов или свободного места.delegate
предоставит диапазон и высоту ячейки, которые ограниченыavailableSpan
иcolumnHeight
соответственно.cellWidth
затем устанавливается в соответствии сcellSpan
иcolumnWidth
.- Положение элемента по x определяется с учетом индекса
column
иcolumnSpacing
. В соответствии с положением элемента по оси YcollectionViewContentHeight
отслеживает элементы, которые уже были размещены. Следовательно, новый элемент должен располагаться сразу под последним элементом. - Увеличить
itemIndex
и обновитьavailableSpan
- Когда строка заполнена, увеличьте индекс
row
и обновитеcollectionViewContentHeight
, добавивcolumnHeight
- Наконец, если это не последняя строка в разделе, добавьте еще
rowSpacing
.
Что ж, это было безумием, но я обещаю, что все, что осталось сделать, очень легко.
Реализация: layoutAttributesForElements (в :)
На этом этапе мы успешно кэшируем атрибуты всех элементов, но не получаем, какие элементы должны отображаться в любой момент. Так что продолжайте и реализуйте layoutAttributesForElements(in:)
:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return attributes.values.filter { $0.frame.intersects(rect) } }
В этой статье я использовал термин элемент и он e безразлично. Однако есть небольшое различие: item относится к экземпляру UICollectionViewCell
, тогда как element относится ко всему, что отображает UICollectionView
, например к ячейкам, дополнительным представлениям или декоративным представлениям.
В этом примере это не имеет значения, поскольку мы просто работаем с ячейками или элементами, но если бы мы отображали верхний или нижний колонтитул, нам нужно было бы проверить их атрибуты здесь и вернуть их, если было пересечение.
Реализация: layoutAttributesForItem (at :)
В данном случае это просто относится к ячейкам. Реализация довольно проста:
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return attributes[indexPath] }
Если вы хотите разметить, скажем, и дополнительные представления, вам нужно будет реализовать layoutAttributesForSupplementaryView(ofKind:at:)
.
Реализация: invalidateLayout
И последнее, но не менее важное: макет может стать недействительным, например, из-за поворота устройства или ограничения макета collectionView. В этом сценарии фрейм представления коллекции изменится, как и его макет. Мы просто сбросим значения в исходное состояние:
override func invalidateLayout() { super.invalidateLayout() collectionViewContentHeight = 0 attributes.removeAll() }
Иногда система вызывает invalidateLayout
сама по себе, поэтому вы также можете реализовать этот метод, чтобы решить, следует ли пересчитывать макет:
Заключительные шаги
Если вы следили за стартовым проектом, все, что вам нужно сделать сейчас, это перейти на MainViewController
и реализовать эти изменения, которые мы вносили. Итак, прежде всего, перейдите к его инициализатору и поменяйте код на:
init() { let layout = GridCollectionViewLayout(numberOfColumns: 4) super.init(collectionViewLayout: layout) layout.delegate = self }
Наконец, сделайте так, чтобы он реализовал GridCollectionViewLayoutDelegate
:
Теперь попробуйте и запустите проект. Вы должны увидеть что-то похожее на это:
Вы можете повозиться со значениями, возвращаемыми в методах делегата, чтобы получить разные результаты, например:
Куда пойти отсюда?
Если вы хотите продолжить эксперименты с UICollectionViewLayout
, вы можете попробовать добавить дополнительные представления, такие как верхние или нижние колонтитулы. Кроме того, если вы заметили, ячейки выравниваются по верхнему краю, если их высота меньше высоты столбца. Одна вещь, которую вы можете сделать, - это изменить выравнивание по запросу и установить для него три разных сценария: верхний, центральный и нижний.
Посмотрите финальный проект, если вы застряли, финальная реализация уже есть.
Выводы
UICollectionView
, без сомнения, сложнее, чем UITableView
, и для его компоновки требуется специальный компонент. В большинстве случаев UICollectionViewFlowLayout
будет достаточно и он удовлетворит ваши потребности, но вы не должны бояться расширять этот класс и создавать свой собственный макет. Скорее всего, это упростит ваш код и позволит вам легко расширить его, добавив больше функций.