Реализация макета «UICollectionView Compositional» с разделом Pinterest

Реализация макета «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
}

  1. Давайте определим размер отдельного элемента, который будет использоваться в качестве основы для построения остальной части макета. Этот элемент будет занимать всю ширину и высоту доступного пространства с размером ширины .fractionalWidth(1) и размером высоты .fractionalHeight(1).
  2. Создается группа с заданным размером элемента и настраивается для горизонтального расположения с помощью NSCollectionLayoutGroup.horizontal. Эта группа занимает всю ширину доступного пространства с размером ширины .fractionalWidth(1) и размером высоты, равным его ширине с помощью .fractionalWidth(1).
  3. Затем создается NSCollectionLayoutSection с использованием определенной группы, и его ортогональное поведение прокрутки устанавливается на .continuousGroupLeadingBoundary, что означает, что раздел будет непрерывно прокручиваться в горизонтальном направлении. .
  4. Обработчик 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
}

  1. Макет группы определяется объектом NSCollectionLayoutGroup, который является горизонтальным и имеет ширину и высоту, определенные как 20 % и 30 % ширины представления коллекции соответственно. Группа состоит из одного элемента, определенного выше.
  2. Заголовок раздела определен как 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) }
    }
}()

  1. Отступ вокруг ячеек равен расстоянию между ячейками.
  2. Свойство frames — это отложенное свойство, которое вычисляет количество кадров для каждого элемента в разделе.
  3. 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
}

  1. calculateFrames отвечает за вычисление кадров для каждого элемента. Сначала ширина каждого столбца рассчитывается путем вычитания поля из общей ширины и деления его на количество столбцов.
  2. Настраивает переменные для хранения смещения координаты X для каждого столбца, смещения координаты Y для каждого столбца и массива кадров.
  3. Функция использует цикл для перебора массива itemRatios, вычисления кадра для каждого элемента на основе его соотношения сторон и добавления его в массив frames.< /li>
  4. Метод обновляет высоту, чтобы сохранить правильное соотношение сторон. Используйте для этого расширение:

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 :)


Оригинал