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
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
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];
}
Use :scrollToItemAtIndexPath
instead:
[UIView animateWithDuration:duration animations:^{
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]
atScrollPosition:UICollectionViewScrollPositionNone animated:NO];
}];
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.
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];
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.