Понять 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
    }
}
  1. Мы будем использовать prepareCells(at:) для перебора элементов каждого раздела и подготовки атрибутов их ячеек.
  2. Если раздел не последний, нам нужно немного места для разделения разделов между собой. Для этого мы увеличим collectionViewContentHeight

Давайте теперь посмотрим на prepareCells(at:) реализацию. Для проведения расчетов мы определим struct, который нам поможет:

struct GridIndex {
    let row: Int
    let column: Int
}

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

Не забудьте объявить делегат внутри определения класса:

weak var delegate: GridCollectionViewLayoutDelegate?

Идите вперед и объявите функцию prepareCells(at:):

Вау, похоже, здесь много чего происходит. Итак, вот что происходит:

  1. Мы начинаем итерацию до тех пор, пока в разделе не останется больше элементов, то есть когда itemIndex = itemCount. Мы также вычисляем columnWidth с учетом numberOfColumns и просим delegate на columnHeight.
  2. availableSpan определяется как numberOfColumns, и итерация начинается там, где мы пытаемся заполнить строку. Эту итерацию следует остановить, если в строке больше нет доступных элементов или свободного места.
  3. delegate предоставит диапазон и высоту ячейки, которые ограничены availableSpan и columnHeight соответственно. cellWidth затем устанавливается в соответствии с cellSpan и columnWidth.
  4. Положение элемента по x определяется с учетом индекса column и columnSpacing. В соответствии с положением элемента по оси Y collectionViewContentHeight отслеживает элементы, которые уже были размещены. Следовательно, новый элемент должен располагаться сразу под последним элементом.
  5. Увеличить itemIndex и обновить availableSpan
  6. Когда строка заполнена, увеличьте индекс row и обновите collectionViewContentHeight, добавив columnHeight
  7. Наконец, если это не последняя строка в разделе, добавьте еще 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 будет достаточно и он удовлетворит ваши потребности, но вы не должны бояться расширять этот класс и создавать свой собственный макет. Скорее всего, это упростит ваш код и позволит вам легко расширить его, добавив больше функций.