Динамическая настройка макета на UICollectionView вызывает необъяснимое изменение содержимогоOffset

Согласно документации Apple (и рекламируемой на WWDC 2012), можно динамически установить макет на UICollectionView и даже оживить изменения:

Обычно вы указываете объект макета при создании представления коллекции, но вы также можете динамически изменять макет представления коллекции. Объект макета сохраняется в свойстве collectionViewLayout. Установка этого свойства сразу обновляет макет сразу, без анимирования изменений. Если вы хотите анимировать изменения, вы должны вместо этого вызвать метод setCollectionViewLayout: анимированный.

Однако на практике я обнаружил, что UICollectionView делает необъяснимые и даже недействительные изменения в contentOffset, в результате чего ячейки перемещаются неправильно, что делает эту функцию практически непригодной. Чтобы проиллюстрировать эту проблему, я собрал следующий примерный код, который можно подключить к контроллеру представления коллекции по умолчанию, упавшему в раскадровку:

#import <UIKit/UIKit.h>

@interface MyCollectionViewController : UICollectionViewController
@end

@implementation MyCollectionViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"CELL"];
    self.collectionView.collectionViewLayout = [[UICollectionViewFlowLayout alloc] init];
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return 1;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    UICollectionViewCell *cell = [self.collectionView dequeueReusableCellWithReuseIdentifier:@"CELL" forIndexPath:indexPath];
    cell.backgroundColor = [UIColor whiteColor];
    return cell;
}

- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath
{
    NSLog(@"contentOffset=(%f, %f)", self.collectionView.contentOffset.x, self.collectionView.contentOffset.y);
    [self.collectionView setCollectionViewLayout:[[UICollectionViewFlowLayout alloc] init] animated:YES];
    NSLog(@"contentOffset=(%f, %f)", self.collectionView.contentOffset.x, self.collectionView.contentOffset.y);
}

@end

Контроллер устанавливает по умолчанию UICollectionViewFlowLayout в viewDidLoad и отображает одиночную ячейку на экране. Когда ячейки выбраны, контроллер создает еще один по умолчанию UICollectionViewFlowLayout и устанавливает его в виде коллекции с флагом animated:YES. Ожидаемое поведение заключается в том, что ячейка не перемещается. Фактическое поведение, однако, состоит в том, что ячейка прокручивается за кадром, и в этот момент даже невозможно прокрутить ячейку обратно на экране.

Глядя на консольный журнал, вы обнаружите, что contentOffset необъяснимо изменился (в моем проекте, от (0, 0) до (0, 205)). Я разместил решение для решения для неанимационного случая (i.e. animated:NO), но поскольку мне нужна анимация, мне очень интересно узнать, есть ли у кого решение или обходной путь для анимированного случая.

Как замечание, я протестировал пользовательские макеты и получил то же поведение.

Ответ 1

Я вытягивал свои волосы на протяжении нескольких дней и нашел решение для своей ситуации, которая может помочь. В моем случае у меня есть сворачивающийся макет фотографии, как в приложении для фотографий на ipad. Он показывает альбомы с фотографиями друг на друга, а при нажатии на альбом он расширяет фотографии. Итак, у меня есть два отдельных UICollectionViewLayouts и переключаются между ними с помощью [self.collectionView setCollectionViewLayout:myLayout animated:YES]. У меня была ваша точная проблема с ядром, прыгающим перед анимацией, и понял, что это был contentOffset. Я пробовал все с помощью contentOffset, но он все еще прыгал во время анимации. tyler решение выше работал, но он все еще возился с анимацией.

Затем я заметил, что это происходит только тогда, когда на экране было несколько альбомов, которых недостаточно, чтобы заполнить экран. Мой макет переопределяет -(CGSize)collectionViewContentSize, как рекомендовано. Когда есть только несколько альбомов, размер содержимого коллекции меньше, чем размер содержимого просмотров. Это вызывает скачок, когда я переключаюсь между макетами коллекции.

Итак, я установил свойство в своих макетах, называемых minHeight, и установил его в родительскую высоту коллекции. Затем я проверю высоту до того, как вернусь в -(CGSize)collectionViewContentSize, я гарантирую высоту >= минимальную высоту.

Не настоящее решение, но теперь он отлично работает. Я попытался бы установить contentSize в вашем представлении коллекции как минимум на длину, содержащую представление.

изменить Manicaesar добавил легкое обходное решение, если вы наследуете UICollectionViewFlowLayout:

-(CGSize)collectionViewContentSize { //Workaround
    CGSize superSize = [super collectionViewContentSize];
    CGRect frame = self.collectionView.frame;
    return CGSizeMake(fmaxf(superSize.width, CGRectGetWidth(frame)), fmaxf(superSize.height, CGRectGetHeight(frame)));
}

Ответ 2

UICollectionViewLayout содержит переопределяемый метод targetContentOffsetForProposedContentOffset:, который позволяет вам обеспечить правильное смещение содержимого во время изменения макета, и это будет правильно анимироваться. Это доступно в iOS 7.0 и выше

Ответ 3

Эта проблема также меня укусила, и это похоже на ошибку в коде перехода. Из того, что я могу сказать, он пытается сосредоточиться на ячейке, которая ближе всего к центру схемы представления перед переходом. Однако, если в центре предварительного предпросмотра нет ячейки, то она все же пытается центрировать, где ячейка будет пост-переход. Это очень ясно, если вы устанавливаете alwaysBounceVertical/Horizontal равным YES, загружаете представление с помощью одной ячейки и затем выполняете переход макета.

Мне удалось обойти это, явно указав коллекции сосредоточиться на определенной ячейке (первая ячейка видимой ячейки в этом примере) после запуска обновления макета.

[self.collectionView setCollectionViewLayout:[self generateNextLayout] animated:YES];

// scroll to the first visible cell
if ( 0 < self.collectionView.indexPathsForVisibleItems.count ) {
    NSIndexPath *firstVisibleIdx = [[self.collectionView indexPathsForVisibleItems] objectAtIndex:0];
    [self.collectionView scrollToItemAtIndexPath:firstVisibleIdx atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:YES];
}

Ответ 4

Прыжки с последним ответом на мой собственный вопрос.

Библиотека TLLayoutTransitioning обеспечивает отличное решение этой проблемы путем повторной задачи интерактивных переходных API-интерфейсов iOS7s, чтобы сделать неинтерактивный, макет для переходы макета. Он эффективно предоставляет альтернативу setCollectionViewLayout, решая проблему смещения содержимого и добавляя несколько функций:

  • Продолжительность анимации
  • 30 + кривые ослабления (любезно предоставлено Warren Moore AHEasing library)
  • Несколько режимов смещения содержимого

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

Чтобы понять, что я имею в виду, попробуйте запустить "Изменить размер демо" в рабочей области "Примеры" и поиграть с параметрами.

Используется так. Сначала настройте контроллер вида для возврата экземпляра TLTransitionLayout:

- (UICollectionViewTransitionLayout *)collectionView:(UICollectionView *)collectionView transitionLayoutForOldLayout:(UICollectionViewLayout *)fromLayout newLayout:(UICollectionViewLayout *)toLayout
{
    return [[TLTransitionLayout alloc] initWithCurrentLayout:fromLayout nextLayout:toLayout];
}

Затем вместо вызова setCollectionViewLayout вызовите transitionToCollectionViewLayout:toLayout, определенный в категории UICollectionView-TLLayoutTransitioning:

UICollectionViewLayout *toLayout = ...; // the layout to transition to
CGFloat duration = 2.0;
AHEasingFunction easing = QuarticEaseInOut;
TLTransitionLayout *layout = (TLTransitionLayout *)[collectionView transitionToCollectionViewLayout:toLayout duration:duration easing:easing completion:nil];

Этот вызов инициирует интерактивный переход и внутренний запрос CADisplayLink, который управляет прогрессом перехода с указанной функцией продолжительности и ослабления.

Следующий шаг - указать окончательное смещение содержимого. Вы можете указать любое произвольное значение, но метод toContentOffsetForLayout, определенный в UICollectionView-TLLayoutTransitioning, обеспечивает элегантный способ вычисления смещений контента относительно одного или нескольких указательных путей. Например, для того, чтобы конкретная ячейка могла оказаться как можно ближе к центру представления коллекции, сразу после transitionToCollectionViewLayout выполните следующий вызов:

NSIndexPath *indexPath = ...; // the index path of the cell to center
TLTransitionLayoutIndexPathPlacement placement = TLTransitionLayoutIndexPathPlacementCenter;
CGPoint toOffset = [collectionView toContentOffsetForLayout:layout indexPaths:@[indexPath] placement:placement];
layout.toContentOffset = toOffset;

Ответ 5

Легко.

Анимируйте новый макет и collectionView contentOffset в том же блоке анимации.

[UIView animateWithDuration:0.3 animations:^{
                                 [self.collectionView setCollectionViewLayout:self.someLayout animated:YES completion:nil];
                                 [self.collectionView setContentOffset:CGPointMake(0, -64)];
                             } completion:nil];

Он будет поддерживать константу self.collectionView.contentOffset.

Ответ 6

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

@interface CustomLayout ()

@property (nonatomic) NSValue *previousContentOffset;

@end

@implementation CustomLayout

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
    CGPoint previousContentOffset = [self.previousContentOffset CGPointValue];
    CGPoint superContentOffset = [super targetContentOffsetForProposedContentOffset:proposedContentOffset];
    return self.previousContentOffset != nil ? previousContentOffset : superContentOffset ;
}

- (void)prepareForTransitionFromLayout:(UICollectionViewLayout *)oldLayout
{
    self.previousContentOffset = [NSValue valueWithCGPoint:self.collectionView.contentOffset];
    return [super prepareForTransitionFromLayout:oldLayout];
}

- (void)finalizeLayoutTransition
{
    self.previousContentOffset = nil;
    return [super finalizeLayoutTransition];
}
@end

Все это делает сохранение предыдущего смещения содержимого перед переходом макета в prepareForTransitionFromLayout, переписывание нового смещения содержимого в targetContentOffsetForProposedContentOffset и очистка его в finalizeLayoutTransition. Довольно простой

Ответ 7

Если это помогает добавить к телу опыта: я постоянно сталкивался с этой проблемой независимо от размера моего контента, независимо от того, установил ли я контентную вставку или какой-либо другой очевидный фактор. Поэтому мой подход был несколько резким. Сначала я подклассифицировал UICollectionView и добавил для борьбы с неправильной настройкой смещения содержимого:

- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated
{
    if(_declineContentOffset) return;
    [super setContentOffset:contentOffset];
}

- (void)setContentOffset:(CGPoint)contentOffset
{
    if(_declineContentOffset) return;
    [super setContentOffset:contentOffset];
}

- (void)setCollectionViewLayout:(UICollectionViewLayout *)layout animated:(BOOL)animated
{
    _declineContentOffset ++;
    [super setCollectionViewLayout:layout animated:animated];
    _declineContentOffset --;
}

- (void)setContentSize:(CGSize)contentSize
{
    _declineContentOffset ++;
    [super setContentSize:contentSize];
    _declineContentOffset --;
}

Я не горжусь этим, но единственное работоспособное решение, похоже, полностью отвергает любую попытку просмотра коллекции, чтобы установить собственное смещение содержимого, вызванное вызовом setCollectionViewLayout:animated:. Эмпирически похоже, что это изменение происходит непосредственно в непосредственном вызове, что, очевидно, не гарантируется интерфейсом или документацией, но имеет смысл с точки зрения Core Animation, поэтому я, возможно, только 50% неудобно с этим предположением.

Однако возникла вторая проблема: UICollectionView теперь добавлял небольшой прыжок к тем представлениям, которые оставались в одном и том же месте на новом макете просмотра коллекций - отталкивая их примерно на 240 точек, а затем оживляя их обратно в исходное положение. Я не понимаю, почему, но я изменил свой код, чтобы справиться с ним, тем не менее, отделив CAAnimation, который был добавлен в какие-либо ячейки, которые на самом деле не двигались:

- (void)setCollectionViewLayout:(UICollectionViewLayout *)layout animated:(BOOL)animated
{
    // collect up the positions of all existing subviews
    NSMutableDictionary *positionsByViews = [NSMutableDictionary dictionary];
    for(UIView *view in [self subviews])
    {
        positionsByViews[[NSValue valueWithNonretainedObject:view]] = [NSValue valueWithCGPoint:[[view layer] position]];
    }

    // apply the new layout, declining to allow the content offset to change
    _declineContentOffset ++;
    [super setCollectionViewLayout:layout animated:animated];
    _declineContentOffset --;

    // run through the subviews again...
    for(UIView *view in [self subviews])
    {
        // if UIKit has inexplicably applied animations to these views to move them back to where
        // they were in the first place, remove those animations
        CABasicAnimation *positionAnimation = (CABasicAnimation *)[[view layer] animationForKey:@"position"];
        NSValue *sourceValue = positionsByViews[[NSValue valueWithNonretainedObject:view]];
        if([positionAnimation isKindOfClass:[CABasicAnimation class]] && sourceValue)
        {
            NSValue *targetValue = [NSValue valueWithCGPoint:[[view layer] position]];

            if([targetValue isEqualToValue:sourceValue])
                [[view layer] removeAnimationForKey:@"position"];
        }
    }
}

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

Итак, это мое текущее решение.

Ответ 8

swift 3.

Подкласс

UICollectionViewLayout. Способ cdemiris99:

override public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
    return collectionView?.contentOffset ?? CGPoint.zero
}

Я сам тестировал его, используя свой собственный layout

Ответ 9

Фактическое решение 2019 года

Скажем, у вас есть несколько макетов для вашего представления "Автомобили".

Apple облажалась, она будет прыгать, когда вы анимируете между макетами.

var avoidAppleFuckupCarsLayouts: CGPoint? = nil

class FixerForCarsLayouts: UICollectionViewLayout {

    override func prepareForTransition(from oldLayout: UICollectionViewLayout) {
        avoidAppleFuckupCarsLayouts = collectionView?.contentOffset
    }

    override func targetContentOffset(
        forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        if avoidAppleFuckupCarsLayouts != nil {
            return avoidAppleFuckupCarsLayouts!
        }
        return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
    }
}

.

Скажем, у вас есть различные макеты для экрана "Автомобили":

CarsLayout1: FixerForCarsLayouts { ...
CarsLayout2: FixerForCarsLayouts { ...
CarsLayout3: FixerForCarsLayouts { ...

Вот оно. Это работает.

Apple, спасибо за создание еще одной загадочной проблемы, которой никогда не должно было быть.

Сноски

  1. Невероятно маловероятно, что у вас могут быть разные "наборы" раскладок (для автомобилей, собак и т.д.), Которые могут (предположительно) сталкиваться. По этой причине имейте глобальный и базовый класс (как указано выше) для каждого "набора".

  2. Это было изобретено мимоходом пользователя @Isaacliu, много лет назад. Благодаря.

  3. Добавлена деталь, FWIW в фрагменте кода Исааклю, finalizeLayoutTransition. Это не нужно по логике.

Ответ 10

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

Ответ Tommy был единственным, что работало для меня в версиях до 10.2. Теперь я делаю следующее.

class HackedCollectionView: UICollectionView {

    var ignoreContentOffsetChanges = false

    override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
        guard ignoreContentOffsetChanges == false else { return }
        super.setContentOffset(contentOffset, animated: animated)
    }

    override var contentOffset: CGPoint {
        get {
            return super.contentOffset
        }
        set {
            guard ignoreContentOffsetChanges == false else { return }
            super.contentOffset = newValue
        }
    }

    override func setCollectionViewLayout(_ layout: UICollectionViewLayout, animated: Bool) {
        guard ignoreContentOffsetChanges == false else { return }
        super.setCollectionViewLayout(layout, animated: animated)
    }

    override var contentSize: CGSize {
        get {
            return super.contentSize
        }
        set {
            guard ignoreContentOffsetChanges == false else { return }
            super.contentSize = newValue
        }
    }

}

Затем, когда я устанавливаю макет, я делаю это...

let theContentOffsetIActuallyWant = CGPoint(x: 0, y: 100)
UIView.animate(withDuration: animationDuration,
               delay: 0, options: animationOptions,
               animations: {
                collectionView.setCollectionViewLayout(layout, animated: true, completion: { completed in
                    // I'm also doing something in my layout, but this may be redundant now
                    layout.overriddenContentOffset = nil
                })
                collectionView.ignoreContentOffsetChanges = true
}, completion: { _ in
    collectionView.ignoreContentOffsetChanges = false
    collectionView.setContentOffset(theContentOffsetIActuallyWant, animated: false)
})

Ответ 11

Это, наконец, сработало для меня (Swift 3)

self.collectionView.collectionViewLayout = UICollectionViewFlowLayout()
self.collectionView.setContentOffset(CGPoint(x: 0, y: -118), animated: true)