Реализация макета «UICollectionView Compositional» с разделом Pinterest
18 февраля 2023 г.Композиционный макет коллекции — это новая структура макета в структуре UIKit для iOS, представленная в iOS 13. Она предоставляет мощный и гибкий способ создания пользовательских представлений коллекции модульным и компонуемым образом. С помощью композиционного макета коллекции вы можете определить пользовательские макеты для представлений вашей коллекции, составляя различные элементы макета, такие как разделы, элементы и группы, таким образом, который лучше всего соответствует вашим потребностям.
Вы можете использовать компоновочный макет коллекции, чтобы определить макеты, поддерживающие динамическое содержимое и различные размеры элементов, а также легко изменить макет представления коллекции на лету. Платформа также включает в себя ряд встроенных элементов макета, которые можно использовать для создания общих макетов представлений коллекций, таких как сетка, список и вложенные группы.
Чтобы использовать композиционный макет коллекции в приложении для iOS, вам необходимо создать экземпляр UICollectionViewCompositionalLayout
, а затем определить один или несколько объектов NSCollectionLayoutSection
, которые представляют макет для каждого раздела в вашем приложении. просмотр коллекции. Затем вы можете установить свойство collectionView
вашего макета в свой экземпляр UICollectionView
, и макет будет автоматически применен к вашему представлению коллекции.
Сегодня мы подробно рассмотрим, как создать такой экран с помощью Collection Compositional Layout
Источник данных
Изначально необходимо включить два псевдонима типов и перечисление разделов.
Я хотел бы использовать DiffableDataSource, чтобы легко обновлять контент и добавлять новый контент при необходимости
typealias DataSource = UICollectionViewDiffableDataSource<Section, PictureModel>
typealias DataSourceSnapshot = NSDiffableDataSourceSnapshot<Section, PictureModel>
enum Section: Int, CaseIterable {
case carousel
case widget
case pinterest
}
Я настроил источник данных для представления коллекции. Функция принимает два параметра: [PictureModel]
и логическое значение "animatingDifferences". Функция начинается с удаления всех элементов в моментальном снимке источника данных с помощью метода «deleteAllItems». Затем он добавляет все варианты Section
в разделы моментального снимка.
Затем функция добавляет диапазон изображений к каждому разделу.
Наконец, функция применяет снимок к источнику данных с помощью apply
. Параметр "animatingDifferences" определяет, будут ли анимированы изменения в представлении коллекции.
private func configureDataSource(pictures: [PictureModel], animatingDifferences: Bool) {
snapshot.deleteAllItems()
snapshot.appendSections(Section.allCases)
snapshot.appendItems(pictures[20...29].map { $0 }, toSection: .carousel)
snapshot.appendItems(pictures[10...19].map { $0 }, toSection: .widget)
snapshot.appendItems(pictures[0...9].map { $0 }, toSection: .pinterest)
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
Дополнительную информацию о том, как работает источник данных, вы можете найти здесь:
Учебное пособие по iOS: представление коллекции и источник данных с возможностью дифференциации
Раздел карусели
Первый раздел — "Карусель" или "Несколько баннеров"
private static func carouselBannerSection() -> NSCollectionLayoutSection {
//1
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
//2
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalWidth(1)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
//3
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
//4
section.visibleItemsInvalidationHandler = { (items, offset, environment) in
items.forEach { item in
let distanceFromCenter = abs((item.frame.midX - offset.x) - environment.container.contentSize.width / 2.0)
let minScale: CGFloat = 0.8
let maxScale: CGFloat = 1.0 - (distanceFromCenter / environment.container.contentSize.width
let scale = max(maxScale, minScale)
item.transform = CGAffineTransform(scaleX: scale, y: scale)
}
}
return section
}
- Давайте определим размер отдельного элемента, который будет использоваться в качестве основы для построения остальной части макета. Этот элемент будет занимать всю ширину и высоту доступного пространства с размером ширины
.fractionalWidth(1)
и размером высоты.fractionalHeight(1)
. - Создается группа с заданным размером элемента и настраивается для горизонтального расположения с помощью
NSCollectionLayoutGroup.horizontal
. Эта группа занимает всю ширину доступного пространства с размером ширины.fractionalWidth(1)
и размером высоты, равным его ширине с помощью.fractionalWidth(1)
. - Затем создается
NSCollectionLayoutSection
с использованием определенной группы, и его ортогональное поведение прокрутки устанавливается на.continuousGroupLeadingBoundary
, что означает, что раздел будет непрерывно прокручиваться в горизонтальном направлении. . - Обработчик
visibleItemsInvalidationHandler
раздела настроен на замыкание, которое выполняет преобразование масштабирования для каждого элемента в зависимости от его расстояния от центра видимой области. Величина масштабирования определяется расстоянием от центра, при этом элементы, расположенные ближе к центру, увеличиваются, а элементы, расположенные дальше, уменьшаются. Минимальное и максимальное значения масштаба определяются какminScale
иmaxScale
соответственно.
Раздел виджетов
Этот раздел реализован почти так же, как и предыдущий. Отличия только в конфигурации группы
private static func widgetBannerSection() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5)
//1
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.2),
heightDimension: .fractionalWidth(0.3)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
//2
let supplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: .init(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(30)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
)
supplementaryItem.contentInsets = .init(
top: 0,
leading: 5,
bottom: 0,
trailing: 5
)
section.boundarySupplementaryItems = [supplementaryItem]
section.contentInsets = .init(top: 10, leading: 5, bottom: 10, trailing: 5)
section.orthogonalScrollingBehavior = .continuous
return section
}
- Макет группы определяется объектом
NSCollectionLayoutGroup
, который является горизонтальным и имеет ширину и высоту, определенные как 20 % и 30 % ширины представления коллекции соответственно. Группа состоит из одного элемента, определенного выше. - Заголовок раздела определен как
NSCollectionLayoutBoundarySupplementaryItem
. Заголовок размещается в верхней части раздела и имеет ту же ширину, что и раздел, с высотой 30 пунктов. Содержимое заголовка вставляется на 5 пунктов от переднего и заднего края.
Раздел Pinterest
Макет в стиле Pinterest – это тип дизайна пользовательского интерфейса на основе сетки, в котором контент упорядочивается в ряд равномерно расположенных столбцов с ячейками переменного размера, содержащими изображения. Макет обычно используется в таких приложениях, как обмен фотографиями и платформы социальных сетей.
Макет в стиле Pinterest придает макету более органичный и менее структурированный вид, чем традиционный дизайн на основе сетки, и может помочь разрушить монотонность страницы, заполненной ячейками одинакового размера.
Ячейки в этом разделе представлены в разных соотношениях. Таким образом, модели для этих ячеек должны соответствовать протоколу Ratioable
, который определяет единственное требование — свойство ratio
, которое является значением CGFloat
.
protocol Ratioable {
var ratio: CGFloat { get }
}
Соотношение сторон — это пропорциональное соотношение между шириной и высотой объекта.
Реализация этого раздела немного сложнее предыдущих. Поэтому я создам для него отдельный класс PinterestLayoutSection
.
Частные свойства внутри этого класса:
private let numberOfColumns: Int
private let itemRatios: [Ratioable]
private let spacing: CGFloat
private let contentWidth: CGFloat
Чтобы правильно рассчитать размер секции, мы должны передать массив элементов [Ratioable]
, в котором хранится соотношение для каждой будущей ячейки. Также нам нужно иметь определенное количество столбцов и полную ширину содержимого.
Для простоты понимания кода давайте добавим вычисляемые и ленивые свойства.
private var padding: CGFloat {
spacing / 2
}
// 1
private var insets: NSDirectionalEdgeInsets {
.init(
top: padding,
leading: padding,
bottom: padding,
trailing: padding
)
}
// 2
private lazy var frames: [CGRect] = {
calculateFrames()
}()
// 3
private lazy var sectionHeight: CGFloat = {
(frames
.map(.maxY)
.max() ?? 0
) + insets.bottom
}()
// 4
private lazy var customLayoutGroup: NSCollectionLayoutGroup = {
let layoutSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(sectionHeight)
)
return NSCollectionLayoutGroup.custom(layoutSize: layoutSize) { _ in
self.frames.map { .init(frame: $0) }
}
}()
- Отступ вокруг ячеек равен расстоянию между ячейками.
- Свойство
frames
— это отложенное свойство, которое вычисляет количество кадров для каждого элемента в разделе. sectionHeight
вычисляет высоту всего раздела на основе максимального значения
y-координата всех элементов.
4. customLayoutGroup
— это отложенное свойство, которое вычисляет группу макета для раздела. Он указывает размер раздела и возвращает массив элементов макета на основе рассчитанных кадров. Группа макетов создается с помощью метода NSCollectionLayoutGroup.custom
.
Последнее, но не менее важное. Нам нужно определить метод calculateFrames
.
private func calculateFrames() -> [CGRect] {
var contentHeight: CGFloat = 0
// 1
let columnWidth = (contentWidth - insets.leading - insets.trailing) /
CGFloat(numberOfColumns)
// 2
let xOffset = (0..<numberOfColumns).map { CGFloat($0) * columnWidth }
var currentColumn = 0
var yOffset: [CGFloat] = .init(repeating: 0, count: numberOfColumns)
// Total number of frames
var frames = [CGRect]()
// 3
for index in 0..<itemRatios.count {
let aspectRatio = itemRatios[index]
// Сalculate the frame.
let frame = CGRect(
x: xOffset[currentColumn],
y: yOffset[currentColumn],
width: columnWidth,
height: columnWidth / aspectRatio.ratio
)
// Total frame inset between cells and along edges
.insetBy(dx: padding, dy: padding)
// Additional top and left offset to account for padding
.offsetBy(dx: 0, dy: insets.leading)
// 4
.setHeight(ratio: aspectRatio.ratio)
frames.append(frame)
// Сalculate the height
let columnLowestPoint = frame.maxY
contentHeight = max(contentHeight, columnLowestPoint)
yOffset[currentColumn] = columnLowestPoint
// 5
currentColumn = yOffset.indexOfMinElement ?? 0
}
return frames
}
calculateFrames
отвечает за вычисление кадров для каждого элемента. Сначала ширина каждого столбца рассчитывается путем вычитания поля из общей ширины и деления его на количество столбцов.- Настраивает переменные для хранения смещения координаты X для каждого столбца, смещения координаты Y для каждого столбца и массива кадров.
- Функция использует цикл для перебора массива
itemRatios
, вычисления кадра для каждого элемента на основе его соотношения сторон и добавления его в массивframes
.< /li> - Метод обновляет высоту, чтобы сохранить правильное соотношение сторон. Используйте для этого расширение:
javascript
приватное расширение CGRect {
func setHeight(соотношение: CGFloat) -> CGRect {
.init(x: minX, y: minY, ширина: ширина, высота: ширина/соотношение)
}
код>
5. Добавление следующего элемента в столбец минимальной высоты. Мы можем двигаться последовательно, но тогда есть шанс, что некоторые столбцы будут намного длиннее других. Для удобства добавьте расширение для
Array
. Вычислимое свойство помогает найти индекс первого минимального элемента в массиве:
javascript
частный массив расширений, где элемент: Comparable {
вар indexOfMinElement: Int? {
количество охранников > 0 иначе { вернуть ноль }
вар мин = первый
переменный индекс = 0
index.forEach { я в
пусть currentItem = self[i]
если пусть minumum = min, currentItem < минимум {
мин = текущийЭлемент
индекс = я
}
}
возвращаемый индекс
}
код>
Последний шаг
Чтобы соединить все разделы вместе в один макет, мы создадим еще один класс.
Пользовательскийкомпозиционныймакет
.
final class CustomCompositionalLayout {
static func layout(
ratios: [Ratioable],
contentWidth: CGFloat
) -> UICollectionViewCompositionalLayout {
.init { sectionIndex, enviroment in
guard let section = Section(rawValue: sectionIndex)
else { return nil }
switch section {
case .carousel :
return carouselBannerSection()
case .widget :
return widgetBannerSection()
case .pinterest:
return pinterestSection(ratios: ratios, contentWidth: contentWidth)
}
}
}
}
У него есть статическая функция с именем layout
, которая принимает два параметра: ratios
и contentWidth
.
Не забудьте добавить в этот класс уже реализованные методы.
Разделы, которые могут быть возвращены:
carouselBannerSection
для случая.carousel
widgetBannerSection
для случая.widget
pinterestSection
для случая.pinterest
Возвращенное значение представляет собой объект UICollectionViewCompositionalLayout
, который имеет разные разделы в зависимости от значения sectionIndex
.
Заключение
Этот пример макета экрана — уникальный и визуально привлекательный способ отображения контента в представлении коллекции. Это достигается за счет использования UICollectionViewCompositionalLayout
и настраиваемых пропорций каждого элемента в коллекции.
Реализация этого макета может значительно улучшить взаимодействие с пользователем и придать вашему приложению свежий и динамичный вид. Благодаря возможности обработки различных соотношений сторон и динамической настройки контента макет предлагает универсальное и практичное решение для отображения контента в представлении коллекции.
В целом, макет с разделом в стиле Pinterest является ценным дополнением к набору инструментов любого разработчика iOS и может добавить креативности и дизайна вашему следующему проекту приложения.
На создание этой статьи меня вдохновило Учебное пособие по пользовательскому макету UICollectionView: Pinterest.
Полную реализацию с сетевым уровнем по шаблону MVVM вы можете найти на моем Github.
Не стесняйтесь ставить звезды здесь и на github :)
Оригинал