UICollectionView自定义Layout教程

本文来自RAYWENDERLICH网站,上面有一系列的IOS教程,都写得十分精彩,大家可以上去看看。本文对于原文有所删改。因为Swift的语法进行了更新,打开工程的时候会提示更新语法,点击是就行了。

开始工作

首先下载原始工程,然后运行工程,你会看到这样的效果:

这个app使用了UICollectionView展现了一系列的图片,但是由于使用了标准的flow layout,它还是有些问题:图片没有完全填充内容区域,长文字说明没有完全显示。

自定义Collection View Layout

你首先需要为collection view创建一个自定义的layout class。

Collection view的layouts都是抽象类UICollectionViwLayout类的子类,它们定义了你的collection view的每个item的可见attributes,每个单独的attributes都是UICollectionViewLayoutAttributes的实例,它包含每个item的属性,例如frame和opacity。

Layouts的组里面创建一个文件。从iOS\Source列表选择Cocoa Touch Class。将它命名为PinterestLayout,注意它是UICollectionViewLayout的子类。注意这个教程都是Swift的,所以语言一定要选Swift

接着你需要更新你的storyboard配置,打开Main.storyboard,在Photo Stream View Controller Scene子项中选择Collection View,如下图所示

接着打开Attributes InspectorLayout选项选择CustomClass选项选择PinterestLayout

接着再运行工程,你会看到屏幕一片漆黑,这就说明collection view已经绑定了你自定义的PinterestLayout,由于你没有添加任何方法,所以不会显示内容。

核心Latyou过程

collection view layout的过程是由collection view和layout object共同作用的。当collection view需要一些layout信息的时候,它就会要求layout object按一定的顺序调用方法来提供给它:

你自定义的layout子类必须实现以下方法:

  • prepareLayout():当一个layout动作发生时,这个方法就会调用。你应该在里面执行计算collection view的大小和items的位置的操作。

  • collectionViewContenSize():在这个方法里面,你必须返回整个collection view的content的宽和高,这应该包括不可见部分,而不仅仅是可见的部分。collection view会使用这些信息在内部计算它的scroll view的content size。

  • layoutAttributesForElementsInRect(_:):在这个方法里面你要返回在函数参数的Rect的范围内的所有items的layout attributes。返回值是一个UICollectionViewLayoutAttributes类型的array。

计算Layout Attributes

对于这个layout而言,你需要动态计算每个item的位置和高度,因为你事先并不知道图片和文字的高度。你需要声明一个protocol为PinterestLayout计算高度。

现在,打开PinterestLayout.swift,在类声明的上方添加protocol的声明:

protocol PinterestLayoutDelegate {
// 1:计算图片高度
func collectionView(collectionView:UICollectionView, heightForPhotoAtIndexPath indexPath:NSIndexPath, 
  withWidth:CGFloat) -> CGFloat
// 2:计算文字高度
func collectionView(collectionView: UICollectionView, 
  heightForAnnotationAtIndexPath indexPath: NSIndexPath, withWidth width: CGFloat) -> CGFloat
}

接着在PinterestLayout中添加以下代码:

// 1 
var delegate: PinterestLayoutDelegate!

// 2
var numberOfColumns = 2
var cellPadding: CGFloat = 6.0

// 3
private var cache = [UICollectionViewLayoutAttributes]()

// 4
private var contentHeight: CGFloat  = 0.0
private var contentWidth: CGFloat {
let insets = collectionView!.contentInset
 return CGRectGetWidth(collectionView!.bounds) - (insets.left + insets.right)
}

下面对代码进行说明:

  1. 定义一个delegate变量
  2. 定义了列数和cell的padding
  3. 这个数组用来储存计算好的layout attributes。当你调用prepareLayout(),你会计算好所有item的attributes,并把它们添加到这个数组中。当collection view之后需要layout attributes的时候,你就可以直接从cache数组中去获取而不用每次都去重新计算。
  4. 定义两个属性来储存content size。当图片增加时,contentHeight的值就回增加,contentWidth由collection view的width和它的content inset决定(ps:content inset可以理解为内容视图的上下左右四个边扩展出去的大小,默认值为零)

为了更好地理解计算过程,请看下图:

每个item的frame都取决于它所在的列数和同一列中上一个item的位置。

为了计算水平方向的位置,你需要使用不同列的x坐标加上cell的padding来得到。垂直方向的位置则是同一列中上一项的起始位置加上上一项的高度来得到。整个item的高度等于图片高度加上文字高度再加上cellPadding。

如上文提到,你需要在prepareLayout()中为每个item计算它们的UICollectionViewLayoutAttributes

PinterestLayout中添加以下方法:

override func prepareLayout() {
// 1
if cache.isEmpty {
    // 2
    let columnWidth = contentWidth / CGFloat(numberOfColumns)
    var xOffset = [CGFloat]()
    for column in 0 ..< numberOfColumns {
     xOffset.append(CGFloat(column) * columnWidth )
    }
    var column = 0
    var yOffset = [CGFloat](count: numberOfColumns, repeatedValue: 0)

    // 3
    for item in 0 ..< collectionView!.numberOfItemsInSection(0) {

        let indexPath = NSIndexPath(forItem: item, inSection: 0)

        // 4
        let width = columnWidth - cellPadding * 2
        let photoHeight = delegate.collectionView(collectionView!, heightForPhotoAtIndexPath: indexPath, 
      withWidth:width)
        let annotationHeight = delegate.collectionView(collectionView!,
      heightForAnnotationAtIndexPath: indexPath, withWidth: width)
        let height = cellPadding +  photoHeight + annotationHeight + cellPadding
        let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height)
        let insetFrame = CGRectInset(frame, cellPadding, cellPadding)

        // 5
        let attributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
         attributes.frame = insetFrame
  cache.append(attributes)

        // 6
         contentHeight = max(contentHeight, CGRectGetMaxY(frame))
  yOffset[column] = yOffset[column] + height

        column = column >= (numberOfColumns - 1) ? 0 : ++column
     }
  }
}

下面对上述代码进行说明:

  1. 只用cache数组是空的时候才需要计算layout attributes
  2. xOffset根据所在的列数来得到,第一列xOffset = 0,第二列xOffset = contentWidth的一半,然后把这些数值填充到xOffset array中。yOffset array用来记录每一列的y点坐标,设置初始值是零。
  3. 因为collection view只有一个section,所以遍历section所有的item进行计算。
  4. width不需要cellPadding,所以等于之前计算的cellWidth减去cell之间的padding。而height则等于cellPadding + 图片的高度 + 文字高度 + cellPaddig。然后使用当前列的x和y的offsets来创建insetFrame,将它赋值给attribute。(PS:这里的值怀疑是硬编码,个人不能理解为什么width不用cellPadding,attributes的frame为什么要使用CGRectInset,有知道的同学求解答)
  5. 创建UICollectionViewLayoutAttribute,并把它存到cache数组中
  6. 更新当前contentHeight的值,并把新的偏移量存到yOffset array中。最后判断当前列数,来回切换。

Note:当collection view的layout无效时就回重新调用prepareLayout()方法,有很多情况会使layout无效,导致重新计算。比如最典型的就是屏幕方向变化,就会使layout无效。

接着在PinterestLayout中添加下面的方法:

override func collectionViewContentSize() -> CGSize {
    return CGSize(width: contentWidth, height:  contentHeight)
}

重写这个方法,就会给collection view设置之前计算好的content的size。

接着重写layoutAttributesForElementsInRect(_:),这个方法会在prepareLayout()调用之后调用,它会确定给定Rect内的可见items。

继续在PinterestLayout中添加下面代码:

override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

var layoutAttributes = [UICollectionViewLayoutAttributes]()

for attributes in cache {
    if CGRectIntersectsRect(attributes.frame, rect) {
     layoutAttributes.append(attributes)
    }
}
return layoutAttributes 
}

首先遍历cache array,检查每一项的frame是否在给定的Rect中,然后将在Rect的attributes添加到layoutAttributes中再返回给collection view。

我们还要实现PinterestLayoutDelegate中的方法,打开PhotoStreamViewController.swift,添加以下代码:

extension PhotoStreamViewController : PinterestLayoutDelegate {
// 1
func collectionView(collectionView:UICollectionView, heightForPhotoAtIndexPath indexPath: NSIndexPath,
  withWidth width: CGFloat) -> CGFloat {
let photo = photos[indexPath.item]
let boundingRect =  CGRect(x: 0, y: 0, width: width, height: CGFloat(MAXFLOAT))
let rect  = AVMakeRectWithAspectRatioInsideRect(photo.image.size, boundingRect)
return rect.size.height
}

// 2
func collectionView(collectionView: UICollectionView, 
  heightForAnnotationAtIndexPath indexPath: NSIndexPath, withWidth width: CGFloat) -> CGFloat {
let annotationPadding = CGFloat(4)
let annotationHeaderHeight = CGFloat(17)
let photo = photos[indexPath.item]
let font = UIFont(name: "AvenirNext-Regular", size: 10)!
let commentHeight = photo.heightForComment(font, width: width)
let height = annotationPadding + annotationHeaderHeight + commentHeight + annotationPadding
return height
}
}

上面的代码发生了什么:

  1. 使用AVMakeRectWithAspectRationInsideRect()方法计算图片高度,返回的高度受限于提供的width
  2. 主要是使用自定义的heightForComent(_:width:)来计算文字的高度

最后在viewDidLoad()中设置delegate:

if let layout = collectionView?.collectionViewLayout as? PinterestLayout {
    layout.delegate = self
}

好了,现在再运行程序,有以下效果:

虽然有内容了,但是图片并没有填充cell,我们可以自定义layout attributes来解决这个问题。

自定义Layout Attributes

现在你需要改变image view的大小来使其填满整个cell。为了实现这个效果,你需要创建UICollectionViewLayoutAttributies的子类。

UICollectionViewLayoutAttributies的子类中,你可以添加properties,这些properties会被传到cell。你可以使用这些properties在你自定义的子类中重写applyLayoutAtributes(_:)
,具体过程如下图所示:

继续打开PinterestLayout.swift,在之前定义的protocol之前添加以下代码:

class PinterestLayoutAttributes: UICollectionViewLayoutAttributes {

// 1
var photoHeight: CGFloat = 0.0

// 2
override func copyWithZone(zone: NSZone) -> AnyObject {
let copy = super.copyWithZone(zone) as! PinterestLayoutAttributes
copy.photoHeight = photoHeight
return copy
}

// 3
override func isEqual(object: AnyObject?) -> Bool {
if let attributes = object as? PinterestLayoutAttributes {
  if( attributes.photoHeight == photoHeight  ) {
    return super.isEqual(object)
  }
}
return false
}
}

这里定义了一个名为PinterestLayoutAttributesUICollectionViewLayoutAttributes的子类。下面是它的工作流程:

  1. 设置了photoHeight属性来重新设置image view的大小
  2. 重写NSCopying方法,因为attributes会被内部机制复制,所以必须重写NSCopying保证photoHeight在复制的时候被设置
  3. 重写isEqual方法,这也是必要的。collection view会调用这个方法判断新和旧的attributes。你必须重写这个方法来判断你自定义的属性。

然后在PinterestLayot中添加下面方法:

override class func layoutAttributesClass() -> AnyClass {
return PinterestLayoutAttributes.self
}

这个方法会将PinterestLayoutAttributes与collection view绑定。

因为使用了自定义的attributes,要将之前的UICollectionViewLayoutAttributes类型替换为自定义的类型,首先找到下面这一行:

private var cache = [UICollectionViewLayoutAttributes]()

用下面这句代替:

private var cache = [PinterestLayoutAttributes]()

再找到下面这句:

let attributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)

替换为:

 let attributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
 attributes.photoHeight = photoHeight

最后一步就是改变image view的高度了,打开AnnotatedPhotoCell.swift添加下面的方法在类的底部:

override func applyLayoutAttributes(layoutAttributes: UICollectionViewLayoutAttributes) {
super.applyLayoutAttributes(layoutAttributes)
if let attributes = layoutAttributes as? PinterestLayoutAttributes {
imageViewHeightLayoutConstraint.constant = attributes.photoHeight
}
}

重载的方法首先调用超类的方法,再将layoutAttributes转换成自定义的类型,如果成功就改变image view的高度。

重新运行程序,你会发现一切都十分完美:

结束

你可以在这里下载完整的工程。

你可能感兴趣的:(iOS)