水平 UICollectionView UIKIT 需要 Header
Need Header on Top for Horizontal UICollectionView UIKIT
我正在为 UICollectionView
实现自定义 header
我需要一个 header 在顶部用于水平 CollectionView。
简而言之,创建自定义 UICollectionViewLayout
是获得所需结果的一种方式。这是一个相当复杂的话题,所以解释很长。
在添加一些代码之前,您需要了解 UICollectionViewLayout
中的不同组件,例如单元格、补充视图和修饰视图。检查 this out to know more
在您的示例中,上面的 header 被称为 Supplementary views
,类型为 UICollectionReusableView
,并且这些单元格是类型为 UICollectionViewCells
的单元格。
将布局分配给 collection 视图时,collection 视图对其布局进行一系列调用以了解如何布置视图。
这是我发现的一个例子:
来源:https://www.raywenderlich.com/4829472-uicollectionview-custom-layout-tutorial-pinterest
在您的自定义布局中,您必须override
这些来创建您想要的布局:
- 准备()
- collectionViewContentSize
- layoutAttributesForElements(in:)
- layoutAttributesForItem(在:)
- layoutAttributesForSupplementaryView(ofKind:, at:)
- invalidateLayout()
- shouldInvalidateLayout(forBoundsChange)
设置布局有点复杂,主要是因为涉及数学,但归根结底,它只是为不同的单元格和补充视图指定框架(x、y、宽度、高度)在不同的视口。
自定义布局示例
首先,我创建了一个非常基本的可重用视图,用作每个部分单元格上方的 header,其中只有一个标签
class HeaderView: UICollectionReusableView
{
let title = UILabel()
static let identifier = "CVHeader"
override init(frame: CGRect)
{
super.init(frame: frame)
layoutInterface()
}
required init(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)!
layoutInterface()
}
func layoutInterface()
{
backgroundColor = .clear
title.translatesAutoresizingMaskIntoConstraints = false
title.backgroundColor = .clear
title.textAlignment = .left
title.textColor = .black
addSubview(title)
addConstraints([
title.leadingAnchor.constraint(equalTo: leadingAnchor),
title.topAnchor.constraint(equalTo: topAnchor),
title.trailingAnchor.constraint(equalTo: trailingAnchor),
title.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}
接下来我以正常方式设置我的 collection 视图,唯一的区别是我提供了自定义布局 class
// The collection view
private var collectionView: UICollectionView!
// A random data source
let colors: [UIColor] = [.systemBlue, .orange, .purple]
private func configureCollectionView()
{
collectionView = UICollectionView(frame: CGRect.zero,
collectionViewLayout: createLayout())
collectionView.backgroundColor = .lightGray
collectionView.register(UICollectionViewCell.self,
forCellWithReuseIdentifier: "cell")
// This is for the section titles
collectionView.register(HeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: HeaderView.identifier)
collectionView.dataSource = self
collectionView.delegate = self
view.addSubview(collectionView)
// Auto layout config to pin collection view to the edges of the view
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.leadingAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor,
constant: 0).isActive = true
collectionView.topAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,
constant: 0).isActive = true
collectionView.trailingAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor,
constant: 0).isActive = true
collectionView.heightAnchor
.constraint(equalToConstant: 300).isActive = true
}
private func createLayout() -> HorizontalLayout
{
// Not flow layout, but our custom layout
let customLayout = HorizontalLayout()
customLayout.itemSpacing = 10
customLayout.sectionSpacing = 20
customLayout.itemSize = CGSize(width: 50, height: 50)
return customLayout
}
数据源和委托也没有太大区别,但我添加它是为了完整性
extension ViewController: UICollectionViewDataSource
{
func numberOfSections(in collectionView: UICollectionView) -> Int
{
// random number
return 3
}
func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int
{
// random number
return 8 + section
}
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
{
let cell = collectionView
.dequeueReusableCell(withReuseIdentifier:"cell",
for: indexPath)
cell.backgroundColor = colors[indexPath.section]
return cell
}
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize
{
return CGSize(width: 200, height: 50)
}
}
extension ViewController: UICollectionViewDelegate
{
func collectionView(_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath) -> UICollectionReusableView
{
let header
= collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: HeaderView.identifier,
for: indexPath) as! HeaderView
header.title.text = "Section \(indexPath.section)"
return header
}
}
最后,最复杂的部分,创建自定义布局 class。从查看和理解我们跟踪的实例变量开始,这将根据您要创建的布局而改变
class HorizontalLayout: UICollectionViewLayout
{
// Cache layout attributes for the cells
private var cellLayoutCache: [IndexPath: UICollectionViewLayoutAttributes] = [:]
// Cache layout attributes for the header
private var headerLayoutCache: [Int: UICollectionViewLayoutAttributes] = [:]
// Set a y offset so the items render a bit lower which
// leaves room for the title at the top
private var sectionTitleHeight = CGFloat(60)
// The content height of the layout is static since we're configuring horizontal
// layout. However, the content width needs to be calculated and returned later
private var contentWidth = CGFloat.zero
private var contentHeight: CGFloat
{
guard let collectionView = collectionView else { return 0 }
let insets = collectionView.contentInset
return collectionView.bounds.height - (insets.left + insets.right)
}
// Based on the height of the collection view, the interItem spacing and
// the item height, we can set
private var maxItemsInRow = 0
// Set the spacing between items & sections
var itemSpacing: CGFloat = .zero
var sectionSpacing: CGFloat = .zero
var itemSize: CGSize = .zero
override init()
{
super.init()
}
required init?(coder: NSCoder)
{
super.init(coder: coder)
}
然后我们需要一个函数来帮助我们根据单元格高度、间距和可用 space 根据 collection 视图高度计算出 1 列中可以容纳多少项目。如果当前列中没有更多 space,这将帮助我们将单元格移动到下一列:
private func updateMaxItemsInColumn()
{
guard let collectionView = collectionView else { return }
let contentHeight = collectionView.bounds.height
let totalInsets
= collectionView.contentInset.top + collectionView.contentInset.bottom
// The height we have left to render the cells in
let availableHeight = contentHeight - sectionTitleHeight - totalInsets
// Set the temp number of items in a column as we have not
// accounted for item spacing
var tempItemsInColumn = Int(availableHeight / itemSize.height)
// Figure out max items allowed in a row based on spacing settings
while tempItemsInColumn != 0
{
// There is 1 gap between 2 items, 2 gaps between 3 items etc
let totalSpacing = CGFloat(tempItemsInColumn - 1) * itemSpacing
let finalHeight
= (CGFloat(tempItemsInColumn) * itemSize.height) + totalSpacing
if availableHeight < finalHeight
{
tempItemsInColumn -= 1
continue
}
break
}
maxItemsInRow = tempItemsInColumn
}
接下来,我们需要一个函数来帮助我们找到一个部分的宽度,因为我们需要知道 header 视图应该有多长
private func widthOfSection(_ section: Int) -> CGFloat
{
guard let collectionView = collectionView else { return .zero }
let itemsInSection = collectionView.numberOfItems(inSection: section)
let columnsInSection = itemsInSection / maxItemsInRow
// There is 1 gap between 2 items, 2 gaps between 3 items etc
let totalSpacing = CGFloat(itemsInSection - 1) * itemSpacing
let totalWidth = (CGFloat(columnsInSection) * itemSize.width) + totalSpacing
return totalWidth
}
并且如上所述,我们需要覆盖一些函数和属性。让我们从 prepare()
开始
// This function gets called before the collection view starts the layout process
// load layout into the cache so it doesn't have to be recalculated each time
override func prepare()
{
guard let collectionView = collectionView else { return }
// Only calculate if the cache is empty
guard cellLayoutCache.isEmpty else { return }
updateMaxItemsInColumn()
let sections = 0 ... collectionView.numberOfSections - 1
// Track the x position of the items being drawn
var itemX = CGFloat.zero
// Loop through all the sections
for section in sections
{
var itemY = sectionTitleHeight
var row = 0
let headerFrame = CGRect(x: itemX,
y: 0,
width: widthOfSection(section),
height: sectionTitleHeight)
let attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
with: IndexPath(item: 0, section: section))
attributes.frame = headerFrame
headerLayoutCache[section] = attributes
let itemsInSection = collectionView.numberOfItems(inSection: section)
// Generate valid index paths for all items in the section
let indexPaths = [Int](0 ... itemsInSection - 1).map
{
IndexPath(item: [=16=], section: section)
}
// Loop through all index paths and cache all the layout attributes
// so it can be reused later
for indexPath in indexPaths
{
let itemFrame = CGRect(x: itemX,
y: itemY,
width: itemSize.width,
height: itemSize.height)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = itemFrame
cellLayoutCache[indexPath] = attributes
contentWidth = max(contentWidth, itemFrame.maxX)
// last item in the section, update the x position
// to start the next section in a new column and also
// update the content width to add the section spacing
if indexPath.item == indexPaths.count - 1
{
itemX += itemSize.width + sectionSpacing
contentWidth = max(contentWidth, itemFrame.maxX + sectionSpacing)
continue
}
if row < maxItemsInRow - 1
{
row += 1
itemY += itemSize.height + itemSpacing
}
else
{
row = 0
itemY = sectionTitleHeight
itemX += itemSize.width + itemSpacing
}
}
}
}
内容大小属性
// We need to set the content size. Since it is a horizontal
// collection view, the height will be fixed. The width should be
// the max X value of the last item in the collection view
override var collectionViewContentSize: CGSize
{
return CGSize(width: contentWidth, height: contentHeight)
}
三个布局属性函数
// This defines what gets shown in the rect (viewport) the user
// is currently viewing
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
// Get the attributes that fall in the current view port
let itemAttributes = cellLayoutCache.values.filter { rect.intersects([=18=].frame) }
let headerAttributes = headerLayoutCache.values.filter { rect.intersects([=18=].frame) }
return itemAttributes + headerAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
{
return cellLayoutCache[indexPath]
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String,
at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
{
return headerLayoutCache[indexPath.section]
}
最后是无效布局
// In invalidateLayout(), the layout of the elements will be changing
// inside the collection view. Here the attribute cache can be reset
override func invalidateLayout()
{
// Reset the attribute cache
cellLayoutCache = [:]
headerLayoutCache = [:]
super.invalidateLayout()
}
// Invalidating the layout means the layout needs to be recalculated from scratch
// which might need to happen when the orientation changes so we only want to
// do this when it is necessary since it is expensive
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool
{
guard let collectionView = collectionView else { return false }
return newBounds.height != collectionView.bounds.height
}
所有这些都准备就绪后,您应该会得到以下结果:
如果由于某种原因您无法将代码添加到正确的部分,请查看带有 complete source code here
的同一示例
虽然我无法涵盖所有内容,但您可以在阅读此答案后查看以下 3 个很棒的教程以更深入地了解:
我正在为 UICollectionView
实现自定义 header我需要一个 header 在顶部用于水平 CollectionView。
简而言之,创建自定义 UICollectionViewLayout
是获得所需结果的一种方式。这是一个相当复杂的话题,所以解释很长。
在添加一些代码之前,您需要了解 UICollectionViewLayout
中的不同组件,例如单元格、补充视图和修饰视图。检查 this out to know more
在您的示例中,上面的 header 被称为 Supplementary views
,类型为 UICollectionReusableView
,并且这些单元格是类型为 UICollectionViewCells
的单元格。
将布局分配给 collection 视图时,collection 视图对其布局进行一系列调用以了解如何布置视图。
这是我发现的一个例子:
来源:https://www.raywenderlich.com/4829472-uicollectionview-custom-layout-tutorial-pinterest
在您的自定义布局中,您必须override
这些来创建您想要的布局:
- 准备()
- collectionViewContentSize
- layoutAttributesForElements(in:)
- layoutAttributesForItem(在:)
- layoutAttributesForSupplementaryView(ofKind:, at:)
- invalidateLayout()
- shouldInvalidateLayout(forBoundsChange)
设置布局有点复杂,主要是因为涉及数学,但归根结底,它只是为不同的单元格和补充视图指定框架(x、y、宽度、高度)在不同的视口。
自定义布局示例
首先,我创建了一个非常基本的可重用视图,用作每个部分单元格上方的 header,其中只有一个标签
class HeaderView: UICollectionReusableView
{
let title = UILabel()
static let identifier = "CVHeader"
override init(frame: CGRect)
{
super.init(frame: frame)
layoutInterface()
}
required init(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)!
layoutInterface()
}
func layoutInterface()
{
backgroundColor = .clear
title.translatesAutoresizingMaskIntoConstraints = false
title.backgroundColor = .clear
title.textAlignment = .left
title.textColor = .black
addSubview(title)
addConstraints([
title.leadingAnchor.constraint(equalTo: leadingAnchor),
title.topAnchor.constraint(equalTo: topAnchor),
title.trailingAnchor.constraint(equalTo: trailingAnchor),
title.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}
接下来我以正常方式设置我的 collection 视图,唯一的区别是我提供了自定义布局 class
// The collection view
private var collectionView: UICollectionView!
// A random data source
let colors: [UIColor] = [.systemBlue, .orange, .purple]
private func configureCollectionView()
{
collectionView = UICollectionView(frame: CGRect.zero,
collectionViewLayout: createLayout())
collectionView.backgroundColor = .lightGray
collectionView.register(UICollectionViewCell.self,
forCellWithReuseIdentifier: "cell")
// This is for the section titles
collectionView.register(HeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: HeaderView.identifier)
collectionView.dataSource = self
collectionView.delegate = self
view.addSubview(collectionView)
// Auto layout config to pin collection view to the edges of the view
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.leadingAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor,
constant: 0).isActive = true
collectionView.topAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,
constant: 0).isActive = true
collectionView.trailingAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor,
constant: 0).isActive = true
collectionView.heightAnchor
.constraint(equalToConstant: 300).isActive = true
}
private func createLayout() -> HorizontalLayout
{
// Not flow layout, but our custom layout
let customLayout = HorizontalLayout()
customLayout.itemSpacing = 10
customLayout.sectionSpacing = 20
customLayout.itemSize = CGSize(width: 50, height: 50)
return customLayout
}
数据源和委托也没有太大区别,但我添加它是为了完整性
extension ViewController: UICollectionViewDataSource
{
func numberOfSections(in collectionView: UICollectionView) -> Int
{
// random number
return 3
}
func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int
{
// random number
return 8 + section
}
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
{
let cell = collectionView
.dequeueReusableCell(withReuseIdentifier:"cell",
for: indexPath)
cell.backgroundColor = colors[indexPath.section]
return cell
}
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize
{
return CGSize(width: 200, height: 50)
}
}
extension ViewController: UICollectionViewDelegate
{
func collectionView(_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath) -> UICollectionReusableView
{
let header
= collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: HeaderView.identifier,
for: indexPath) as! HeaderView
header.title.text = "Section \(indexPath.section)"
return header
}
}
最后,最复杂的部分,创建自定义布局 class。从查看和理解我们跟踪的实例变量开始,这将根据您要创建的布局而改变
class HorizontalLayout: UICollectionViewLayout
{
// Cache layout attributes for the cells
private var cellLayoutCache: [IndexPath: UICollectionViewLayoutAttributes] = [:]
// Cache layout attributes for the header
private var headerLayoutCache: [Int: UICollectionViewLayoutAttributes] = [:]
// Set a y offset so the items render a bit lower which
// leaves room for the title at the top
private var sectionTitleHeight = CGFloat(60)
// The content height of the layout is static since we're configuring horizontal
// layout. However, the content width needs to be calculated and returned later
private var contentWidth = CGFloat.zero
private var contentHeight: CGFloat
{
guard let collectionView = collectionView else { return 0 }
let insets = collectionView.contentInset
return collectionView.bounds.height - (insets.left + insets.right)
}
// Based on the height of the collection view, the interItem spacing and
// the item height, we can set
private var maxItemsInRow = 0
// Set the spacing between items & sections
var itemSpacing: CGFloat = .zero
var sectionSpacing: CGFloat = .zero
var itemSize: CGSize = .zero
override init()
{
super.init()
}
required init?(coder: NSCoder)
{
super.init(coder: coder)
}
然后我们需要一个函数来帮助我们根据单元格高度、间距和可用 space 根据 collection 视图高度计算出 1 列中可以容纳多少项目。如果当前列中没有更多 space,这将帮助我们将单元格移动到下一列:
private func updateMaxItemsInColumn()
{
guard let collectionView = collectionView else { return }
let contentHeight = collectionView.bounds.height
let totalInsets
= collectionView.contentInset.top + collectionView.contentInset.bottom
// The height we have left to render the cells in
let availableHeight = contentHeight - sectionTitleHeight - totalInsets
// Set the temp number of items in a column as we have not
// accounted for item spacing
var tempItemsInColumn = Int(availableHeight / itemSize.height)
// Figure out max items allowed in a row based on spacing settings
while tempItemsInColumn != 0
{
// There is 1 gap between 2 items, 2 gaps between 3 items etc
let totalSpacing = CGFloat(tempItemsInColumn - 1) * itemSpacing
let finalHeight
= (CGFloat(tempItemsInColumn) * itemSize.height) + totalSpacing
if availableHeight < finalHeight
{
tempItemsInColumn -= 1
continue
}
break
}
maxItemsInRow = tempItemsInColumn
}
接下来,我们需要一个函数来帮助我们找到一个部分的宽度,因为我们需要知道 header 视图应该有多长
private func widthOfSection(_ section: Int) -> CGFloat
{
guard let collectionView = collectionView else { return .zero }
let itemsInSection = collectionView.numberOfItems(inSection: section)
let columnsInSection = itemsInSection / maxItemsInRow
// There is 1 gap between 2 items, 2 gaps between 3 items etc
let totalSpacing = CGFloat(itemsInSection - 1) * itemSpacing
let totalWidth = (CGFloat(columnsInSection) * itemSize.width) + totalSpacing
return totalWidth
}
并且如上所述,我们需要覆盖一些函数和属性。让我们从 prepare()
// This function gets called before the collection view starts the layout process
// load layout into the cache so it doesn't have to be recalculated each time
override func prepare()
{
guard let collectionView = collectionView else { return }
// Only calculate if the cache is empty
guard cellLayoutCache.isEmpty else { return }
updateMaxItemsInColumn()
let sections = 0 ... collectionView.numberOfSections - 1
// Track the x position of the items being drawn
var itemX = CGFloat.zero
// Loop through all the sections
for section in sections
{
var itemY = sectionTitleHeight
var row = 0
let headerFrame = CGRect(x: itemX,
y: 0,
width: widthOfSection(section),
height: sectionTitleHeight)
let attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
with: IndexPath(item: 0, section: section))
attributes.frame = headerFrame
headerLayoutCache[section] = attributes
let itemsInSection = collectionView.numberOfItems(inSection: section)
// Generate valid index paths for all items in the section
let indexPaths = [Int](0 ... itemsInSection - 1).map
{
IndexPath(item: [=16=], section: section)
}
// Loop through all index paths and cache all the layout attributes
// so it can be reused later
for indexPath in indexPaths
{
let itemFrame = CGRect(x: itemX,
y: itemY,
width: itemSize.width,
height: itemSize.height)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = itemFrame
cellLayoutCache[indexPath] = attributes
contentWidth = max(contentWidth, itemFrame.maxX)
// last item in the section, update the x position
// to start the next section in a new column and also
// update the content width to add the section spacing
if indexPath.item == indexPaths.count - 1
{
itemX += itemSize.width + sectionSpacing
contentWidth = max(contentWidth, itemFrame.maxX + sectionSpacing)
continue
}
if row < maxItemsInRow - 1
{
row += 1
itemY += itemSize.height + itemSpacing
}
else
{
row = 0
itemY = sectionTitleHeight
itemX += itemSize.width + itemSpacing
}
}
}
}
内容大小属性
// We need to set the content size. Since it is a horizontal
// collection view, the height will be fixed. The width should be
// the max X value of the last item in the collection view
override var collectionViewContentSize: CGSize
{
return CGSize(width: contentWidth, height: contentHeight)
}
三个布局属性函数
// This defines what gets shown in the rect (viewport) the user
// is currently viewing
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
// Get the attributes that fall in the current view port
let itemAttributes = cellLayoutCache.values.filter { rect.intersects([=18=].frame) }
let headerAttributes = headerLayoutCache.values.filter { rect.intersects([=18=].frame) }
return itemAttributes + headerAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
{
return cellLayoutCache[indexPath]
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String,
at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
{
return headerLayoutCache[indexPath.section]
}
最后是无效布局
// In invalidateLayout(), the layout of the elements will be changing
// inside the collection view. Here the attribute cache can be reset
override func invalidateLayout()
{
// Reset the attribute cache
cellLayoutCache = [:]
headerLayoutCache = [:]
super.invalidateLayout()
}
// Invalidating the layout means the layout needs to be recalculated from scratch
// which might need to happen when the orientation changes so we only want to
// do this when it is necessary since it is expensive
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool
{
guard let collectionView = collectionView else { return false }
return newBounds.height != collectionView.bounds.height
}
所有这些都准备就绪后,您应该会得到以下结果:
如果由于某种原因您无法将代码添加到正确的部分,请查看带有 complete source code here
的同一示例虽然我无法涵盖所有内容,但您可以在阅读此答案后查看以下 3 个很棒的教程以更深入地了解: