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

ahe*_*eze 8 ios uicollectionview uicollectionviewlayout swift

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

使用动画在胶片和列表模式之间切换

动画很好很流畅,对吧?这是当前的工作代码(这里是完整的演示项目):

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($0.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($0.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
    }
}
Run Code Online (Sandbox Code Playgroud)
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!
}
Run Code Online (Sandbox Code Playgroud)

同样,一切都完美无缺,包括动画。然后,我试图让单元格在接近屏幕边缘时缩小。我超越layoutAttributesForElements了这样做。

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return layoutAttributes.filter { rect.intersects($0.frame) } /// delete this line
    return shrinkingEdgeCellAttributes(in: rect) /// replace with this
}
Run Code Online (Sandbox Code Playgroud)
胶卷 列表
水平滚动时边缘单元格缩小 垂直滚动时边缘单元格缩小

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

之前 ( return layoutAttributes.filter...) 之后 ( return shrinkingEdgeCellAttributes(in: rect))
以流畅的动画在胶片和列表模式之间切换 在胶片和列表模式之间切换,动画很破碎

我该如何修复这个动画?我应该使用 custom UICollectionViewTransitionLayout,如果是,如何使用?

Thi*_*oda 6

哇!这是一次锻炼。我能够修改你的,FlowLayout以便动画中没有打嗝。见下文。

有用!

问题

这就是正在发生的事情。当您更改布局时,如果集合视图的内容偏移量不是,则layoutAttributesForElements方法 in 将FlowLayout被调用两次(0, 0)

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

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

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

let visibleRect = CGRect(
    origin: collectionView.contentOffset, 
    size: collectionView.frame.size
)
Run Code Online (Sandbox Code Playgroud)

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

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

解决方案

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

  1. 编写 helpers 方法将前一个布局留下的内容偏移量(和相关的可见矩形)转换为对这个布局有意义的值。
  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)
}
Run Code Online (Sandbox Code Playgroud)
  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)
        }
    }

// ...

Run Code Online (Sandbox Code Playgroud)

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

/**
 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
    }
}
Run Code Online (Sandbox Code Playgroud)

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

关键要点是,

  • 使用targetContentOffset时,在内容上任意变化发生偏移响应布局的变化。

  • 小心不正确查询layoutAttributesForElements. 调试你的矩形!

  • 请记住清除prepare()方法上的缓存布局属性。

笔记

  1. 即使在您引入缩放变换之前,“跳跃”行为也很明显,如gif 所示

  2. 如果答案很长,我真诚地道歉。或者,解决方案不是您想要的。这个问题很有趣,这就是为什么我花了一整天的时间试图找到一种帮助方法。

  3. 分叉拉取请求

  • 哇!非常感谢您的时间和回答。我会看一看。 (3认同)
  • 完美运行,再次感谢! (3认同)
  • 不用客气! (3认同)