Как заставить Вспомогательное Представление плавать в UICollectionView, как Заголовки Разделов делают в простом стиле UITableView

Я изо всех сил пытаюсь добиться эффекта "плавающего заголовка раздела" с UICollectionView, То, что было достаточно легко в UITableView (поведение по умолчанию для UITableViewStylePlainкажется невозможным в UICollectionView без большой тяжелой работы. Я упускаю очевидное?

Apple не предоставляет никакой документации о том, как этого добиться. Кажется, что нужно подкласс UICollectionViewLayout и реализовать пользовательский макет только для достижения этого эффекта. Это влечет за собой немало работы, реализуя следующие методы:

Методы для переопределения

Каждый объект макета должен реализовывать следующие методы:

collectionViewContentSize
layoutAttributesForElementsInRect:
layoutAttributesForItemAtIndexPath:
layoutAttributesForSupplementaryViewOfKind:atIndexPath: (if your layout supports supplementary views)
layoutAttributesForDecorationViewOfKind:atIndexPath: (if your layout supports decoration views)
shouldInvalidateLayoutForBoundsChange:

Однако мне не ясно, как заставить дополнительный вид плавать над ячейками и "прилипать" к верхней части вида, пока не будет достигнут следующий раздел. Есть ли флаг для этого в атрибутах макета?

Я бы использовал UITableView но мне нужно создать довольно сложную иерархию коллекций, которая легко достигается с помощью представления коллекции.

Любое руководство или образец кода будет принята с благодарностью!

14 ответов

В iOS9 Apple любезно добавила простое свойство в UICollectionViewFlowLayout называется sectionHeadersPinToVisibleBounds,

При этом вы можете заставить заголовки плавать так же, как в табличных представлениях.

let layout = UICollectionViewFlowLayout()
layout.sectionHeadersPinToVisibleBounds = true
layout.minimumInteritemSpacing = 1
layout.minimumLineSpacing = 1
super.init(collectionViewLayout: layout)

Либо реализуйте следующие методы делегата:

– collectionView:layout:sizeForItemAtIndexPath:
– collectionView:layout:insetForSectionAtIndex:
– collectionView:layout:minimumLineSpacingForSectionAtIndex:
– collectionView:layout:minimumInteritemSpacingForSectionAtIndex:
– collectionView:layout:referenceSizeForHeaderInSection:
– collectionView:layout:referenceSizeForFooterInSection:

По вашему мнению, контроллер, который имеет ваш :cellForItemAtIndexPath метод (просто верните правильные значения). Или вместо использования методов делегата вы также можете установить эти значения непосредственно в объекте макета, например [layout setItemSize:size];,

Использование любого из этих методов позволит вам настроить параметры в коде, а не в IB, поскольку они удаляются при установке пользовательского макета. Не забудьте добавить <UICollectionViewDelegateFlowLayout> в ваш.h файл тоже!

Создать новый подкласс UICollectionViewFlowLayoutназовите его как хотите и убедитесь, что файл H имеет:

#import <UIKit/UIKit.h>

@interface YourSubclassNameHere : UICollectionViewFlowLayout

@end

Внутри файла реализации убедитесь, что он имеет следующее:

- (NSArray *) layoutAttributesForElementsInRect:(CGRect)rect {

    NSMutableArray *answer = [[super layoutAttributesForElementsInRect:rect] mutableCopy];
    UICollectionView * const cv = self.collectionView;
    CGPoint const contentOffset = cv.contentOffset;

    NSMutableIndexSet *missingSections = [NSMutableIndexSet indexSet];
    for (UICollectionViewLayoutAttributes *layoutAttributes in answer) {
        if (layoutAttributes.representedElementCategory == UICollectionElementCategoryCell) {
            [missingSections addIndex:layoutAttributes.indexPath.section];
        }
    }
    for (UICollectionViewLayoutAttributes *layoutAttributes in answer) {
        if ([layoutAttributes.representedElementKind isEqualToString:UICollectionElementKindSectionHeader]) {
            [missingSections removeIndex:layoutAttributes.indexPath.section];
        }
    }

    [missingSections enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {

        NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:idx];

        UICollectionViewLayoutAttributes *layoutAttributes = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader atIndexPath:indexPath];

        [answer addObject:layoutAttributes];

    }];

    for (UICollectionViewLayoutAttributes *layoutAttributes in answer) {

        if ([layoutAttributes.representedElementKind isEqualToString:UICollectionElementKindSectionHeader]) {

            NSInteger section = layoutAttributes.indexPath.section;
            NSInteger numberOfItemsInSection = [cv numberOfItemsInSection:section];

            NSIndexPath *firstCellIndexPath = [NSIndexPath indexPathForItem:0 inSection:section];
            NSIndexPath *lastCellIndexPath = [NSIndexPath indexPathForItem:MAX(0, (numberOfItemsInSection - 1)) inSection:section];

            NSIndexPath *firstObjectIndexPath = [NSIndexPath indexPathForItem:0 inSection:section];
            NSIndexPath *lastObjectIndexPath = [NSIndexPath indexPathForItem:MAX(0, (numberOfItemsInSection - 1)) inSection:section];

            UICollectionViewLayoutAttributes *firstObjectAttrs;
            UICollectionViewLayoutAttributes *lastObjectAttrs;

            if (numberOfItemsInSection > 0) {
                firstObjectAttrs = [self layoutAttributesForItemAtIndexPath:firstObjectIndexPath];
                lastObjectAttrs = [self layoutAttributesForItemAtIndexPath:lastObjectIndexPath];
            } else {
                firstObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
                                                                    atIndexPath:firstObjectIndexPath];
                lastObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
                                                                   atIndexPath:lastObjectIndexPath];
            }

            CGFloat headerHeight = CGRectGetHeight(layoutAttributes.frame);
            CGPoint origin = layoutAttributes.frame.origin;
            origin.y = MIN(
                           MAX(
                               contentOffset.y + cv.contentInset.top,
                               (CGRectGetMinY(firstObjectAttrs.frame) - headerHeight)
                               ),
                           (CGRectGetMaxY(lastObjectAttrs.frame) - headerHeight)
                           );

            layoutAttributes.zIndex = 1024;
            layoutAttributes.frame = (CGRect){
                .origin = origin,
                .size = layoutAttributes.frame.size
            };

        }

    }

    return answer;

}

- (BOOL) shouldInvalidateLayoutForBoundsChange:(CGRect)newBound {

    return YES;

}

Выберите "Custom" в Интерфейсном Разработчике для Разметки потока, выберите ваш "YourSubclassNameHere" Класс, который вы только что создали. И беги!

(Примечание: приведенный выше код может не учитывать значения contentInset.bottom, особенно большие или маленькие объекты нижнего колонтитула, или коллекции, которые имеют 0 объектов, но не имеют нижнего колонтитула.)

Если у вас есть один заголовок, который вы хотите закрепить в верхней части вашего UICollectionView, вот относительно простой способ сделать это. Обратите внимание, что это должно быть максимально просто - предполагается, что вы используете один заголовок в одном разделе.

//Override UICollectionViewFlowLayout class
@interface FixedHeaderLayout : UICollectionViewFlowLayout
@end

@implementation FixedHeaderLayout
//Override shouldInvalidateLayoutForBoundsChange to require a layout update when we scroll 
- (BOOL) shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    return YES;
}

//Override layoutAttributesForElementsInRect to provide layout attributes with a fixed origin for the header
- (NSArray *) layoutAttributesForElementsInRect:(CGRect)rect {

    NSMutableArray *result = [[super layoutAttributesForElementsInRect:rect] mutableCopy];

    //see if there's already a header attributes object in the results; if so, remove it
    NSArray *attrKinds = [result valueForKeyPath:@"representedElementKind"];
    NSUInteger headerIndex = [attrKinds indexOfObject:UICollectionElementKindSectionHeader];
    if (headerIndex != NSNotFound) {
        [result removeObjectAtIndex:headerIndex];
    }

    CGPoint const contentOffset = self.collectionView.contentOffset;
    CGSize headerSize = self.headerReferenceSize;

    //create new layout attributes for header
    UICollectionViewLayoutAttributes *newHeaderAttributes = [UICollectionViewLayoutAttributes layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader withIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]];
    CGRect frame = CGRectMake(0, contentOffset.y, headerSize.width, headerSize.height);  //offset y by the amount scrolled
    newHeaderAttributes.frame = frame;
    newHeaderAttributes.zIndex = 1024;

    [result addObject:newHeaderAttributes];

    return result;
}
@end

Смотрите: https://gist.github.com/4613982

Вот мой взгляд на это, я думаю, что это намного проще, чем то, что мы увидели выше. Основным источником простоты является то, что я не делаю подклассы макета потока, а скорее использую свой собственный макет (гораздо проще, если вы спросите меня).

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

несколько бонусов:

  1. Я плаваю два заголовка, а не только один
  2. Заголовки смещают предыдущие заголовки
  3. Смотри, быстро!

нота:

  1. supplementaryLayoutAttributes содержит все атрибуты заголовка без использования плавающего
  2. Я использую этот код в prepareLayout, так как я делаю все вычисления заранее.
  3. не забудьте переопределить shouldInvalidateLayoutForBoundsChange к истине!

// float them headers
let yOffset = self.collectionView!.bounds.minY
let headersRect = CGRect(x: 0, y: yOffset, width: width, height: headersHeight)

var floatingAttributes = supplementaryLayoutAttributes.filter {
    $0.frame.minY < headersRect.maxY
}

// This is three, because I am floating 2 headers
// so 2 + 1 extra that will be pushed away
var index = 3
var floatingPoint = yOffset + dateHeaderHeight

while index-- > 0 && !floatingAttributes.isEmpty {

    let attribute = floatingAttributes.removeLast()
    attribute.frame.origin.y = max(floatingPoint, attribute.frame.origin.y)

    floatingPoint = attribute.frame.minY - dateHeaderHeight
}

Если кто-то ищет решение в Objective-C, поместите это в viewDidload:

    UICollectionViewFlowLayout *flowLayout = 
    (UICollectionViewFlowLayout*)_yourcollectionView.collectionViewLayout;
    [flowLayout setSectionHeadersPinToVisibleBounds:YES];

Если уже установлен макет потока в Storyboard или же Xib файл, попробуйте это,

(collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.sectionHeadersPinToVisibleBounds = true

У @iPrabu был отличный ответ: sectionHeadersPinToVisibleBounds, Я просто добавлю, что вы также можете установить это свойство в Интерфейсном Разработчике:

  1. Выберите объект макета потока в навигаторе документов. (Если он свернут, сначала разверните его, используя кнопку панели инструментов в левом нижнем углу редактора.)

Выбор объекта макета потока в навигаторе документов

  1. Откройте инспектор удостоверений и добавьте пользовательский атрибут времени выполнения с ключевым путем sectionHeadersPinToVisibleBoundsвведите Boolean, и флажок установлен.

Установка атрибута времени выполнения в инспекторе удостоверений

Вид заголовка по умолчанию имеет прозрачный фон. Возможно, вы захотите сделать его (частично) непрозрачным или добавить вид эффекта размытия.

Я запустил это с помощью кода Vigorouscoding. Однако этот код не учитывает sectionInset.

Поэтому я изменил этот код для вертикальной прокрутки

origin.y = MIN(
              MAX(contentOffset.y, (CGRectGetMinY(firstCellAttrs.frame) - headerHeight)),
              (CGRectGetMaxY(lastCellAttrs.frame) - headerHeight)
           );

в

origin.y = MIN(
           MAX(contentOffset.y, (CGRectGetMinY(firstCellAttrs.frame) - headerHeight - self.sectionInset.top)),
           (CGRectGetMaxY(lastCellAttrs.frame) - headerHeight + self.sectionInset.bottom)
           );

Если вы, ребята, хотите код для горизонтальной прокрутки, обратитесь к коду.

Swift 5.0

Поместите в свой viewDidLoad следующее:

if let layout = collectionView?.collectionViewLayout as? UICollectionViewFlowLayout {
    layout.sectionHeadersPinToVisibleBounds = true
}

Я столкнулся с той же проблемой и нашел это в моих результатах Google. Сначала я хотел бы поблагодарить cocotutch за то, что он поделился своим решением. Однако я хотел, чтобы мой UICollectionView выполнял горизонтальную прокрутку, а заголовки прилипали к левой части экрана, поэтому мне пришлось немного изменить решение.

В основном я просто изменил это:

        CGFloat headerHeight = CGRectGetHeight(layoutAttributes.frame);
        CGPoint origin = layoutAttributes.frame.origin;
        origin.y = MIN(
                       MAX(
                           contentOffset.y,
                           (CGRectGetMinY(firstCellAttrs.frame) - headerHeight)
                           ),
                       (CGRectGetMaxY(lastCellAttrs.frame) - headerHeight)
                       );

        layoutAttributes.zIndex = 1024;
        layoutAttributes.frame = (CGRect){
            .origin = origin,
            .size = layoutAttributes.frame.size
        };

к этому:

        if (self.scrollDirection == UICollectionViewScrollDirectionVertical) {
            CGFloat headerHeight = CGRectGetHeight(layoutAttributes.frame);
            CGPoint origin = layoutAttributes.frame.origin;
            origin.y = MIN(
                           MAX(contentOffset.y, (CGRectGetMinY(firstCellAttrs.frame) - headerHeight)),
                           (CGRectGetMaxY(lastCellAttrs.frame) - headerHeight)
                           );

            layoutAttributes.zIndex = 1024;
            layoutAttributes.frame = (CGRect){
                .origin = origin,
                .size = layoutAttributes.frame.size
            };
        } else {
            CGFloat headerWidth = CGRectGetWidth(layoutAttributes.frame);
            CGPoint origin = layoutAttributes.frame.origin;
            origin.x = MIN(
                           MAX(contentOffset.x, (CGRectGetMinX(firstCellAttrs.frame) - headerWidth)),
                           (CGRectGetMaxX(lastCellAttrs.frame) - headerWidth)
                           );

            layoutAttributes.zIndex = 1024;
            layoutAttributes.frame = (CGRect){
                .origin = origin,
                .size = layoutAttributes.frame.size
            };
        }

См.: https://gist.github.com/vigorouscoding/5155703 или http://www.vigorouscoding.com/2013/03/uicollectionview-with-sticky-headers/

Кажется, я наткнулся на элегантное решение, которое может оказаться полезным для других :)

          let layout = UICollectionViewCompositionalLayout(
        sectionProvider: { (_, layoutEnvironment) -> NSCollectionLayoutSection? in
            var configuration = UICollectionLayoutListConfiguration(appearance: .grouped)
            configuration.headerMode = .supplementary
            configuration.showsSeparators = true
            configuration.backgroundColor = UIColor.systemGray6

            var section = NSCollectionLayoutSection.list(
                using: configuration,
                layoutEnvironment: layoutEnvironment
            )
            let sectionSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .estimated(1)
            )
            let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
                layoutSize: sectionSize,
                elementKind: UICollectionView.elementKindSectionHeader,
                alignment: .top
            )
            sectionHeader.pinToVisibleBounds = true
            section.boundarySupplementaryItems = [sectionHeader]
            return section
    })
    let frame = CGRect(x: 0.0, y: 0.0, width: 300.0, height: 500.0)
    let view = UICollectionView(frame: frame, collectionViewLayout: layout)

Проблема, с которой я всегда сталкивался с другими решениями, заключалась в том, что я не нашел способ закрепить заголовок и заставить его анимировать, выталкивая другой заголовок из поля зрения. В приведенном ниже коде ни один из экземпляров не дает вам доступа к pinToVisibleBoundsкак и решение выше:

          var configuration = UICollectionLayoutListConfiguration(appearance: .grouped)
    configuration.headerMode = .supplementary
    configuration.showsSeparators = true
    configuration.backgroundColor = UIColor.systemGray6
    let layout = UICollectionViewCompositionalLayout.list(using: configuration)

Я добавил пример на GitHub, который довольно прост, я думаю.

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

В сообщении Cocotouch есть ошибка. Если в разделе нет элементов, а нижний колонтитул раздела не задан, заголовок раздела выходит за пределы представления коллекции, и пользователь не сможет его увидеть.

На самом деле изменить:

if (numberOfItemsInSection > 0) {
    firstObjectAttrs = [self layoutAttributesForItemAtIndexPath:firstObjectIndexPath];
    lastObjectAttrs = [self layoutAttributesForItemAtIndexPath:lastObjectIndexPath];
} else {
    firstObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
                                                            atIndexPath:firstObjectIndexPath];
    lastObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
                                                           atIndexPath:lastObjectIndexPath];
}

в:

if (numberOfItemsInSection > 0) {
    firstObjectAttrs = [self layoutAttributesForItemAtIndexPath:firstObjectIndexPath];
    lastObjectAttrs = [self layoutAttributesForItemAtIndexPath:lastObjectIndexPath];
} else {
    firstObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
                                                            atIndexPath:firstObjectIndexPath];
    lastObjectAttrs = [self layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
                                                           atIndexPath:lastObjectIndexPath];
    if (lastObjectAttrs == nil) {
        lastObjectAttrs = firstObjectAttrs;
    }
}

решит эту проблему.

VCollectionViewGridLayout делает липкие заголовки. Это вертикальная прокрутка с простой структурой сетки, основанная на TLIndexPathTools. Попробуйте запустить пример проекта Sticky Headers.

Этот макет также имеет намного лучшее поведение анимации пакетного обновления, чем UICollectionViewFlowLayout, Существует несколько примеров проектов, которые позволяют переключаться между двумя макетами, чтобы продемонстрировать улучшение.

Другие вопросы по тегам