Анимация UICollectionView contentOffset не отображает невидимые ячейки

Я работаю над некоторыми тикерами и использую UICollectionView. Первоначально это был scrollView, но мы полагаем, что collectionView упростит добавление/удаление ячеек.

Я оживляю collectionView со следующим:

- (void)beginAnimation {
    [UIView animateWithDuration:((self.collectionView.collectionViewLayout.collectionViewContentSize.width - self.collectionView.contentOffset.x) / 75) delay:0 options:(UIViewAnimationOptionCurveLinear | UIViewAnimationOptionRepeat | UIViewAnimationOptionBeginFromCurrentState) animations:^{
        self.collectionView.contentOffset = CGPointMake(self.collectionView.collectionViewLayout.collectionViewContentSize.width, 0);
    } completion:nil];
}

Это отлично подходит для просмотра прокрутки, и анимация происходит с представлением коллекции. Однако на самом деле отображаются только те ячейки, которые видны в конце анимации. Настройка contentOffset не вызывает вызов cellForItemAtIndexPath. Как я могу отображать ячейки при изменении содержимогоOffset?

EDIT: Для получения дополнительной информации (не уверен, если это поможет):

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    TickerElementCell *cell = (TickerElementCell *)[collectionView dequeueReusableCellWithReuseIdentifier:@"TickerElementCell" forIndexPath:indexPath];
    cell.ticker = [self.fetchedResultsController objectAtIndexPath:indexPath];
    return cell;
}

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {

    // ...

    [self loadTicker];
}

- (void)loadTicker {

    // ...

    if (self.animating) {
        [self updateAnimation];
    }
    else {
        [self beginAnimation];
    }
}

- (void)beginAnimation {

    if (self.animating) {
        [self endAnimation];
    }

    if ([self.tickerElements count] && !self.animating && !self.paused) {
        self.animating = YES;
        self.collectionView.contentOffset = CGPointMake(1, 0);
        [UIView animateWithDuration:((self.collectionView.collectionViewLayout.collectionViewContentSize.width - self.collectionView.contentOffset.x) / 75) delay:0 options:(UIViewAnimationOptionCurveLinear | UIViewAnimationOptionAllowUserInteraction | UIViewAnimationOptionRepeat | UIViewAnimationOptionBeginFromCurrentState) animations:^{
            self.collectionView.contentOffset = CGPointMake(self.collectionView.collectionViewLayout.collectionViewContentSize.width, 0);
        } completion:nil];
    }
}

Ответы

Ответ 1

Вы должны просто добавить [self.view layoutIfNeeded]; внутри блока анимации, например:

[UIView animateWithDuration:((self.collectionView.collectionViewLayout.collectionViewContentSize.width - self.collectionView.contentOffset.x) / 75) delay:0 options:(UIViewAnimationOptionCurveLinear | UIViewAnimationOptionAllowUserInteraction | UIViewAnimationOptionRepeat | UIViewAnimationOptionBeginFromCurrentState) animations:^{
            self.collectionView.contentOffset = CGPointMake(self.collectionView.collectionViewLayout.collectionViewContentSize.width, 0);
            [self.view layoutIfNeeded];
        } completion:nil];

Ответ 2

Вы можете попробовать использовать CADisplayLink, чтобы самостоятельно управлять анимацией. Это не так сложно настроить, так как вы все равно используете линейную анимационную кривую. Вот базовая реализация, которая может работать для вас:

@property (nonatomic, strong) CADisplayLink *displayLink;
@property (nonatomic, assign) CFTimeInterval lastTimerTick;
@property (nonatomic, assign) CGFloat animationPointsPerSecond;
@property (nonatomic, assign) CGPoint finalContentOffset;

-(void)beginAnimation {
    self.lastTimerTick = 0;
    self.animationPointsPerSecond = 50;
    self.finalContentOffset = CGPointMake(..., ...);
    self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkTick:)];
    [self.displayLink setFrameInterval:1];
    [self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}

-(void)endAnimation {
    [self.displayLink invalidate];
    self.displayLink = nil;
}

-(void)displayLinkTick {
    if (self.lastTimerTick = 0) {
        self.lastTimerTick = self.displayLink.timestamp;
        return;
    }
    CFTimeInterval currentTimestamp = self.displayLink.timestamp;
    CGPoint newContentOffset = self.collectionView.contentOffset;
    newContentOffset.x += self.animationPointsPerSecond * (currentTimestamp - self.lastTimerTick)
    self.collectionView.contentOffset = newContentOffset;

    self.lastTimerTick = currentTimestamp;

    if (newContentOffset.x >= self.finalContentOffset.x)
        [self endAnimation];
}

Ответ 3

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

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

Или может быть вызов setNeedsDisplay периодически во время прокрутки?

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

https://github.com/steipete/PSTCollectionView

Ответ 4

Вот краткая реализация с комментариями, объясняющими, почему это необходимо.

Идея такая же, как в ответе devdavid, только подход к реализации отличается.

/*
Animated use of `scrollToContentOffset:animated:` doesn't give enough control over the animation duration and curve.
Non-animated use of `scrollToContentOffset:animated:` (or contentOffset directly) embedded in an animation block gives more control but interfer with the internal logic of UICollectionView. For example, cells that are not visible for the target contentOffset are removed at the beginning of the animation because from the collection view point of view, the change is not animated and the cells can safely be removed.
To fix that, we must control the scroll ourselves. We use CADisplayLink to update the scroll offset step-by-step and render cells if needed alongside. To simplify, we force a linear animation curve, but this can be adapted if needed.
*/
private var currentScrollDisplayLink: CADisplayLink?
private var currentScrollStartTime = Date()
private var currentScrollDuration: TimeInterval = 0
private var currentScrollStartContentOffset: CGFloat = 0.0
private var currentScrollEndContentOffset: CGFloat = 0.0

// The curve is hardcoded to linear for simplicity
private func beginAnimatedScroll(toContentOffset contentOffset: CGPoint, animationDuration: TimeInterval) {
  // Cancel previous scroll if needed
  resetCurrentAnimatedScroll()

  // Prevent non-animated scroll
  guard animationDuration != 0 else {
    logAssertFail("Animation controlled scroll must not be used for non-animated changes")
    collectionView?.setContentOffset(contentOffset, animated: false)
    return
  }

  // Setup new scroll properties
  currentScrollStartTime = Date()
  currentScrollDuration = animationDuration
  currentScrollStartContentOffset = collectionView?.contentOffset.y ?? 0.0
  currentScrollEndContentOffset = contentOffset.y

  // Start new scroll
  currentScrollDisplayLink = CADisplayLink(target: self, selector: #selector(handleScrollDisplayLinkTick))
  currentScrollDisplayLink?.add(to: RunLoop.current, forMode: .commonModes)
}

@objc
private func handleScrollDisplayLinkTick() {
  let animationRatio = CGFloat(abs(currentScrollStartTime.timeIntervalSinceNow) / currentScrollDuration)

  // Animation is finished
  guard animationRatio < 1 else {
    endAnimatedScroll()
    return
  }

  // Animation running, update with incremental content offset
  let deltaContentOffset = animationRatio * (currentScrollEndContentOffset - currentScrollStartContentOffset)
  let newContentOffset = CGPoint(x: 0.0, y: currentScrollStartContentOffset + deltaContentOffset)
  collectionView?.setContentOffset(newContentOffset, animated: false)
}

private func endAnimatedScroll() {
  let newContentOffset = CGPoint(x: 0.0, y: currentScrollEndContentOffset)
  collectionView?.setContentOffset(newContentOffset, animated: false)

  resetCurrentAnimatedScroll()
}

private func resetCurrentAnimatedScroll() {
  currentScrollDisplayLink?.invalidate()
  currentScrollDisplayLink = nil
}

Ответ 5

Если вам нужно запустить анимацию, прежде чем пользователь начнет перетаскивать UICollectionView (например, с одной страницы на другую страницу), вы можете использовать это обходное решение для предварительной загрузки боковых ячеек:

func scroll(to index: Int, progress: CGFloat = 0) {
    let isInsideAnimation = UIView.inheritedAnimationDuration > 0

    if isInsideAnimation {
        // workaround
        // preload left & right cells
        // without this, some cells will be immediately removed before animation starts
        preloadSideCells()
    }

    collectionView.contentOffset.x = (CGFloat(index) + progress) * collectionView.bounds.width

    if isInsideAnimation {
        // workaround
        // sometimes invisible cells not removed (because of side cells preloading)
        // without this, some invisible cells will persists on superview after animation ends
        removeInvisibleCells()

        UIView.performWithoutAnimation {
            self.collectionView.layoutIfNeeded()
        }
    }
}

private func preloadSideCells() {
    collectionView.contentOffset.x -= 0.5
    collectionView.layoutIfNeeded()
    collectionView.contentOffset.x += 1
    collectionView.layoutIfNeeded()
}

private func removeInvisibleCells() {
    let visibleCells = collectionView.visibleCells

    let visibleRect = CGRect(
        x: max(0, collectionView.contentOffset.x - collectionView.bounds.width),
        y: collectionView.contentOffset.y,
        width: collectionView.bounds.width * 3,
        height: collectionView.bounds.height
    )

    for cell in visibleCells {
        if !visibleRect.intersects(cell.frame) {
            cell.removeFromSuperview()
        }
    }
}

Без этого решения UICollectionView удалит ячейки, которые не пересекают целевые границы, до начала анимации.

P.S. Это работает, только если вам нужна анимация на следующей или предыдущей странице.

Ответ 6

Вместо этого используйте :scrollToItemAtIndexPath:

[UIView animateWithDuration:duration animations:^{
    [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]
                                    atScrollPosition:UICollectionViewScrollPositionNone animated:NO];
}];