UICollectionView复杂网格再次

huangapple go评论113阅读模式
英文:

UICollectionView complex grid again

问题

我无法管理到达此类型的布局:

UICollectionView复杂网格再次

只有在我在'sizeForItemAt'方法中设置单元格大小时,才能实现这一点:

UICollectionView复杂网格再次

我尝试了Apple提供的解决方案,如UICollectionViewCompositionalLayout和子类化UICollectionViewLayout。但第一个不提供设备旋转所需的灵活性,因为您必须在组中设置子项的确切计数。 UICollectionViewCompositionalLayout的另一个问题是滚动时间的计算 - 它在屏幕显示后不提供完整的布局。 UICollectionViewLayout的子类化(https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts/customizing_collection_view_layouts)性能非常差。

但即使以上方法存在所有缺陷,我仍然没有得到我需要的确切布局。我可以想象我们可以使用包含四个单元格网格的附加类型的单元格,但这也不够灵活。

我将感激任何帮助。

英文:

I can't manage to get this type of layout:

UICollectionView复杂网格再次

I can only achieve this when I set size of cells in 'sizeForItemAt' method:

UICollectionView复杂网格再次

I tried solutions from Apple like UICollectionViewCompositionalLayout and subclassing of UICollectionViewLayout. But the first one don't give the flexibility needed for the device rotation because you have to set exact count of subitems in group. Another issue with UICollectionViewCompositionalLayout is scroll time calculations - it doesn't give the full layout after the screen is displayed. Subclassing of UICollectionViewLayout (https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts/customizing_collection_view_layouts)
has terrible performance.

But even with all the shortcomings of the above approaches, I did not get exactly the layout that I need. I can imagine that we can use an additional type of cell that contains a grid of four cells, but it's also not flexible.

I will appreciate any help.

答案1

得分: 1

这个布局可以通过自定义UICollectionViewLayout来实现,可能比看上去更简单。

首先,将布局视为每个部分的网格... 4列 x n 行:

因为我们使用的是正方形,第一个项目将占据2列和2行。

为了避免宽度/高度混淆和复制,我们将称2x2项目为“主要”项目,1x1项目为“次要”项目。

因此,当我们计算布局矩形时,我们可以这样说:

numCols = 4
secondarySize = collectionView.width / numCols

y = 0
row = 0
col = 0

for i in 0..<numItems {

    if i == 0 {

        itemRect = .init(x: 0.0, y: y, width: secondarySize * 2.0, height: secondarySize * 2.0)

        // skip a column
        col = 2

    } else {

        // if we're at the last column
        if col == numCols {
            // increment the row
            row += 1
            // if we're on row 1, next column is 2
            // else it's 0
            col = row < 2 ? 2 : 0
        }
                
        itemRect = .init(x: col * secondarySize, y: y + row * secondarySize, width: secondarySize, height: secondarySize)
                
        // increment the column
        col += 1
                
    }

}

这可以在 iPhone 14 Pro Max 上正常工作,如下所示:

不过,事实并不如此简单,因为当我们旋转手机时,我们不希望出现以下情况:

而且如果是在 iPad 上,我们绝对不希望出现以下情况:

因此,我们需要决定布局可以有多宽。

当前的手机宽度范围从275到430点(纵向方向),所以我们可以说:

  • 如果集合视图的宽度小于450,使用默认布局
  • 否则,我们将使用一个特定的主要项目尺寸,以及“填充”剩余的空间

如果我们决定主要项目的尺寸为200x200,那么我们的布局代码的初始部分将更改为:

primaryItemSize = 200.0

if contentWidth < 450.0 {
    secondarySize = contentWidth / 4.0
    numCols = 4
} else {
    secondarySize = primaryItemSize / 2.0
    numCols = Int(contentWidth / secondarySize)
}

现在,如果我们的布局看起来像这样(再次以 iPhone 14 Pro Max 为例):

旋转手机会得到这样的结果:

iPad 看起来像这样:

我们可能仍然需要一些条件计算... 在 iPhone SE 上,相同的代码如下:

因此,200x200的主要大小对于该设备可能太大了。

此外,正如您所看到的,设置显式主要项目大小并不能完全填满宽度。iPhone SE 在横向方向上的视图宽度为667。如果次要大小(列宽)为100,那么6列将占用600点,剩下667点的空间。

如果这可以接受,那太好了,减少了工作 UICollectionView复杂网格再次 否则,我们可以进行“最佳拟合”计算,这将稍微“增大”尺寸以填满它,或者稍微“缩小”尺寸并扩展到7列。

如果您想要分区间距和/或标题,那也需要考虑进去。

以下是一些示例代码,实现了到此步骤的效果:

class SampleViewController: UIViewController {
    // 代码已省略,具体布局和视图控制器的部分。

    // “标准”集合视图数据源函数
    extension SampleViewController: UICollectionViewDataSource {
        // 代码已省略,数据源函数的部分。
    }

    // “标准”集合视图委托函数
    extension SampleViewController: UICollectionViewDelegate {
        // 代码已省略,委托函数的部分。
    }

    // 图像数据生成
    class SampleData: NSObject {
        // 代码已省略,图像数据生成的部分。
    }

    // 具有圆角图像视图和4点“填充”的基本集合视图单元格
    class SimpleImageCell: UICollectionViewCell {
        // 代码已省略,单元格的部分。
    }

    class SampleGridLayout: UICollectionViewLayout {
        // 代码已省略,布局的部分。
    }
}

这些是您提供的代码的主要部分,用于实现特定的集合视图布局。如果您需要进一步的帮助或解释,请随时提出更多问题。

英文:

This layout can be done with a custom UICollectionViewLayout and is probably much more straight-forward than it might seem.

First, think about the layout as a grid for each section... 4-columns x n rows:

UICollectionView复杂网格再次

Because we're using squares, the first item will take up 2-columns and 2-rows.

To avoid width/height confusion and replication, we'll call the 2x2 item the "Primary" item, and the 1x1 items "Secondary" items.

So, when we calculate the layout rectangles, we can say:

numCols = 4
secondarySize = collectionView.width / numCols

y = 0
row = 0
col = 0

for i in 0..&lt;numItems {

    if i == 0 {

        itemRect = .init(x: 0.0, y: y, width: secondarySize * 2.0, height: secondarySize * 2.0)

        // skip a column
        col = 2

    } else {

		// if we&#39;re at the last column
		if col == numCols {
			// increment the row
			row += 1
			// if we&#39;re on row 1, next column is 2
			//	else it&#39;s 0
			col = row &lt; 2 ? 2 : 0
		}
				
		itemRect = .init(x: col * secondarySize, y: y + row * secondarySize, width: secondarySize, height: secondarySize)
				
		// increment the column
		col += 1
				
	}

}

That works fine, giving us this on an iPhone 14 Pro Max:

UICollectionView复杂网格再次

It's not quite that simple though, because when we rotate the phone, we don't want this:

UICollectionView复杂网格再次

and if we're on an iPad, we definitely don't want this:

UICollectionView复杂网格再次

So, we need to decide how wide we can go for that layout.

Current phones range from 275 to 430 points wide (in Portrait orientation), so we might say:

  • if the collectionView width is less than 450, use this default layout
  • else
  • let's use a specific size for the Primary item, and "fill in" the remaining space

If we decide we want the Primary item to be 200x200, that changes the initial part of our layout code to:

primaryItemSize = 200.0

if contentWidth &lt; 450.0 {
	secondarySize = contentWidth / 4.0
	numCols = 4
} else {
	secondarySize = primaryItemSize / 2.0
	numCols = Int(contentWidth / secondarySize)
}

Now if our layout looks like this (again, iPhone 14 Pro Max):

UICollectionView复杂网格再次

rotating the phone gives us this:

UICollectionView复杂网格再次

and the iPad looks like this:

UICollectionView复杂网格再次

We may still want some conditional calculations... that same code on an iPhone SE looks like this:

UICollectionView复杂网格再次

So, a Primary size of 200x200 might be too big for that device.

Additionally, as you can see, setting an explicit Primary item size won't fill the width exactly. An iPhone SE in Landscape orientation has a view width of 667. If the secondary size (the column width) is 100, 6 columns gets us 600-points, leaving 667-points of empty space on the end.

If that's acceptable, great, less work UICollectionView复杂网格再次 Otherwise, we can do a "best fit" calculation which would either "grow" the size a bit to fill it out, or "shrink" the size a bit and expand to 7 columns.

And... if you want section spacing and/or headers, that would need to be factored in as well.

Here, though, is some sample code to get to this point:

class SampleViewController: UIViewController {
var collectionView: UICollectionView!
var myData: [[UIImage]] = []
// a view with a &quot;spinner&quot; to show that we are
//	generating the images to use as the data
//	(if the data needs to be created in this controller)
lazy var spinnerView: UIView = {
let v = UIView()
let label = UILabel()
label.text = &quot;Generating Images Data...&quot;
let spinner = UIActivityIndicatorView(style: .large)
spinner.startAnimating()
[label, spinner].forEach { sv in
sv.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(sv)
}
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: v.topAnchor, constant: 20.0),
label.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 20.0),
label.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -20.0),
spinner.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 20.0),
spinner.centerXAnchor.constraint(equalTo: v.centerXAnchor),
spinner.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -20.0),
])
v.layer.cornerRadius = 8
v.layer.borderWidth = 1
v.layer.borderColor = UIColor.black.cgColor
v.backgroundColor = .white
return v
}()
// for development purposes
var showCellFrame: Bool = false
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
let gl = SampleGridLayout()
gl.primaryItemSize = 200.0
collectionView = UICollectionView(frame: .zero, collectionViewLayout: gl)
// the imageView in our SimpleImageCell is inset by 4-points, which results in
//	8-points between adjacent cells
// so, if we inset the content 4-points on each side, it will look &quot;balanced&quot;
//	with a total of 8-points on each side
collectionView.contentInset = .init(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
])
collectionView.register(SimpleImageCell.self, forCellWithReuseIdentifier: SimpleImageCell.identifier)
collectionView.dataSource = self
collectionView.delegate = self
// for use during development
let dt = UITapGestureRecognizer(target: self, action: #selector(toggleFraming(_:)))
dt.numberOfTapsRequired = 2
view.addGestureRecognizer(dt)
if myData.isEmpty {
spinnerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(spinnerView)
NSLayoutConstraint.activate([
spinnerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
spinnerView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
])
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// data may already be created by a data manager class
//	so only create images if needed
if myData.isEmpty {
DispatchQueue.global(qos: .userInitiated).async {
let sectionCounts: [Int] = [
8, 2, 3, 4, 5, 10, 13, 16, 24
]
self.myData = SampleData().generateData(sectionCounts)
DispatchQueue.main.async {
self.spinnerView.removeFromSuperview()
self.collectionView.reloadData()
}
}
}
}
// for use during development
@objc func toggleFraming(_ sender: Any?) {
self.showCellFrame.toggle()
self.collectionView.reloadData()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(
alongsideTransition: { [unowned self] _ in
self.collectionView.collectionViewLayout.invalidateLayout()
self.collectionView.reloadData()
},
completion: { [unowned self] _ in
// if we want to do something after the size transition
}
)
}
}
// &quot;standard&quot; collection view DataSource funcs
extension SampleViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -&gt; Int {
return myData.count
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -&gt; Int {
return myData[section].count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -&gt; UICollectionViewCell {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: SimpleImageCell.identifier, for: indexPath) as! SimpleImageCell
c.theImageView.image = myData[indexPath.section][indexPath.item]
// any other cell data configuration
// this is here only during development
c.showCellFrame = self.showCellFrame
return c
}
}
// &quot;standard&quot; collection view Delegate funcs
extension SampleViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
print(&quot;Selected item at:&quot;, indexPath)
}
}
// MARK: image data generation
class SampleData: NSObject {
func generateData(_ sectionCounts: [Int]) -&gt; [[UIImage]] {
// let&#39;s generate some sample data...
// we&#39;ll create numbered 200x200 UIImages,
//	cycling through some background colors
//	to make it easy to see the sections
let sectionColors: [UIColor] = [
.systemRed, .systemGreen, .systemBlue,
.cyan, .green, .yellow,
]
var returnArray: [[UIImage]] = []
for i in 0..&lt;sectionCounts.count {
var sectionImages: [UIImage] = []
let c = sectionColors[i % sectionColors.count]
for n in 0..&lt;sectionCounts[i] {
if let img = createLabel(text: &quot;\(n)&quot;, bkgColor: c) {
sectionImages.append(img)
}
}
returnArray.append(sectionImages)
}
return returnArray
}
func createLabel(text: String, bkgColor: UIColor) -&gt; UIImage? {
let label = CATextLayer()
let uiFont = UIFont.boldSystemFont(ofSize: 140)
label.font = CGFont(uiFont.fontName as CFString)
label.fontSize = 140
label.alignmentMode = .center
label.foregroundColor = UIColor.white.cgColor
label.string = text
label.shadowColor = UIColor.black.cgColor
label.shadowOffset = .init(width: 0.0, height: 3.0)
label.shadowRadius = 6
label.shadowOpacity = 0.9
let sz = label.preferredFrameSize()
label.frame = .init(x: 0.0, y: 0.0, width: 200.0, height: sz.height)
let r: CGRect = .init(x: 0.0, y: 0.0, width: 200.0, height: 200.0)
let renderer = UIGraphicsImageRenderer(size: r.size)
return renderer.image { context in
bkgColor.setFill()
context.fill(r)
context.cgContext.translateBy(x: 0.0, y: (200.0 - sz.height) / 2.0)
label.render(in: context.cgContext)
}
}
}
// basic collection view cell with a
//	rounded-corners image view, 4-points &quot;padding&quot; on all sides
class SimpleImageCell: UICollectionViewCell {
static let identifier: String = &quot;simpleImageCell&quot;
let theImageView: UIImageView = {
let v = UIImageView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -&gt; Void {
contentView.addSubview(theImageView)
let g = contentView
NSLayoutConstraint.activate([
theImageView.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
theImageView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0),
theImageView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0),
theImageView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
])
theImageView.layer.cornerRadius = 12
theImageView.clipsToBounds = true
}
override var isSelected: Bool {
didSet {
theImageView.layer.borderWidth = isSelected ? 2.0 : 0.0
}
}
// for development, so we can see the framing
var showCellFrame: Bool = false {
didSet {
//contentView.backgroundColor = showCellFrame ? .systemYellow : .clear
contentView.layer.borderColor = showCellFrame ? UIColor.blue.cgColor : UIColor.clear.cgColor
contentView.layer.borderWidth = showCellFrame ? 1 : 0
}
}
}
class SampleGridLayout: UICollectionViewLayout {
public var primaryItemSize: CGFloat = 200.0
private var itemCache: [UICollectionViewLayoutAttributes] = []
private var nextY: CGFloat = 0.0
private var contentHeight: CGFloat = 0
private var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
override func prepare() {
guard let collectionView = collectionView else { return }
var numCols: Int = 0
var secondarySize: CGFloat = 0
if contentWidth &lt; 450.0 {
secondarySize = contentWidth / 4.0
numCols = 4
} else {
secondarySize = primaryItemSize / 2.0
numCols = Int(contentWidth / secondarySize)
}
var primaryFrame: CGRect = .zero
var secondaryFrame: CGRect = .zero
itemCache = []
nextY = 0.0
for section in 0..&lt;collectionView.numberOfSections {
let y: CGFloat = nextY
var curCol: Int = 0
var curRow: Int = 0
for item in 0..&lt;collectionView.numberOfItems(inSection: section) {
let indexPath = IndexPath(item: item, section: section)
if item == 0 {
primaryFrame = .init(x: 0.0, y: y, width: secondarySize * 2.0, height: secondarySize * 2.0)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = primaryFrame
itemCache.append(attributes)
// item 0 takes up 2 columns
curCol = 2
} else {
// if we&#39;re at the last column
if curCol == numCols {
// increment the row
curRow += 1
// if we&#39;re on row 1, next column is 2
//	else it&#39;s 0
curCol = curRow &lt; 2 ? 2 : 0
}
secondaryFrame = .init(x: CGFloat(curCol) * secondarySize, y: y + CGFloat(curRow) * secondarySize, width: secondarySize, height: secondarySize)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = secondaryFrame
itemCache.append(attributes)
// increment the column
curCol += 1
}
}
nextY = max(primaryFrame.maxY, secondaryFrame.maxY)
}
contentHeight = nextY
}
override func layoutAttributesForElements(in rect: CGRect) -&gt; [UICollectionViewLayoutAttributes]? {
super.layoutAttributesForElements(in: rect)
var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []
for attributes in itemCache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -&gt; UICollectionViewLayoutAttributes? {
super.layoutAttributesForItem(at: indexPath)
return itemCache.count &gt; indexPath.row ? itemCache[indexPath.row] : nil
}
}

huangapple
  • 本文由 发表于 2023年3月15日 20:18:25
  • 转载请务必保留本文链接:https://go.coder-hub.com/75744595.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定