Animating UICollectionView contentOffset does not display non-visible cells
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.
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
}
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];
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];
}