我正在尝试实现一个 UICollectionView 布局,其工作方式类似于流布局,但可以有条件地将网格的多个项目堆叠到单个单元格中。
现在我正在使用一个辅助结构,
AssetStack
来实现这一点。每个网格项目都会显示这些堆栈之一,即使它只是堆栈中的单个项目。问题在于,随着节数的变化,这使得数据源更新变得棘手,并且它将大量布局逻辑混合到数据结构中。这也意味着我无法轻松地为过渡设置动画。
似乎自定义流程布局或组合布局可以做到这一点,但我不确定如何做到这一点,并且我想我应该问专家在这里。
谢谢大家
这其中有很多未知的部分,包括......
13, 14, 16, 17
全部被选中,16
和17
是否应该从2
部分移动到1
部分?等等。
但是,让我们从基础开始,看看是否可以从那里开始。
通过自定义
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
}
}
跑步时的样子:
备注:
同样,关于您的实际目标和实施有很多未知数 - 但这可能会给您一些想法。