24

I'm working on some ticker-like functionality and am using a UICollectionView. It was originally a scrollView, but we figure a collectionView will make it easier to add/remove cells.

I am animating the collectionView with the following:

- (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];
}

This works fine for the scroll view, and the animation is happening with the collection view. However, only the cells that are visible at the end of the animation are actually rendered. Adjusting the contentOffset is not causing cellForItemAtIndexPath to be called. How can I get the cells to render when the contentOffset changes?

EDIT: For a bit more reference (not sure if it's much help):

- (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];
    }
}
ravun
  • 1,493
  • 9
  • 25
  • 44
  • Is there any particular reason you're using `animateWithDuration:...` to change the scrolling? There is a built in method `scrollToItemAtIndexPath:atScrollPosition:animated:`. – Fogmeister Mar 25 '14 at 14:51
  • Also, could you show the code for `collectionView:cellForItemAtIndexPath` and possibly a video of what is currently happening? (You can use QuickTime player to record the simulator). – Fogmeister Mar 25 '14 at 14:55
  • @Fogmeister I'm not using `scrollToItemAtIndexPath:atScrollPosition:animated:` because I cannot set a duration. I'm wanting a scrolling ticker... – ravun Mar 25 '14 at 16:01
  • You mean like a row of images or something that constantly scrolls across the screen? Something like that? – Fogmeister Mar 25 '14 at 16:37
  • you never answered my question. – Fogmeister Mar 28 '14 at 21:04
  • Yea it's a row of text, like a sports or news ticker, that continuously scrolls. – ravun Mar 31 '14 at 14:30
  • could you not just use a UIScrollView then? Why do you need a UICollectionView? – Fogmeister Mar 31 '14 at 14:36

7 Answers7

43

You should simply add [self.view layoutIfNeeded]; inside the animation block, like so:

[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];
AmitP
  • 4,985
  • 4
  • 31
  • 27
  • Strong code, softly spoken. This is massive. Needs more numbers by it ;) – Geoff H Jul 16 '15 at 21:31
  • 5
    This is great, thanks for your answer. I'll add that I had to wrap `layoutIfNeeded` in a `UIView.performWithoutAnimations` block to avoid animating the size of newly visible cells. It feels weird to wrap that immediately in an `animateWithDuration` but it worked well. – bloudermilk Sep 18 '15 at 02:02
  • can anyone explain why this works? I'm really confused :( – bogardon May 05 '16 at 06:03
  • 1
    @bloudermilk I believe you mean `UIView.performWithoutAnimation` - no 's' – David Zorychta Jul 21 '16 at 00:44
  • 12
    This doesn't work for me. The cells still disappear. – tbag Jul 03 '18 at 16:54
8

You could try using a CADisplayLink to drive the animation yourself. This is not too hard to set up since you are using a Linear animation curve anyway. Here's a basic implementation that may work for you:

@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];
}
devdavid
  • 1,543
  • 10
  • 14
2

I've built upon what's already in these answers and made a generic manual animator, as everything can be distilled down to a percentage float value and a block.

class ManualAnimator {
    
    enum AnimationCurve {
        
        case linear, parametric, easeInOut, easeIn, easeOut
        
        func modify(_ x: CGFloat) -> CGFloat {
            switch self {
            case .linear:
                return x
            case .parametric:
                return x.parametric
            case .easeInOut:
                return x.quadraticEaseInOut
            case .easeIn:
                return x.quadraticEaseIn
            case .easeOut:
                return x.quadraticEaseOut
            }
        }
        
    }
    
    private var displayLink: CADisplayLink?
    private var start = Date()
    private var total = TimeInterval(0)
    private var closure: ((CGFloat) -> Void)?
    private var animationCurve: AnimationCurve = .linear
    
    func animate(duration: TimeInterval, curve: AnimationCurve = .linear, _ animations: @escaping (CGFloat) -> Void) {
        guard duration > 0 else { animations(1.0); return }
        reset()
        start = Date()
        closure = animations
        total = duration
        animationCurve = curve
        let d = CADisplayLink(target: self, selector: #selector(tick))
        d.add(to: .current, forMode: .common)
        displayLink = d
    }

    @objc private func tick() {
        let delta = Date().timeIntervalSince(start)
        var percentage = animationCurve.modify(CGFloat(delta) / CGFloat(total))
        //print("%:", percentage)
        if percentage < 0.0 { percentage = 0.0 }
        else if percentage >= 1.0 { percentage = 1.0; reset() }
        closure?(percentage)
    }

    private func reset() {
        displayLink?.invalidate()
        displayLink = nil
    }
}

extension CGFloat {
    
    fileprivate var parametric: CGFloat {
        guard self > 0.0 else { return 0.0 }
        guard self < 1.0 else { return 1.0 }
        return ((self * self) / (2.0 * ((self * self) - self) + 1.0))
    }
    
    fileprivate var quadraticEaseInOut: CGFloat {
        guard self > 0.0 else { return 0.0 }
        guard self < 1.0 else { return 1.0 }
        if self < 0.5 { return 2 * self * self }
        return (-2 * self * self) + (4 * self) - 1
    }
    
    fileprivate var quadraticEaseOut: CGFloat {
        guard self > 0.0 else { return 0.0 }
        guard self < 1.0 else { return 1.0 }
        return -self * (self - 2)
    }
    
    fileprivate var quadraticEaseIn: CGFloat {
        guard self > 0.0 else { return 0.0 }
        guard self < 1.0 else { return 1.0 }
        return self * self
    }
}

Implementation

let initialOffset = collectionView.contentOffset.y
let delta = collectionView.bounds.size.height
let animator = ManualAnimator()
animator.animate(duration: TimeInterval(1.0), curve: .easeInOut) { [weak self] (percentage) in
    guard let `self` = self else { return }
    self.collectionView.contentOffset = CGPoint(x: 0.0, y: initialOffset + (delta * percentage))
    if percentage == 1.0 { print("Done") }
}

It might be worth combining the animate function with an init method.. it's not a huge deal though.

Magoo
  • 2,312
  • 1
  • 19
  • 38
1

Here is a swift implementation, with comments explaining why this is needed.

The idea is the same as in devdavid's answer, only the implementation approach is different.

/*
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
}
Aurelien Porte
  • 2,583
  • 25
  • 31
0

I suspect that UICollectionView is trying to improve performance by waiting until the end of the scroll before updating.

Perhaps you could divide the animation up into chucks, although I'm not sure how smooth that would be.

Or maybe calling setNeedsDisplay periodically during the scroll?

Alternatively, perhaps this replacement for UICollectionView will either do want you need or else can be modified to do so:

https://github.com/steipete/PSTCollectionView

tarmes
  • 15,008
  • 8
  • 50
  • 85
  • Yea I thought about doing it in chunks. I was hoping to get this working some how, but I guess I will need to do it that way or stick with the UIScrollView implementation I have. – ravun Mar 25 '14 at 16:03
0

If you need to start animation before user start dragging UICollectionView (e.g. from one page to another page), you can use this workaround to preload side cells:

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()
        }
    }
}

Without this workaround, UICollectionView will remove cells, that not intersects target bounds, before the animation starts.

P.S. This working only if you need animate to next or previous page.

Roman
  • 186
  • 1
  • 8
-5

Use :scrollToItemAtIndexPath instead:

[UIView animateWithDuration:duration animations:^{
    [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]
                                    atScrollPosition:UICollectionViewScrollPositionNone animated:NO];
}];
Snowman
  • 29,431
  • 43
  • 165
  • 290
  • I want to animate the scrolling with a variable duration. This method only scrolls to the indexPath. – ravun Mar 24 '14 at 19:17
  • @ravun make sure scrollToItemAtIndexPath animated is set to NO. – Snowman Mar 24 '14 at 19:57
  • 3
    This does the same thing as setting the collectionView's contentOffset. The collectionView scrolls, but the cells are not rendered during the animation. – ravun Mar 25 '14 at 14:11