【问题标题】:Animating UICollectionView contentOffset does not display non-visible cells动画 CollectionView contentOffset 不显示不可见的单元格
【发布时间】:2014-04-18 20:28:38
【问题描述】:

我正在开发一些类似自动收报机的功能,并且正在使用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。当 contentOffset 发生变化时,如何让单元格呈现?

编辑: 更多参考(不确定是否有很大帮助):

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

【问题讨论】:

  • 您使用animateWithDuration:... 更改滚动有什么特别的原因吗?有一个内置方法scrollToItemAtIndexPath:atScrollPosition:animated:
  • 另外,您能否展示collectionView:cellForItemAtIndexPath 的代码以及当前正在发生的事情的视频? (您可以使用 QuickTime 播放器录制模拟器)。
  • @Fogmeister 我没有使用scrollToItemAtIndexPath:atScrollPosition:animated:,因为我无法设置持续时间。我想要一个滚动的股票...
  • 你的意思是像一排图像或在屏幕上不断滚动的东西?类似的东西?
  • 你从来没有回答我的问题。

标签: ios animation uicollectionview contentoffset


【解决方案1】:

我以这些答案中已有的内容为基础,制作了一个通用的手动动画师,因为所有内容都可以浓缩为百分比浮点值和块。

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

实施

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

可能值得将 animate 函数与 init 方法结合使用。不过这并不是什么大问题。

【讨论】:

    【解决方案2】:

    如果您需要在用户开始拖动 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 将在动画开始之前删除不与目标边界相交的单元格。

    附:这仅在您需要动画到 nextprevious 页面时才有效。

    【讨论】:

      【解决方案3】:

      这是一个快速的实现,cmets 解释了为什么需要这样做。

      思路和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
      }
      

      【讨论】:

        【解决方案4】:

        您只需在动画块中添加[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];
        

        【讨论】:

        • 强代码,轻声细语。这是巨大的。需要更多的数字;)
        • 这很好,感谢您的回答。我要补充一点,我必须将layoutIfNeeded 包装在UIView.performWithoutAnimations 块中,以避免为新可见单元格的大小设置动画。立即将其包装在 animateWithDuration 中感觉很奇怪,但效果很好。
        • 谁能解释一下为什么会这样?我真的很困惑:(
        • @bloudermilk 我相信你的意思是UIView.performWithoutAnimation - 没有's'
        • 这对我不起作用。细胞仍然消失。
        【解决方案5】:

        您可以尝试使用 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];
        }
        

        【讨论】:

        • @AmitP 的回答现在似乎更合适了
        【解决方案6】:

        我怀疑UICollectionView 试图通过等到滚动结束再更新来提高性能。

        也许你可以把动画分成几段,虽然我不确定那会是多么流畅。

        或者可能在滚动期间定期调用 setNeedsDisplay?

        或者,也许这个 UICollectionView 的替代品确实需要你需要,或者可以修改为这样做:

        https://github.com/steipete/PSTCollectionView

        【讨论】:

        • 是的,我想过分块做。我希望以某种方式使其工作,但我想我需要这样做或坚持我拥有的 UIScrollView 实现。
        【解决方案7】:

        改用:scrollToItemAtIndexPath

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

        【讨论】:

        • 我想以可变的持续时间为滚动设置动画。此方法只滚动到 indexPath。
        • @ravun 确保 scrollToItemAtIndexPath 动画设置为 NO。
        • 这和设置collectionView的contentOffset一样。 collectionView 滚动,但动画期间未渲染单元格。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多