Animating UICollectionView contentOffset does not display non-visible cells

后端 未结 7 2101
旧巷少年郎
旧巷少年郎 2021-02-01 18:31

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

相关标签:
7条回答
  • 2021-02-01 18:47

    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

    0 讨论(0)
  • 2021-02-01 19:02

    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];
    }
    
    0 讨论(0)
  • 2021-02-01 19:04

    Use :scrollToItemAtIndexPath instead:

    [UIView animateWithDuration:duration animations:^{
        [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]
                                        atScrollPosition:UICollectionViewScrollPositionNone animated:NO];
    }];
    
    0 讨论(0)
  • 2021-02-01 19:08

    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.

    0 讨论(0)
  • 2021-02-01 19:10

    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];
    
    0 讨论(0)
  • 2021-02-01 19:10

    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.

    0 讨论(0)
提交回复
热议问题