Динамическая настройка макета на 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, спасибо за создание еще одной загадочной проблемы, которой никогда не должно было быть.
Сноски
Невероятно маловероятно, что у вас могут быть разные "наборы" раскладок (для автомобилей, собак и т.д.), Которые могут (предположительно) сталкиваться. По этой причине имейте глобальный и базовый класс (как указано выше) для каждого "набора".
Это было изобретено мимоходом пользователя @Isaacliu, много лет назад. Благодаря.
Добавлена деталь, 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)