【问题标题】:How to animate collection view layout change while using `layoutAttributesForElements`?使用`layoutAttributesForElements`时如何为集合视图布局更改设置动画?
【发布时间】:2021-10-17 15:53:07
【问题描述】:

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

动画很流畅,对吧?这是当前的工作代码 (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($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
    }
}
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($0.frame) } /// delete this line
    return shrinkingEdgeCellAttributes(in: rect) /// replace with this
}
Film-strip List

缩放/收缩动画很棒。但是,当我在布局之间切换时,过渡动画会中断。

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

如何修复此动画?我应该使用自定义的UICollectionViewTransitionLayout,如果是,如何使用?

【问题讨论】:

    标签: ios swift uicollectionview uicollectionviewlayout


    【解决方案1】:

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

    有效!

    问题

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

    这是因为您已经覆盖了 'shouldInvalidateLayout' 以返回 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
        }
    }
    

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

    关键点是,

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

    • 注意layoutAttributesForElements中布局属性的错误查询。调试你的矩形!

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

    注意事项

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

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

    3. ForkPull request

    【讨论】:

    • 哇!非常感谢您的时间和回答。我一会儿看看。
    猜你喜欢
    • 1970-01-01
    • 2020-06-12
    • 2015-05-20
    • 2013-10-21
    • 1970-01-01
    • 2023-04-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多