用于折叠/堆叠网格项的 UICollectionView 布局 API

问题描述 投票:0回答:1

我正在尝试实现一个 UICollectionView 布局,其工作方式类似于流布局,但可以有条件地将网格的多个项目堆叠到单个单元格中。

现在我正在使用一个辅助结构,

AssetStack
来实现这一点。每个网格项目都会显示这些堆栈之一,即使它只是堆栈中的单个项目。问题在于,随着节数的变化,这使得数据源更新变得棘手,并且它将大量布局逻辑混合到数据结构中。这也意味着我无法轻松地为过渡设置动画。

似乎自定义流程布局或组合布局可以做到这一点,但我不确定如何做到这一点,并且我想我应该问专家在这里。

谢谢大家

swift uicollectionview uicollectionviewlayout uicollectionviewcompositionallayout uicollectionviewflowlayout
1个回答
0
投票

这其中有很多未知的部分,包括......

  • 如何管理数据
  • 您的图像似乎是多个部分?
    • “15”缺失,但如果
      13, 14, 16, 17
      全部被选中,
      16
      17
      是否应该从
      2
      部分移动到
      1
      部分?
  • 您的评论之一说“30k+ 行”
    • 您可以在 200 行中选择 1,000 个单元格吗?
  • 所需的动画效果
    • 如果连续选定单元格的“开始”或“结束”已滚动到视图之外会发生什么?

等等。

但是,让我们从基础开始,看看是否可以从那里开始。


通过自定义

UICollectionViewLayout
我们 为集合视图生成一个框架数组以用于其单元格 - 与我们定义视图框架的方式非常相似。

创建自定义“网格”(认为垂直流布局)相当简单(这不是实际代码 - 只是解释逻辑):

// first cell is at 0,0
var cellFrame: CGRect = .init(x: 0.0, y: 0.0, width: itemSize.width, height: itemSize.height)

for i in 0..<theCells.count {
    
    let indexPath: IndexPath = .init(item: i, section: 0)
    
    theCells[indexPath.item].frame = cellFrame
    
    // increment frame x by item width + spacing
    cellFrame.origin.x += itemSize.width + itemSpacing
    
    if cellFrame.origin.x > simCollectionView.bounds.width {
        // we've exceeded the width, so "wrap around" to the next row
        cellFrame.origin.x = 0.0
        cellFrame.origin.y += itemSize.height + rowSpacing
    }
    
}

它看起来像这样:

现在,假设我们选择一些单元格:

如果我们想“折叠”(或“堆叠”)连续选定的单元格,我们可以稍微修改我们的逻辑:

  • 如果选择了当前单元格,并且
  • 选择了下一个单元格
    • 不要改变当前的单元格框架

所以,我们的循环看起来像这样:

var cellFrame: CGRect = .init(x: 0.0, y: 0.0, width: itemSize.width, height: itemSize.height)

for i in 0..<theCells.count {
    
    let indexPath: IndexPath = .init(item: i, section: 0)
    
    theCells[indexPath.item].frame = cellFrame
    
    // index path for the next cell
    let ipNext: IndexPath = .init(item: i+1, section: 0)
    
    // if we want the consecutive selected cells to "collapse"
    //  if current cell is selected AND the next cell is selected
    if isCollapsed, selectedPaths.contains(indexPath), selectedPaths.contains(ipNext) {
        // don't change frame
    } else {
        cellFrame.origin.x += itemSize.width + itemSpacing
        if cellFrame.origin.x > simCollectionView.bounds.width {
            cellFrame.origin.x = 0.0
            cellFrame.origin.y += itemSize.height + rowSpacing
        }
    }
    
}

我们得到这个输出:

因此,我们可以定义一个“未折叠”的单元格框架数组和第二个“折叠”数组——然后告诉集合视图更新其在动画块内的布局:

UIView.animate(withDuration: 0.3, animations: {
    self.layout.invalidateLayout()
    self.collectionView.layoutIfNeeded()
})

在您的评论中,您想要“微调”堆叠动画...因为集合视图单元格是视图,我们可以单独对它们进行动画处理,然后生成新的布局并对其余单元格进行动画处理。

这是一个完整的示例,您可以使用...


简单单元类

class SimpleCell: UICollectionViewCell {
    class var reuseIdentifier: String { return "\(self)" }
    
    var label: UILabel = UILabel()
    
    let unselectedColor: UIColor = .init(white: 0.9, alpha: 1.0)
    let selectedColor: UIColor = .init(white: 0.75, alpha: 1.0)
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        
        label.font = UIFont.systemFont(ofSize: 16)
        
        label.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(label)
        
        let g = contentView
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
        contentView.backgroundColor = unselectedColor
        
    }
    
    override var isSelected: Bool {
        didSet {
            contentView.backgroundColor = isSelected ? selectedColor : unselectedColor
        }
    }
    
}

自定义布局类

class CollapsibleGridLayout: UICollectionViewLayout {
    
    public var itemSize: CGSize = .init(width: 50.0, height: 50.0)
    public var itemSpacing: CGFloat = 8.0
    public var rowSpacing: CGFloat = 8.0
    
    public var isCollapsed: Bool = false
    
    private var previousAttributes: [UICollectionViewLayoutAttributes] = []
    private var currentAttributes: [UICollectionViewLayoutAttributes] = []
    
    private var contentSize: CGSize = .zero
    
    override func prepare() {
        super.prepare()
        
        previousAttributes = currentAttributes
        
        contentSize = .zero
        currentAttributes = []
        
        if let collectionView = collectionView {
            
            let pths: [IndexPath] = collectionView.indexPathsForSelectedItems ?? []
            
            var cellFrame: CGRect = .init(x: 0.0, y: 0.0, width: itemSize.width, height: itemSize.height)
            
            let itemCount = collectionView.numberOfItems(inSection: 0)
            
            for i in 0..<itemCount {
                let indexPath: IndexPath = .init(item: i, section: 0)
                let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
                
                attributes.frame = cellFrame
                
                currentAttributes.append(attributes)
                
                let ipNext: IndexPath = .init(item: i+1, section: indexPath.section)
                
                // if we want the consecutive selected cells to "collapse"
                //  if current cell is selected AND the next cell is selected
                if isCollapsed, pths.contains(indexPath), pths.contains(ipNext) {
                    // don't change frame
                } else {
                    cellFrame.origin.x += itemSize.width + itemSpacing
                    if cellFrame.origin.x > collectionView.bounds.width {
                        cellFrame.origin.x = 0.0
                        cellFrame.origin.y += itemSize.height + rowSpacing
                    }
                }
            }
            
            // if cellFrame.origin.x is currently 0, that means we've wrapped to a new row
            //  but we have no cells on that row
            let csHeight: CGFloat = cellFrame.origin.x == 0 ? cellFrame.minY - rowSpacing : cellFrame.maxY
            contentSize = .init(width: collectionView.bounds.width, height: csHeight)
        }
        
    }
    
    // MARK: - Layout Attributes
    
    override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return previousAttributes[itemIndexPath.item]
    }
    
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return currentAttributes[indexPath.item]
    }
    
    override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return layoutAttributesForItem(at: itemIndexPath)
    }
    
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return currentAttributes.filter { rect.intersects($0.frame) }
    }
    
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        if let cv = collectionView, cv.bounds != newBounds {
            return true
        }
        return false
    }
    
    override var collectionViewContentSize: CGSize { return contentSize }
}

数据对象结构 - 假设我们有复杂的单元格...

struct MyObject {
    var myID: Int = 0
}

示例控制器类

class MyViewController: UIViewController {
    
    var myData: [MyObject] = []
    
    var collectionView: UICollectionView!
    var layout: CollapsibleGridLayout = CollapsibleGridLayout()
    
    var segCtrl: UISegmentedControl!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        for i in 0..<18 {
            myData.append(MyObject(myID: i+1))
        }
        
        // layout properties
        layout.itemSize = .init(width: 50.0, height: 50.0)
        layout.itemSpacing = 8.0
        layout.rowSpacing = 8.0
        
        // create collection view
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        
        // controls
        segCtrl = UISegmentedControl(items: ["Basic Anim", "Pre-Stack Anim"])
        
        // button to toggle stacked/un-stacked
        let btn = UIButton()
        btn.setTitle("Stack / Un-Stack", for: [])
        btn.setTitleColor(.white, for: .normal)
        btn.setTitleColor(.lightGray, for: .highlighted)
        btn.backgroundColor = .systemRed
        btn.layer.cornerRadius = 8.0
        
        [segCtrl, btn, collectionView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        
        let g = view.safeAreaLayoutGuide
        
        // let's make things easy on ourselves by setting the collection view width
        //  to the width of 5 cells + 4 spaces/gaps
        let cvWidth: CGFloat = layout.itemSize.width * 5.0 + layout.itemSpacing * 4.0
        
        NSLayoutConstraint.activate([
            
            segCtrl.topAnchor.constraint(equalTo: g.topAnchor, constant: 12.0),
            segCtrl.widthAnchor.constraint(equalToConstant: cvWidth),
            segCtrl.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
            btn.topAnchor.constraint(equalTo: segCtrl.bottomAnchor, constant: 12.0),
            btn.widthAnchor.constraint(equalToConstant: cvWidth),
            btn.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
            collectionView.topAnchor.constraint(equalTo: btn.bottomAnchor, constant: 12.0),
            collectionView.widthAnchor.constraint(equalToConstant: cvWidth),
            collectionView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            collectionView.heightAnchor.constraint(equalToConstant: 320.0),
            
        ])
        
        // collection view properties
        collectionView.dataSource = self
        collectionView.delegate = self
        
        collectionView.register(SimpleCell.self, forCellWithReuseIdentifier: SimpleCell.reuseIdentifier)
        
        collectionView.allowsMultipleSelection = true
        
        // so we can see the framing
        collectionView.backgroundColor = .init(red: 0.10, green: 0.45, blue: 0.95, alpha: 1.0)
        
        // button action
        btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
        
        segCtrl.selectedSegmentIndex = 0
    }
    
    @objc func btnTap(_ sender: Any?) {
        
        // make sure we have some selected cells
        guard let _ = collectionView.indexPathsForSelectedItems else { return }
        
        layout.isCollapsed.toggle()
        
        if layout.isCollapsed {
            if segCtrl.selectedSegmentIndex == 0 {
                basicAnim()
            } else {
                preStackAnim()
            }
        } else {
            basicAnim()
        }
        
    }
    
    func basicAnim(postStack: Bool = false) {
        
        // we want a little quicker animation
        //  if we're "finishing" after pre-stacking
        let dur: Double = postStack ? 0.25 : 0.5
        
        UIView.animate(withDuration: dur, animations: {
            self.layout.invalidateLayout()
            self.collectionView.layoutIfNeeded()
        }, completion: { b in
            // if we want to do something on completion
        })
        
    }
    
    func preStackAnim() {
        
        struct Parent {
            var parentCell: UICollectionViewCell = UICollectionViewCell()
            var children: [UICollectionViewCell] = []
        }
        
        var parents: [Parent] = []
        var curParent: Parent!
        var inGroup: Bool = false
        var maxChildren: Double = 0.0
        
        for i in 0..<myData.count {
            if let c = collectionView.cellForItem(at: .init(item: i, section: 0)) {
                c.layer.zPosition = CGFloat(i)
                let cNext = collectionView.cellForItem(at: .init(item: i+1, section: 0))
                if c.isSelected {
                    if !inGroup {
                        if let cNext = cNext, cNext.isSelected {
                            curParent = Parent(parentCell: c, children: [])
                            inGroup = true
                        }
                    } else {
                        curParent.children.append(c)
                        // if we're at the last data item, close this parent
                        if i == myData.count - 1 {
                            parents.append(curParent)
                            maxChildren = max(maxChildren, Double(curParent.children.count))
                        }
                    }
                } else {
                    if inGroup {
                        parents.append(curParent)
                        maxChildren = max(maxChildren, Double(curParent.children.count))
                    }
                    inGroup = false
                }
            }
            
        }
        
        // parents can be empty if we have no consecutive selected cells
        //  if this is the case, reset the layout isCollapsed to false and return
        if parents.isEmpty {
            layout.isCollapsed = false
            return
        }
        
        var relStart: Double = 0.0
        var relDur: Double = 0.0
        var totalDur: Double = 1.0
        var startInc: Double = 0.0
        
        totalDur = min(0.75, 0.3 * maxChildren)
        
        UIView.animateKeyframes(withDuration: totalDur,
                                delay: 0.0,
                                options: .calculationModeLinear, animations: {
            
            parents.forEach { p in
                relStart = 0.0
                startInc = min(0.1, totalDur / Double(p.children.count))
                relDur = 0.75
                let f: CGRect = p.parentCell.frame
                p.children.forEach { c in
                    UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: relDur) {
                        c.frame = f
                    }
                    relStart += startInc
                }
            }
            
        }, completion: { _ in
            // animate the rest of the cells
            self.basicAnim(postStack: true)
        })
        
    }
    
}

extension MyViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return myData.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SimpleCell.reuseIdentifier, for: indexPath) as! SimpleCell
        
        cell.label.text = "\(myData[indexPath.item].myID)"
        
        // we need to keep the collapsed/stacked cells layered in order
        //  so the "last" cell will be on top
        // i.e. if we stack 4,5,6,7
        //  we don't want 5 to be on top of 7
        cell.layer.zPosition = CGFloat(indexPath.item)
        
        return cell
    }
}

// MARK: - UICollectionViewDelegate

extension MyViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        // we're currently not doing anything on cell selection
    }
}

跑步时的样子:


备注:

  • 这只是示例代码!!!
    • 几乎没有实施错误检查
    • 我对“边缘条件”做了很少的测试
  • 如果某些选定的单元格已滚动到视图之外,“预堆栈”动画将失败(可能会非常严重)
  • “预堆栈”动画逻辑很快就拼凑在一起,因此它“有点”有效。

同样,关于您的实际目标和实施有很多未知数 - 但这可能会给您一些想法。

© www.soinside.com 2019 - 2024. All rights reserved.