Swift - 实现UICollectionView分组头悬停效果(方法1:使用自定义布局)
作者:hangge | 2017-05-03 08:10
我们知道表格 UITableView 可以设置多个 section(分区、分组),而且如果 tableView 是使用 plain 样式的话,分组头还会有有 sticky 效果(粘性效果、悬停效果)。
而在 iOS9 之前,UICollectionView 虽然也可以设置多个 section,但其 section header 并没有悬停效果,而是跟随单元格一同上下移动。

下面演示如何通过自定义布局类,来实现 collectionView 的分组头悬停效果。
1,效果图
可以看到随着视图上下滚动,当前分组的分组头会一直停留在固定位置(对应分组可视区域的顶端)。

2,实现代码
(1)自定义单元格类:MyCollectionViewCell.swift(创建的时候生成对应的 xib 文件)
import UIKit
//自定义的Collection View单元格
class MyCollectionViewCell: UICollectionViewCell {
//用于显示书籍封面图片
@IBOutlet weak var imageView: UIImageView!
override func awakeFromNib() {
super.awakeFromNib()
}
}
(2)自定义分组头:MySectionHeader.swift(创建的时候生成对应的 xib 文件)
import UIKit
//自定义的Collection View分组头
class MySectionHeader: UICollectionReusableView {
//用于显示分组标题
@IBOutlet weak var titleLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
}
}
(3)自定义布局类:StickyHeadersFlowLayout.swift
import UIKit
//自定义的具有粘性分组头的Collection View布局类
class StickyHeadersFlowLayout: UICollectionViewFlowLayout {
//边界发生变化时是否重新布局(视图滚动的时候也会调用)
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
//所有元素的位置属性
override func layoutAttributesForElements(in rect: CGRect)
-> [UICollectionViewLayoutAttributes]? {
//从父类得到默认的所有元素属性
guard let layoutAttributes = super.layoutAttributesForElements(in: rect)
else { return nil }
//用于存储元素新的布局属性,最后会返回这个
var newLayoutAttributes = [UICollectionViewLayoutAttributes]()
//存储每个layout attributes对应的是哪个section
let sectionsToAdd = NSMutableIndexSet()
//循环老的元素布局属性
for layoutAttributesSet in layoutAttributes {
//如果元素师cell
if layoutAttributesSet.representedElementCategory == .cell {
//将布局添加到newLayoutAttributes中
newLayoutAttributes.append(layoutAttributesSet)
} else if layoutAttributesSet.representedElementCategory == .supplementaryView {
//将对应的section储存到sectionsToAdd中
sectionsToAdd.add(layoutAttributesSet.indexPath.section)
}
}
//遍历sectionsToAdd,补充视图使用正确的布局属性
for section in sectionsToAdd {
let indexPath = IndexPath(item: 0, section: section)
//添加头部布局属性
if let headerAttributes = self.layoutAttributesForSupplementaryView(ofKind:
UICollectionElementKindSectionHeader, at: indexPath) {
newLayoutAttributes.append(headerAttributes)
}
//添加尾部布局属性
if let footerAttributes = self.layoutAttributesForSupplementaryView(ofKind:
UICollectionElementKindSectionFooter, at: indexPath) {
newLayoutAttributes.append(footerAttributes)
}
}
return newLayoutAttributes
}
//补充视图的布局属性(这里处理实现粘性分组头,让分组头始终处于分组可视区域的顶部)
override func layoutAttributesForSupplementaryView(ofKind elementKind: String,
at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
//先从父类获取补充视图的布局属性
guard let layoutAttributes = super.layoutAttributesForSupplementaryView(ofKind:
elementKind, at: indexPath) else { return nil }
//如果不是头部视图则直接返回
if elementKind != UICollectionElementKindSectionHeader {
return layoutAttributes
}
//根据section索引,获取对应的边界范围
guard let boundaries = boundaries(forSection: indexPath.section)
else { return layoutAttributes }
guard let collectionView = collectionView else { return layoutAttributes }
//保存视图内入垂直方向的偏移量
let contentOffsetY = collectionView.contentOffset.y
//补充视图的frame
var frameForSupplementaryView = layoutAttributes.frame
//计算分组头垂直方向的最大最小值
let minimum = boundaries.minimum - frameForSupplementaryView.height
let maximum = boundaries.maximum - frameForSupplementaryView.height
//如果内容区域的垂直偏移量小于分组头最小的位置,则将分组头置于其最小位置
if contentOffsetY < minimum {
frameForSupplementaryView.origin.y = minimum
}
//如果内容区域的垂直偏移量大于分组头最小的位置,则将分组头置于其最大位置
else if contentOffsetY > maximum {
frameForSupplementaryView.origin.y = maximum
}
//如果都不满足,则说明内容区域的垂直便宜量落在分组头的边界范围内。
//将分组头设置为内容偏移量,从而让分组头固定在集合视图的顶部
else {
frameForSupplementaryView.origin.y = contentOffsetY
}
//更新布局属性并返回
layoutAttributes.frame = frameForSupplementaryView
return layoutAttributes
}
//根据section索引,获取对应的边界范围(返回一个元组)
func boundaries(forSection section: Int) -> (minimum: CGFloat, maximum: CGFloat)? {
//保存返回结果
var result = (minimum: CGFloat(0.0), maximum: CGFloat(0.0))
//如果collectionView属性为nil,则直接fanhui
guard let collectionView = collectionView else { return result }
//获取该分区中的项目数
let numberOfItems = collectionView.numberOfItems(inSection: section)
//如果项目数位0,则直接返回
guard numberOfItems > 0 else { return result }
//从流布局属性中获取第一个、以及最后一个项的布局属性
let first = IndexPath(item: 0, section: section)
let last = IndexPath(item: (numberOfItems - 1), section: section)
if let firstItem = layoutAttributesForItem(at: first),
let lastItem = layoutAttributesForItem(at: last) {
//分别获区边界的最小值和最大值
result.minimum = firstItem.frame.minY
result.maximum = lastItem.frame.maxY
//将分区都的高度考虑进去,并调整
result.minimum -= headerReferenceSize.height
result.maximum -= headerReferenceSize.height
//将分区的内边距考虑进去,并调整
result.minimum -= sectionInset.top
result.maximum += (sectionInset.top + sectionInset.bottom)
}
//返回最终的边界值
return result
}
}
(4)使用样例:ViewController.swift
import UIKit
//每月书籍
struct BookPreview {
var title:String
var images:[String]
}
class ViewController: UIViewController {
//重用的单元格和分区头的Identifier
let CellIdentifier = "myCell"
let HeaderIdentifier = "myHeader"
//所有书籍数据
let books = [
BookPreview(title: "五月新书", images: ["0.jpg", "1.jpg","2.jpg", "3.jpg",
"4.jpg","5.jpg","6.jpg"]),
BookPreview(title: "六月新书", images: ["7.jpg", "8.jpg", "9.jpg"]),
BookPreview(title: "七月新书", images: ["10.jpg", "11.jpg", "12.jpg", "13.jpg"])
]
override func viewDidLoad() {
super.viewDidLoad()
//去除存在导航栏时内内边距自动调整功能,防止对自定义的Collection View分区头停留功能造成影响
self.automaticallyAdjustsScrollViewInsets = false
//初始化Collection View
initCollectionView()
}
private func initCollectionView() {
//初始化自定义的flow布局
let layout = StickyHeadersFlowLayout()
//Collection View的位置尺寸
let frame = CGRect(x: 0, y: 64, width: view.bounds.width,
height: view.bounds.height - 64)
//初始化Collection View
let collectionView = UICollectionView(frame: frame, collectionViewLayout: layout)
//Collection View代理设置
collectionView.delegate = self
collectionView.dataSource = self
collectionView.backgroundColor = .white
//注册重用的单元格
let cellXIB = UINib.init(nibName: "MyCollectionViewCell", bundle: Bundle.main)
collectionView.register(cellXIB, forCellWithReuseIdentifier: CellIdentifier)
//注册重用的分组头
let headerXIB = UINib.init(nibName: "MySectionHeader", bundle: Bundle.main)
collectionView.register(headerXIB, forSupplementaryViewOfKind:
UICollectionElementKindSectionHeader, withReuseIdentifier: HeaderIdentifier)
//将Collection View添加到主视图中
view.addSubview(collectionView)
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
}
//Collection View数据源协议相关方法
extension ViewController: UICollectionViewDataSource {
//获取分区数
func numberOfSections(in collectionView: UICollectionView) -> Int {
return books.count
}
//获取每个分区里单元格数量
func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
return books[section].images.count
}
//返回每个单元格视图
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
//获取重用的单元格
let cell = collectionView.dequeueReusableCell(withReuseIdentifier:
CellIdentifier, for: indexPath) as! MyCollectionViewCell
//设置内部显示的图片
cell.imageView.image = UIImage(named: books[indexPath.section].images[indexPath.item])
return cell
}
//分区的header
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind
kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
// 获取重用的分组头
if let header = collectionView.dequeueReusableSupplementaryView(ofKind:
UICollectionElementKindSectionHeader, withReuseIdentifier: HeaderIdentifier,
for: indexPath) as? MySectionHeader {
//设置分组标题
header.titleLabel.text = books[indexPath.section].title
return header
}
fatalError("获取重用视图失败!")
}
}
//Collection View样式布局协议相关方法
extension ViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
//返回分组头大小
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize {
return CGSize(width: collectionView.bounds.width, height: 45.0)
}
//返回单元格大小
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
let itemWidth = (collectionView.bounds.width - 5)/3
let itemHeight = itemWidth / 3 * 4
return CGSize(width: itemWidth, height: itemHeight)
}
//每个分组的内边距
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
insetForSectionAt section: Int) -> UIEdgeInsets {
return UIEdgeInsets.zero
}
//单元格的行间距
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 2.0
}
//单元格横向的最小间距
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return 0.0
}
}
源码下载:补充:给CollectionView添加个页眉
1,效果图
(1)collectionView 除了每个分组都有对应的分组头外,还有个整体的 header view。
(2)单元格上移时,整体的页眉也会上移,而分组头有悬停效果。

2,ViewController.swift(高亮部分为修改的地方)
import UIKit
//每月书籍
struct BookPreview {
var title:String
var images:[String]
}
class ViewController: UIViewController {
//重用的单元格和分区头的Identifier
let CellIdentifier = "myCell"
let HeaderIdentifier = "myHeader"
//所有书籍数据
let books = [
BookPreview(title: "五月新书", images: ["0.jpg", "1.jpg","2.jpg", "3.jpg",
"4.jpg","5.jpg","6.jpg"]),
BookPreview(title: "六月新书", images: ["7.jpg", "8.jpg", "9.jpg"]),
BookPreview(title: "七月新书", images: ["10.jpg", "11.jpg", "12.jpg", "13.jpg"])
]
override func viewDidLoad() {
super.viewDidLoad()
//去除存在导航栏时内内边距自动调整功能,防止对自定义的Collection View分区头停留功能造成影响
self.automaticallyAdjustsScrollViewInsets = false
//初始化Collection View
initCollectionView()
}
private func initCollectionView() {
//初始化自定义的flow布局
let layout = StickyHeadersFlowLayout()
//Collection View的位置尺寸
let frame = CGRect(x: 0, y: 64, width: view.bounds.width,
height: view.bounds.height - 64)
//初始化Collection View
let collectionView = UICollectionView(frame: frame, collectionViewLayout: layout)
//Collection View代理设置
collectionView.delegate = self
collectionView.dataSource = self
collectionView.backgroundColor = .white
//注册重用的单元格
let cellXIB = UINib.init(nibName: "MyCollectionViewCell", bundle: Bundle.main)
collectionView.register(cellXIB, forCellWithReuseIdentifier: CellIdentifier)
//注册重用的分组头
let headerXIB = UINib.init(nibName: "MySectionHeader", bundle: Bundle.main)
collectionView.register(headerXIB, forSupplementaryViewOfKind:
UICollectionElementKindSectionHeader, withReuseIdentifier: HeaderIdentifier)
//页眉高度
let headerViewH:CGFloat = 60
//创建页眉
let headerView:UIView = UIView(frame:
CGRect(x:0, y:-headerViewH, width:frame.size.width, height:60))
let headerlabel:UILabel = UILabel(frame: headerView.bounds)
headerlabel.textColor = UIColor.white
headerlabel.backgroundColor = UIColor.clear
headerlabel.font = UIFont.systemFont(ofSize: 16)
headerlabel.text = "CollectionView 页眉"
headerView.addSubview(headerlabel)
headerView.backgroundColor = UIColor.black
//将头部headView添加到collectionView
collectionView.addSubview(headerView)
//插入位置
collectionView.contentInset = UIEdgeInsets(top: headerViewH, left: 0, bottom: 0, right: 0)
//将Collection View添加到主视图中
view.addSubview(collectionView)
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
}
//Collection View数据源协议相关方法
extension ViewController: UICollectionViewDataSource {
//获取分区数
func numberOfSections(in collectionView: UICollectionView) -> Int {
return books.count
}
//获取每个分区里单元格数量
func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
return books[section].images.count
}
//返回每个单元格视图
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
//获取重用的单元格
let cell = collectionView.dequeueReusableCell(withReuseIdentifier:
CellIdentifier, for: indexPath) as! MyCollectionViewCell
//设置内部显示的图片
cell.imageView.image = UIImage(named: books[indexPath.section].images[indexPath.item])
return cell
}
//分区的header
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind
kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
// 获取重用的分组头
if let header = collectionView.dequeueReusableSupplementaryView(ofKind:
UICollectionElementKindSectionHeader, withReuseIdentifier: HeaderIdentifier,
for: indexPath) as? MySectionHeader {
//设置分组标题
header.titleLabel.text = books[indexPath.section].title
return header
}
fatalError("获取重用视图失败!")
}
}
//Collection View样式布局协议相关方法
extension ViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
//返回分组头大小
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize {
return CGSize(width: collectionView.bounds.width, height: 45.0)
}
//返回单元格大小
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
let itemWidth = (collectionView.bounds.width - 5)/3
let itemHeight = itemWidth / 3 * 4
return CGSize(width: itemWidth, height: itemHeight)
}
//每个分组的内边距
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
insetForSectionAt section: Int) -> UIEdgeInsets {
return UIEdgeInsets.zero
}
//单元格的行间距
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 2.0
}
//单元格横向的最小间距
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return 0.0
}
}
全部评论(5)
收藏,谢谢
站长回复:谢谢夸奖。
好实用啊 ,刚好有这个需求,直接拿来主义了!
站长回复:多选夸奖 :)
你这个功能 两种方法 我给你评论的时候弄反了,你向您那个 回复的邮箱(service@hangge.com)中,发邮件了,你看看 我修改您的实例,谢谢
站长回复:哦,我明白你的意思了。当时我写StickyHeadersFlowLayout这个自定义布局的时候就没考虑footer,我现在把StickyHeadersFlowLayout代码更新了。你替换到你之前发给我的样例中,试试看。
谢谢站长的分享,这个当存在头部视图的时候,不会悬停在顶部,而是悬停在下面,该怎么解决啊
//将头部headView添加到collectionView
collectionView.addSubview(self.headerView)
//插入位置
collectionView.contentInset = UIEdgeInsets(top: headerViewH, left: 0, bottom: 0, right: 0)
站长回复:我在文章末尾补充了相关内容,你可以参考下。
航哥,您好,有时间可以写一篇“ObjectMapper”json转Model的文章嘛😄,跪谢
站长回复:可以的,我这周会写几篇关于ObjectMapper的文章,你可以关注下。