最近刚把项目的开发语言升级到了Swift 3.0, 回顾了之前学过的知识, 想发布文章和大家自己掌握的知识和一些开发商的小技巧, 所以就诞生了下面的文章.
需要对齐cell的原因:
UICollectionView是常用的一个应用于流布局的类, 但是很可惜, 有时候苹果官方提供的方法并不能优美地展示你的UI, 毕竟每个人需要展示的效果可能都不一致. 下面是具体的解析:
首先看官方的对齐方式
创建一个新的项目, 在storyboard的ViewController中拽入UICollectionView定好约束, 然后拽入一个UICollectionViewCell并对Cell进行注册
Cell的代码如下:
class TextCell: UICollectionViewCell {
@IBOutlet weak var textLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
//设置超出的部分隐藏
textLabel.clipsToBounds = true
}
override func layoutSubviews() {
super.layoutSubviews()
//设置四角的弧度, 设置为高度的一般即是左右为半圆, 效果看下面的效果图
textLabel.layer.cornerRadius = self.frame.height/2
}
}
ViewController遵从UICollectionViewDataSource
和UICollectionViewDelegateFlowLayout
协议
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout
创建数据源
@IBOutlet weak var collectionView: UICollectionView!
//文字数据源
var dataSource: [String] = ["made", "in", "China", "UIView", "UITableView", "UICollectionView"]
//宽度数据源, 获得宽度时提前计算item的size, 读取时可以减少计算次数
var widthData: [CGFloat] = []
配置collectionView的属性
override func viewDidLoad() {
super.viewDidLoad()
//初始化UICollectionViewFlowLayout.init对象
let flowLayout = UICollectionViewFlowLayout.init()
//设置行间距
flowLayout.minimumLineSpacing = 10
//设置列间距
flowLayout.minimumInteritemSpacing = 10
//设置边界的填充距离
flowLayout.sectionInset = UIEdgeInsets.init(top: 10, left: 10, bottom: 10, right: 10)
//给collectionView设置布局属性, 也可以通过init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout)方法来创建一个UICollectionView对象
collectionView.collectionViewLayout = flowLayout
//设置协议代理
collectionView.delegate = self
collectionView.dataSource = self
//提前计算item的宽度
caculateSize(withData: dataSource)
//刷新collectionView
collectionView.reloadData()
}
func caculateSize(withData dataSource: [String]){
//清空旧数据
widthData.removeAll()
//遍历数据源, 计算item的宽度(高度已固定)
for string in dataSource {
//CGFloat.greatestFiniteMagnitude是Swift 3.0语法, 相当于2.3中的CGFloat.max, 即是CGFloat的最大值
widthData.append(string.width(withFont: UIFont.systemFont(ofSize: 14), size: CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: 30))+30)
}
}
//给String类添加拓展方法, 此方法需写在ViewController之外, 即与class同级
class ViewController: UIViewController {
}
extension String {
/// 根据固定的size和font计算文字的rect
///
/// - Parameters:
/// - font: 文字的字体大小
/// - size: 文字限定的宽高(计算规则:计算宽度, 传入一个实际的高度, 用于计算的宽度则取计算单位的最大值)
/// - Returns: 返回的CGRect
func rect(withFont font: UIFont, size: CGSize) -> CGRect {
return (self as NSString).boundingRect(with: size, options: NSStringDrawingOptions.usesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
}
/// 根据固定的size和font计算文字的height
func height(withFont font: UIFont, size: CGSize) -> CGFloat {
return self.rect(withFont: font, size: size).height
}
/// 根据固定的size和font计算文字的width
func width(withFont font: UIFont, size: CGSize) -> CGFloat {
return self.rect(withFont: font, size: size).width
}
}
由于UICollectionViewDelegateFlowLayout
这个协议提供了collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
这个方法, 我们可以实现该方法返回item的size达成cell大小自适应. 下面是协议实现:
//MARK: - UICollectionViewDataSource
//返回对应组中item的个数, Demo中只有一个分组, 所以直接返回个数
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataSource.count
}
//返回每个item
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TextCell", for: indexPath) as! TextCell
//设置cell中展示的文字
cell.textLabel.text = dataSource[indexPath.row]
return cell
}
//MARK: - UICollectionViewDelegateFlowLayout
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
//返回每个item的size
return CGSize.init(width: widthData[indexPath.row], height: 30)
}
下面是现在代码的运行效果:
标记图如下, 标记的单位是px, 20px相当于代码中的10:
通过观察可以发现, 在collectionView第一行显示不下
UITableView
这个cell, 自动把它移动到第二行,第一行的cell平铺开, 我们一开始设置的列间距
minimumInteritemSpacing = 10
在第一行变得无效了, 因为在官方代码的计算中
sectionInset
的优先级比
minimumInteritemSpacing
高, 实际显示的列间距变成了
(collectionView.frame.size.width-(同一行各item的宽度之和)-sectionInset.left-sectionInset.right)/(item的个数-1)
, 然而这样的显示效果有时候不是我们想要的, 有时候我们就想要
minimumInteritemSpacing
这个列间距生效所有cell向左看齐, 在我目前的摸索中还没有发现方便快捷的属性可以直接改变系统默认的对齐方式, 希望有这方面知识的朋友可以指点我.
对齐的实现(宽度一致)
我参考很多优秀的开源代码, 给了我很好的思路, 现在我用Swift 3.0 语言来把自己的想法通过比较简单的代码展现出来.
创建一个枚举, 用于区分对齐方向
/// 对齐方向的枚举, 可拓展, 命名可根据自己喜好
enum AlignDirection: Int {
case left = 0, //左对齐
rightFlow, //右对齐 左起显示
rightData, //右对齐 右起显示
center //中间对齐
}
创建一个类CollectionViewAlignFlowLayout
继承于UICollectionViewFlowLayout
, 该类内部的代码如下:
class CollectionViewAlignFlowLayout: UICollectionViewFlowLayout {
//默认向左对齐
var alignDirection: AlignDirection = .left
//所有cell的布局属性
var layoutAttributes: [UICollectionViewLayoutAttributes] = []
//collectionViewContentSize collectionView的实际大小
private var contentSize: CGSize = CGSize.zero
override var collectionViewContentSize: CGSize {
if self.scrollDirection == .vertical {
return CGSize.init(width: self.collectionView!.frame.width, height: contentSize.height)
}
return CGSize.init(width: contentSize.width, height: self.collectionView!.frame.height)
}
override init() {
super.init()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
//预加载布局属性
override func prepare() {
super.prepare()
contentSize = CGSize.zero
//目前只考虑单个分组的实现
let itemNum: Int = self.collectionView!.numberOfItems(inSection: 0)
//清空旧布局
layoutAttributes.removeAll()
for i in 0.. UICollectionViewLayoutAttributes? {
//size默认为itemSize
var size = self.itemSize
//从代理方法获取item的size
if self.collectionView!.delegate != nil && self.collectionView!.delegate!.conforms(to: UICollectionViewDelegateFlowLayout.self) && self.collectionView!.delegate!.responds(to: #selector(UICollectionViewDelegateFlowLayout.collectionView(_:layout:sizeForItemAt:))) {
//转换对象
let flowLayoutDelegate = self.collectionView!.delegate as! UICollectionViewDelegateFlowLayout
size = flowLayoutDelegate.collectionView!(self.collectionView!, layout: self, sizeForItemAt: indexPath)
}
//初始化每个item的frame
var frame = CGRect.zero
//初始化x, y
var x: CGFloat = 0
var y: CGFloat = 0
//从layoutAttributes中获取上一个item 如果获取不到 设置现在的item为第一个item
//判断collectionView的滑动方向
if self.scrollDirection == .vertical {
//获取collectionView的宽度
let collectionViewWidth: CGFloat = self.collectionView!.bounds.width
//根据对齐方向设置坐标的初始值
if alignDirection == .left {
//左对齐
x = self.sectionInset.left
y = self.sectionInset.top
//判断是否上一个item
if layoutAttributes.count>0 {
//获取上一个item
let lastLayoutAttr = layoutAttributes.last!
//判断当前行的宽度是否足够插入新的item
if lastLayoutAttr.frame.maxX+self.minimumInteritemSpacing+size.width+self.sectionInset.right>collectionViewWidth {
//如果宽度总和超过总宽度, 改变y坐标, 当前的item在下一行显示
y = lastLayoutAttr.frame.maxY+self.minimumLineSpacing
}else{
//如果宽度可以插入item, 修改坐标点, y轴与上一个item平齐, x轴则为上一个item的最右边加上行间距
x = lastLayoutAttr.frame.maxX+self.minimumInteritemSpacing
y = lastLayoutAttr.frame.minY
}
}
}
}else {
//水平方向滑动
let layoutAttr = super.layoutAttributesForItem(at: indexPath)!
x = layoutAttr.frame.origin.x
y = layoutAttr.frame.origin.y
}
frame = CGRect(x: x, y: y, width: size.width, height: size.height)
//更新contentSize, 此处赋值有时候不是最大值, 如果需要用到collectionViewContentSize这个属性, 需要判断新的值是否比原值大
contentSize.width = frame.maxX+self.sectionInset.right
contentSize.height = frame.maxY+self.sectionInset.bottom
//创建每个item对应的布局属性
let layoutAttr = UICollectionViewLayoutAttributes.init(forCellWith: indexPath)
layoutAttr.frame = frame
return layoutAttr
}
//返回所有布局
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return layoutAttributes
}
}
1. 左对齐
上面的代码中就是左对齐的实现, 调用的代码如下:
let flowLayout = CollectionViewAlignFlowLayout.init()
flowLayout.minimumLineSpacing = 10
flowLayout.minimumInteritemSpacing = 10
flowLayout.sectionInset = UIEdgeInsets.init(top: 10, left: 10, bottom: 10, right: 10)
collectionView.collectionViewLayout = flowLayout
实质上就是简单地把UICollectionViewFlowLayout
替换成我们自定义的CollectionViewAlignFlowLayout
, 运行效果如下:
2. 右对齐
右对齐有两种显示方式, 一种是数据源从右到左显示, 另一种是数据源从左到右显示但是UI向右靠拢, 第一种方式实现比较简单
第一种方式代码如下:
else if alignDirection == .rightData {
//右对齐, 计算方式与左对齐类似, 在此就不详细写出
x = collectionViewWidth-self.sectionInset.right-size.width
y = self.sectionInset.top
if layoutAttributes.count>0 {
let lastLayoutAttr = layoutAttributes.last!
if lastLayoutAttr.frame.minX-self.minimumInteritemSpacing-size.width-self.sectionInset.left<0 {
y = lastLayoutAttr.frame.maxY+self.minimumLineSpacing
}else{
x = lastLayoutAttr.frame.minX-self.minimumInteritemSpacing-size.width
y = lastLayoutAttr.frame.minY
}
}
}
}
因为设置了默认对齐方式是左对齐, 所以现在要修改对齐方式:
flowLayout.alignDirection = .rightData
运行效果如下:
第二种方式设计如下:
先在CollectionViewAlignFlowLayout
类中增加一个属性用来保存每行所有item的布局属性(居中对齐也会用到)
//单独一行cell的布局属性
var layoutAttributesLine: [UICollectionViewLayoutAttributes] = []
在prepare()
方法中添加如下代码:
//清空旧布局
layoutAttributesLine.removeAll()
创建一个用于重构布局的方法:
//重新绘制布局
func reloadlayoutAttributes() {
//重新绘制布局有右对齐和居中对齐两种
if self.alignDirection == .rightFlow {
//单行item个数为1时, item始终在最右边, 所以不需要改变位置
if layoutAttributesLine.count>1 {
for i in 0..
实际调用如下, 我们需要在layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
方法里添加新的处理:
else if alignDirection == .rightFlow || alignDirection == .rightData {
//右对齐, 计算方式与上类似, 在此就不详细写出
x = collectionViewWidth-self.sectionInset.right-size.width
y = self.sectionInset.top
if layoutAttributes.count>0 {
let lastLayoutAttr = layoutAttributes.last!
if lastLayoutAttr.frame.minX-self.minimumInteritemSpacing-size.width-self.sectionInset.left<0 {
y = lastLayoutAttr.frame.maxY+self.minimumLineSpacing
reloadlayoutAttributes() //新加的代码, 调用重构布局方法
layoutAttributesLine.removeAll()
}else{
x = lastLayoutAttr.frame.minX-self.minimumInteritemSpacing-size.width
y = lastLayoutAttr.frame.minY
}
}
}
在下部分继续添加代码:
//创建每个item对应的布局属性
let layoutAttr = UICollectionViewLayoutAttributes.init(forCellWith: indexPath)
layoutAttr.frame = frame
layoutAttributesLine.append(layoutAttr)
if indexPath.row == self.collectionView!.numberOfItems(inSection: 0)-1 { //判断是否最后一个item
reloadlayoutAttributes() //重构布局
}
return layoutAttr
调用是一样的, 修改对齐方式即可:
flowLayout.alignDirection = .rightFlow
下面是效果:
注意哦, 上面的代码虽然是实现了我们需要的效果, 可惜还是比较复杂了, 我们如果使用填充法可以更简单地实现该效果, 修改如下:
//重新绘制布局
func reloadlayoutAttributes() {
if layoutAttributesLine.count == 0 {return} //防止越界
//重新绘制布局有右对齐和居中对齐两种
if self.alignDirection == .rightFlow {
//先获取空白部分的宽度(即是填充宽度的大小)
let space = self.collectionView!.bounds.width-layoutAttributesLine.last!.frame.maxX-self.sectionInset.right
for layout in layoutAttributesLine {
var newFrame = layout.frame
newFrame.origin.x += space
layout.frame = newFrame
}
}
layoutAttributesLine.removeAll()
}
同时, 在layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
也要修改:
if alignDirection == .left || alignDirection == .rightFlow {
//左对齐
x = self.sectionInset.left
y = self.sectionInset.top
//判断是否上一个item
if layoutAttributes.count>0 {
//获取上一个item
let lastLayoutAttr = layoutAttributes.last!
//判断当前行的宽度是否足够插入新的item
if lastLayoutAttr.frame.maxX+self.minimumInteritemSpacing+size.width+self.sectionInset.right>collectionViewWidth {
//如果宽度总和超过总宽度, 改变y坐标, 当前的item在下一行显示
y = lastLayoutAttr.frame.maxY+self.minimumLineSpacing
reloadlayoutAttributes() //新加的代码
}else{
//如果宽度可以插入item, 修改坐标点, y轴与上一个item平齐, x轴则为上一个item的最右边加上行间距
x = lastLayoutAttr.frame.maxX+self.minimumInteritemSpacing
y = lastLayoutAttr.frame.minY
}
}
}else if alignDirection == .rightData {
//右对齐, 计算方式与上类似, 在此就不详细写出
x = collectionViewWidth-self.sectionInset.right-size.width
y = self.sectionInset.top
if layoutAttributes.count>0 {
let lastLayoutAttr = layoutAttributes.last!
if lastLayoutAttr.frame.minX-self.minimumInteritemSpacing-size.width-self.sectionInset.left<0 {
y = lastLayoutAttr.frame.maxY+self.minimumLineSpacing
}else{
x = lastLayoutAttr.frame.minX-self.minimumInteritemSpacing-size.width
y = lastLayoutAttr.frame.minY
}
}
}
哈哈, 代码逻辑减少了很多, 但是展示的效果是一样的, 下面是效果图:
3. 居中对齐
计算思路可以简单地分两种:
1.先用.left方式计算出每行所有item的位置, 然后将右边空白部分的一半宽度填充到左边
2.先用.rightFlow方式计算出每行所有item的位置, 然后将左边空白部分的一半填充到右边
按照计算步骤的多少来考虑, 第一种方法比较好
我们需要在重构布局方法reloadlayoutAttributes()
中做如下修改:
//重新绘制布局
func reloadlayoutAttributes() {
if layoutAttributesLine.count == 0 {return} //防止越界
//重新绘制布局有右对齐和居中对齐两种
if self.alignDirection == .rightFlow || self.alignDirection == .center {
//计算填充比例, rightFlow为1, center为0.5
let scale: CGFloat = self.alignDirection == .rightFlow ? 1 : 0.5
//先获取空白部分的宽度(即是填充宽度的大小)
let space = (self.collectionView!.bounds.width-layoutAttributesLine.last!.frame.maxX-self.sectionInset.right)*scale
for layout in layoutAttributesLine {
var newFrame = layout.frame
newFrame.origin.x += space
layout.frame = newFrame
}
}
layoutAttributesLine.removeAll()
}
修改layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
方法中的判断条件:
if alignDirection == .left || alignDirection == .rightFlow || alignDirection == .center
最终效果如下:
注意:尽量不要让item的宽度与sectionInset之和超过collectionView的实际宽度, 否则可能会导致显示出错.
如果觉得文章的描写太过复杂, 可以参考该项目的 Demo