如何在使用“layoutAttributesForElements”时为集合视图布局更改设置动画?

How to animate collection view layout change while using `layoutAttributesForElements`?

我制作了一个自定义集合视图流布局,可以在“电影片段”和“列表”布局之间切换(带动画)。但是在向边缘单元格添加一些花哨的动画后,切换动画就坏了。这是目前的样子,没有这些变化:

动画很流畅,对吧?这是当前的工作代码 (full demo project here):

enum LayoutType {
    case strip
    case list
}

class FlowLayout: UICollectionViewFlowLayout {
    
    var layoutType: LayoutType
    var layoutAttributes = [UICollectionViewLayoutAttributes]() /// store the frame of each item
    var contentSize = CGSize.zero /// the scrollable content size of the collection view
    override var collectionViewContentSize: CGSize { return contentSize } /// pass scrollable content size back to the collection view
    
    /// pass attributes to the collection view flow layout
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return layoutAttributes[indexPath.item]
    }
    
    // MARK: - Problem is here
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        
        /// edge cells don't shrink, but the animation is perfect
        return layoutAttributes.filter { rect.intersects([=10=].frame) } /// try deleting this line
        
        /// edge cells shrink (yay!), but the animation glitches out
        return shrinkingEdgeCellAttributes(in: rect)
    }
    
    /// makes the edge cells slowly shrink as you scroll
    func shrinkingEdgeCellAttributes(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let collectionView = collectionView else { return nil }

        let rectAttributes = layoutAttributes.filter { rect.intersects([=10=].frame) }
        let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size) /// rect of the visible collection view cells

        let leadingCutoff: CGFloat = 50 /// once a cell reaches here, start shrinking it
        let trailingCutoff: CGFloat
        let paddingInsets: UIEdgeInsets /// apply shrinking even when cell has passed the screen's bounds

        if layoutType == .strip {
            trailingCutoff = CGFloat(collectionView.bounds.width - leadingCutoff)
            paddingInsets = UIEdgeInsets(top: 0, left: -50, bottom: 0, right: -50)
        } else {
            trailingCutoff = CGFloat(collectionView.bounds.height - leadingCutoff)
            paddingInsets = UIEdgeInsets(top: -50, left: 0, bottom: -50, right: 0)
        }

        for attributes in rectAttributes where visibleRect.inset(by: paddingInsets).contains(attributes.center) {
            /// center of each cell, converted to a point inside `visibleRect`
            let center = layoutType == .strip
                ? attributes.center.x - visibleRect.origin.x
                : attributes.center.y - visibleRect.origin.y

            var offset: CGFloat?
            if center <= leadingCutoff {
                offset = leadingCutoff - center /// distance from the cutoff, 0 if exactly on cutoff
            } else if center >= trailingCutoff {
                offset = center - trailingCutoff
            }

            if let offset = offset {
                let scale = 1 - (pow(offset, 1.1) / 200) /// gradually shrink the cell
                attributes.transform = CGAffineTransform(scaleX: scale, y: scale)
            }
        }
        return rectAttributes
    }
    
    /// initialize with a LayoutType
    init(layoutType: LayoutType) {
        self.layoutType = layoutType
        super.init()
    }
    
    /// make the layout (strip vs list) here
    override func prepare() { /// configure the cells' frames
        super.prepare()
        guard let collectionView = collectionView else { return }
        
        var offset: CGFloat = 0 /// origin for each cell
        let cellSize = layoutType == .strip ? CGSize(width: 100, height: 50) : CGSize(width: collectionView.frame.width, height: 50)
        
        for itemIndex in 0..<collectionView.numberOfItems(inSection: 0) {
            let indexPath = IndexPath(item: itemIndex, section: 0)
            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            
            let origin: CGPoint
            let addedOffset: CGFloat
            if layoutType == .strip {
                origin = CGPoint(x: offset, y: 0)
                addedOffset = cellSize.width
            } else {
                origin = CGPoint(x: 0, y: offset)
                addedOffset = cellSize.height
            }
            
            attributes.frame = CGRect(origin: origin, size: cellSize)
            layoutAttributes.append(attributes)
            offset += addedOffset
        }
        
        self.contentSize = layoutType == .strip /// set the collection view's `collectionViewContentSize`
            ? CGSize(width: offset, height: cellSize.height) /// if strip, height is fixed
            : CGSize(width: cellSize.width, height: offset) /// if list, width is fixed
    }
    
    /// boilerplate code
    required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
    override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
        let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
        context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
        return context
    }
}
class ViewController: UIViewController {
    
    var data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    
    var isExpanded = false
    lazy var listLayout = FlowLayout(layoutType: .list)
    lazy var stripLayout = FlowLayout(layoutType: .strip)
    
    @IBOutlet weak var collectionView: UICollectionView!
    @IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint!
    @IBAction func toggleExpandPressed(_ sender: Any) {
        isExpanded.toggle()
        if isExpanded {
            collectionView.setCollectionViewLayout(listLayout, animated: true)
        } else {
            collectionView.setCollectionViewLayout(stripLayout, animated: true)
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.collectionViewLayout = stripLayout /// start with the strip layout
        collectionView.dataSource = self
        collectionViewHeightConstraint.constant = 300
    }
}

/// sample data source
extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return data.count
    }
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ID", for: indexPath) as! Cell
        cell.label.text = "\(data[indexPath.item])"
        cell.contentView.layer.borderWidth = 5
        cell.contentView.layer.borderColor = UIColor.red.cgColor
        return cell
    }
}

class Cell: UICollectionViewCell {
    @IBOutlet weak var label: UILabel!
}

同样,一切都完美无缺,包括动画。因此,我试图让细胞在靠近屏幕边缘时收缩。我否决了 layoutAttributesForElements 来执行此操作。

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return layoutAttributes.filter { rect.intersects([=12=].frame) } /// delete this line
    return shrinkingEdgeCellAttributes(in: rect) /// replace with this
}
Film-strip List

scale/shrink 动画很棒。但是,当我在布局之间切换时,过渡动画被破坏了。

Before (return layoutAttributes.filter...) After (return shrinkingEdgeCellAttributes(in: rect))

我该如何修复这个动画?我是否应该使用自定义 UICollectionViewTransitionLayout,如果是,如何使用?

哇!这是一次锻炼。我能够修改您的 FlowLayout 以便动画中没有问题。见下文。

有效!

问题

这就是正在发生的事情。当您更改布局时,如果集合视图的内容偏移不是 (0, 0).

FlowLayout 中的 layoutAttributesForElements 方法将被调用两次

这是因为您已经将 'shouldInvalidateLayout' 覆盖为 return true 而不管它是否真的需要。我相信 UICollectionView 在布局更改之前和之后在布局上调用此方法(根据观察)。

这样做的副作用是您的缩放变换应用了两次——在动画之前和之后应用到可见的布局属性。

不幸的是,缩放变换是基于集合视图的 contentOffset (link)

let visibleRect = CGRect(
    origin: collectionView.contentOffset, 
    size: collectionView.frame.size
)

在布局更改期间,contentOffset 不一致。动画开始前 contentOffset 适用于之前的布局。动画之后,是相对于新布局的。在这里我还注意到,没有充分的理由,contentOffset 会“跳跃”(见注释 1)

由于您使用 visibleRect 查询要应用比例的布局属性集,因此会引入更多错误。

解决方案

我能够通过应用这些更改找到解决方案。

  1. 编写辅助方法,将前一个布局留下的内容偏移量(和依赖的 visibleRect)转换为对此布局有意义的值。
  2. 防止在prepare方法中计算冗余布局属性
  3. 跟踪布局何时和何时不动画
// In Flow Layout

class FlowLayout: UICollectionViewFlowLayout {
    var animating: Bool = false
    // ...
}

// In View Controller,

isExpanded.toggle()
        
if isExpanded {
    listLayout.reset()
    listLayout.animating = true // <--
    // collectionView.setCollectionViewLayout(listLayout)
} else {
    stripLayout.reset()
    stripLayout.animating = true // <--
    // collectionView.setCollectionViewLayout(stripLayout)
}
  1. 重写 targetContentOffset 方法来处理内容偏移变化(防止跳转)
// In Flow Layout

class FlowLayout: UICollectionViewFlowLayout {
    
    var animating: Bool = false
    var layoutType: LayoutType
    // ...
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        guard animating else {
            // return super
        }

        // Use our 'graceful' content content offset
        // instead of arbitrary "jump"
        
        switch(layoutType){
        case .list: return transformCurrentContentOffset(.fromStripToList)
        case .strip: return transformCurrentContentOffset(.fromListToStrip)
        }
    }

// ...

内容偏移量变换的实现如下

/**
 Transforms this layouts content offset, to the other layout
 as specified in the layout transition parameter.
*/
private func transformCurrentContentOffset(_ transition: LayoutTransition) -> CGPoint{
    
    let stripItemWidth: CGFloat = 100.0
    let listItemHeight: CGFloat = 50.0
    
    switch(transition){
    case .fromStripToList:
        let numberOfItems = collectionView!.contentOffset.x / stripItemWidth  // from strip
        var newPoint = CGPoint(x: 0, y: numberOfItems * CGFloat(listItemHeight)) // to list

        if (newPoint.y + collectionView!.frame.height) >= contentSize.height{
            newPoint = CGPoint(x: 0, y: contentSize.height - collectionView!.frame.height)
        }

        return newPoint

    case .fromListToStrip:
        let numberOfItems = collectionView!.contentOffset.y / listItemHeight // from list
        var newPoint = CGPoint(x: numberOfItems * CGFloat(stripItemWidth), y: 0) // to strip

        if (newPoint.x + collectionView!.frame.width) >= contentSize.width{
            newPoint = CGPoint(x: contentSize.width - collectionView!.frame.width, y: 0)
        }

        return newPoint
    }
}

我在评论中遗漏了一些小细节,作为对 OP 演示项目的拉取请求,因此任何有兴趣的人都可以研究它。

关键 take-aways 是,

  • 当内容偏移发生任意变化以响应布局变化时使用targetContentOffset

  • 注意layoutAttributesForElements布局属性查询错误。调试你的直肠!

  • 记得在 prepare() 方法中清除缓存的布局属性。

注释

  1. “跳跃”行为甚至在您引入 your gif.

    中所见的缩放变换之前就已经很明显了
  2. 如果回答冗长,我深表歉意。或者,解决方案并不是您想要的。这个问题很有趣,这就是为什么我花了一整天的时间来寻找帮助的方法。

  3. Fork and Pull request.

感谢您的详细调查@Thisura Dodangoda – 它帮助我解决了类似的问题。对于最终来到这里的人,我想添加一个小细节,以防你 运行 进入我做的另一个问题。 UICollectionViewLayout API 有两个非常相似的方法:

func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint

此方法检索停止滚动的点

func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint

此方法检索内容偏移以在动画布局更新或更改后使用

我已经为滚动期间的一些自定义行为实现了第一个,并且我试图在该方法中实现@Thisura Dodangoda 发布的解决方案。 但是,它们用于完全不同的目的。您需要使用第二种方法(不带velocity参数)来实现布局变化的方案。