UICollectionView自定义布局之风火轮[译]

现在有许多极具创造力的网站,几周前我碰巧浏览到一个名为Form Follows Function的网站,上面有各种交互动画。其中最吸引我的是网站上的导航转轮,转轮由各种交互体验海报组成。

原文:UICollectionView Custom Layout Tutorial: A Spinning Wheel

本教程将讲解如何使用自定义的 UICollectionViewFlowLayout 来再现那个导航风火轮。在开始之前,希望你有对 2D 转换、UICollectionView 及其自定义布局的基本知识。如果你对这些还不是很熟悉,推荐你先看看下面几篇教程。

通过学习该教程,你将了解到:

  • 从头开始创建自定义collectionView的布局,而不是使用 UICollectionViewFlowLayout作为你的基类。
  • view 在其 bounds 之外绕某点旋转

那么现在,让我们开搞吧。

开始

首先下载模板,在 Xcode 中打开,运行。你将看到一系列 cell,每个代表书城中的一本书。

下面我们来看看工程目录结构,有一个 CollectionViewController、一个自定义 Cell,cell 中有一个 imageView。然后 VC 被这些 Cell填充。我们的任务就是创建一个UICollectionViewLayout子类来将这些 Cell 按照圆弧排列。

理论知识

下图是一个带有 cell 的风火轮。黄色区域是 iPhone 的屏幕,蓝色圆角矩形是 cell,红色虚线是你将要放置 cell 的圆弧。

你需要三个参数来创建这种排列:

  • 1.圆弧半径(radius)
  • 2.每个 cell 之间的角度(anglePerItem)
  • 3.每个 cell 的角位置

你可能已经注意到,并非所有 cell 在屏幕当中能正常显示。

假设第0个 cell 的角度为 x 度,那么第1个 cell 的角位置为 x + anglePerItem,第二个为x + anglePerItem * 2,以此类推。第 n 个的角位置的计算公式如下:


angle_for_i = x + (i * anglePerItem)

下图展示的是角坐标系。0度代表中心,顺时针方向为正,逆时针方向为负。所以0度角的 cell 将处在正中央,完全垂直的方向。

现在你对理论知识有了一个全面的理解,让我们开始撸代码吧。

Circular Collection View Layout

新建一个 swift 文件,取名CircularCollectionViewLayout,继承自UICollectionViewLayout。

点击下一步、创建。这个UICollectionViewLayout的子类将包含所有与位置相关的代码。 因为CircularCollectionViewLayout继承自UICollectionViewLayout而不是UICollectionViewFlowLayout,所以你需要处理所有布局过程而不是简单调用 super 中的实现。

我发现 FlowLayout 非常适合网格视图而非圆形布局。 在CircularCollectionViewLayout中,新建两个属性itemSizeradius

let itemSize = CGSize(width: 133, height: 173)
 
var radius: CGFloat = 500 {
  didSet {
    invalidateLayout()
  }
}

当半径改变时你需要重新计算所有值,所以要在 didSet 中调用invalidateLayout()。在 radius 声明下面紧接着anglePerItem的定义:

var anglePerItem: CGFloat {
  return atan(itemSize.width / radius)
}

anglePerItem可以是你想要的任何值,但是公式要确保 cell 不要被分散的太开。 下一步,实现collectionViewContentSize()来声明你的 collectionView 的内容有多大:

override func collectionViewContentSize() -> CGSize {
  return CGSize(width: CGFloat(collectionView!.numberOfItemsInSection(0)) * itemSize.width,
      height: CGRectGetHeight(collectionView!.bounds))
}

内容高度与 collectionView 高度一致,但是宽度是itemSize.width * numberOfItems。 现在打开 Main.storyboard,选中视图大纲中的Collection View,如下图所示

打开Attributes Inspector,将其 Layout 设置为自定义,将其 Class 设置为CircularCollectionViewLayout。

运行程序,你将发现除了一个可滑动区域外,屏幕上没有任何东西。但是它就是你想要的,因为这确保你正确地将 collectionView 的 Layout 设置为你自定义的 Class 即 CircularCollectionViewLayout。

自定义布局属性

除了新建一个新的布局子类,你还要新建一个继承自UICollectionViewLayoutAttributes的类来存储角位置以及锚点(anchorPoint)。 把下面代码加到CircularCollectionViewLayout.swift这个文件中,将其放在CircularCollectionViewLayout类声明上面。

class CircularCollectionViewLayoutAttributes: UICollectionViewLayoutAttributes {
  // 1
  var anchorPoint = CGPoint(x: 0.5, y: 0.5)
  var angle: CGFloat = 0 {
    // 2 
    didSet {
      zIndex = Int(angle * 1000000)
      transform = CGAffineTransformMakeRotation(angle)
    }
  }
  // 3
  override func copyWithZone(zone: NSZone) -> AnyObject {
    let copiedAttributes: CircularCollectionViewLayoutAttributes = 
        super.copyWithZone(zone) as! CircularCollectionViewLayoutAttributes
    copiedAttributes.anchorPoint = self.anchorPoint
    copiedAttributes.angle = self.angle
    return copiedAttributes
  }
}
  • 1.我们需要一个锚点,因为旋转是围绕锚点而非中心。
  • 2.当设置角度(angle)的时候,在内部设置其 transform 旋转 angle 弧度。同时我们想要右边的 cell 覆盖在左边的 cell 上,这个可以通过设置 zIndex 来实现。因为角度用弧度表示,我们将其扩大 1,000,000倍来确保相邻的值不会被四舍五入成同一个 zIndex 值,zIndex 是 Int 型的。
  • 3.复写copyWithZone()来遵循NSCopying协议,因为在 collectionView 布局时,内部会拷贝布局属性。复写这个方法来确保复制过程中,anchorPointangle两个属性也会被拷贝。

下面我们回到CircularCollectionViewLayout中来实现 layoutAttributesClass()方法。

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

这一步是为了告知 collecttionView 你将使用CircularCollectionViewLayoutAttributes而不是默认的UICollectionViewLayoutAttributes。 为了持有布局属性,在所有属性声明之后创建一个名为attributesList的数组。

var attributesList = [CircularCollectionViewLayoutAttributes]()

Preparing the Layout

当 collectionView 第一次展示在屏幕上时,Layout 的 prepareLayout()方法将被调用。在每次布局生效时这个方法也会被调用。 这是布局过程中最重要的方法之一,因为这是创建和存储布局属性的入口。在CircularCollectionViewLayout添加如下代码:

override func prepareLayout() {
  super.prepareLayout()
 
  let centerX = collectionView!.contentOffset.x + (CGRectGetWidth(collectionView!.bounds) / 2.0)
  attributesList = (0..<collectionView!.numberOfItemsInSection(0)).map { (i) 
      -> CircularCollectionViewLayoutAttributes in
    // 1
    let attributes = CircularCollectionViewLayoutAttributes(forCellWithIndexPath: NSIndexPath(forItem: i,
        inSection: 0))
    attributes.size = self.itemSize
    // 2
    attributes.center = CGPoint(x: centerX, y: CGRectGetMidY(self.collectionView!.bounds))
    // 3
    attributes.angle = self.anglePerItem*CGFloat(i)
    return attributes
  }
}

简单来说,我们便利每一个 item,然后执行闭包。下面我们一行行来解释:

  • 1.为每个 IndexPath 创建一个CircularCollectionViewLayoutAttributes实例,然后设置其大小(size)
  • 2.将 item 放在屏幕中间
  • 按弧度来旋转每个 item,旋转量为anglePerItem * i

>方法中的 map 是 Swift 标准库中的一部分,它创建了一个新的数组,数组中存储的是闭包的执行结果。你可以在这篇文章中了解更多。

我们还需要实现下面的方法,这些方法返回在给定矩形区域中的 item 布局属性,以及给定的 indexpath 的 item 布局属性。collectionView 在布局过程中将会多次调用这些方法,在用户滑动 collectionView 也会触发这些方法。为了保证其高效性,我们在prepareLayout()方法中缓存了这些布局属性。把下面代码加到prepareLayout()下面:

override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
  return attributesList
}
 
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) 
    -> UICollectionViewLayoutAttributes! {
  return attributesList[indexPath.row]
}

第一个方法简单返回了整个布局属性数组,第二个方法返回了指定的 indexpath 对应的布局属性。这个方法非常 OK 因为我们的 item 数目比较小,但是通常我们会遍历数组来判断布局属性的 frame 是否与给定的矩形区域相交,然后返回与给定区域相交的布局属性。这使得 collectionView 在屏幕上只绘制这些 item,或者将要出现在屏幕上的 item。

运行,你会看到所有 cell 出现在屏幕上,但是它们是围绕自身来旋转而非外部的某个点。虽然它不是非常急需的效果,但是如果能做到确实挺酷的,你觉得呢?

你能猜到为什么会这样吗?

有人说是锚点吗?

你还记得上面我们说的 cell 的锚点吗?你还没有设置过它,上面的旋转效果远没达到我们希望得到的效果。

锚点是 CALayer 的一个属性,所有的旋转和缩放都是围绕着它而发生的。锚点的默认值是 center,就像上面的运行结果那样。 真正的锚点的 x 值应该为0.5,y 值应该为radius + (itemSize.height / 2),因为锚点是在归一化坐标系中定义的,所以你要除以itemSize.height

回到prepareLayout(),然后再 centerX 的定义下面定义anchorPointY

let anchorPointY = ((itemSize.height / 2.0) + radius) / itemSize.height

map(_:)闭包中的 return 上方添加如下代码:

attributes.anchorPoint = CGPoint(x: 0.5, y: anchorPointY)

接着打开open CircularCollectionViewCell.swift,然后复写applyLayoutAttributes(_:)

override func applyLayoutAttributes(layoutAttributes: UICollectionViewLayoutAttributes!) {
  super.applyLayoutAttributes(layoutAttributes)
  let circularlayoutAttributes = layoutAttributes as! CircularCollectionViewLayoutAttributes
  self.layer.anchorPoint = circularlayoutAttributes.anchorPoint
  self.center.y += (circularlayoutAttributes.anchorPoint.y - 0.5) * CGRectGetHeight(self.bounds)
}

这里你用父类实现来使用默认属性如 center 和 transform 但是因为锚点(anchorPoint)是一个自定义属性,我们需要手动使用它,同样我们也更新了 center.y 来补偿圆形布局中的anchorPoint.y变化。

运行程序,你会看到所有的 cell 按照圆形来布局了,但是滑动的过程中...等一下,发生了什么?它们被移出了屏幕而不是旋转!?

这使得找到想要的书变得非常困难。

改善滑动效果

最具挑战性的布局 item 任务已经完成了,可喜可贺!:]

现在需要做的就是改变角度值来实现滑动。

回到CircularCollectionViewLayout,然后在底部添加下面代码:

override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
  return true
}

该方法返回 true 告知 collectionView 在滑动时布局失效,然后它会调用prepareLayout(),进而使用更新后的角位置重新计算 cell 的布局。angle被定义为第0个 item 的角位置。你将要通过把contentOffset.x转换成一个合适的角度值来实现滑动。

滑动过程中,contentOffset.x从 0 到collectionViewContentSize().width - CGRectGetWidth(collectionView!.bounds)变化。将contentOffset.x的极值定义为maxContentOffset,当其为 0 时,让第 0 个item 处在中心,当其为极值时(即maxContentOffset),让最后一个 item 处在屏幕中心,这就意味着最后一个 item 的角位置会变为 0 。

想象一下右边的场景,如果你是用angle_for_last_item = 0来解决下面等式你会得到:


angle_for_last_item = angle_for_zero_item + (totalItems - 1) * anglePerItem
0 = angle_for_zero_item + (totalItems - 1) * anglePerItem
angle_for_zero_item = -(totalItems - 1) * anglePerItem

定义-(totalItems - 1) * anglePerItemangleAtExtreme,如下所示:

contentOffset.x = 0, angle = 0
contentOffset.x = maxContentOffset, angle = angleAtExtreme

由上面,使用下面的公式非常容易计算任意contentOffset.x对应的角度:


angle = -angleAtExtreme * contentOffset.x / maxContentOffset

脑海中回想以下这些算式,把下面代码添加到 itemSize 的声明下:

var angleAtExtreme: CGFloat {
  return collectionView!.numberOfItemsInSection(0) > 0 ? 
    -CGFloat(collectionView!.numberOfItemsInSection(0) - 1) * anglePerItem : 0
}
var angle: CGFloat {
  return angleAtExtreme * collectionView!.contentOffset.x / (collectionViewContentSize().width - 
    CGRectGetWidth(collectionView!.bounds))
}

接下来使用

attributes.angle = self.angle + (self.anglePerItem * CGFloat(i))

来替换prepareLayout()中的

attributes.angle = (self.anglePerItem * CGFloat(i))

这一步添加为每个 item 添加了角度值,这样 item 的角度值不在是一个常量,而是一个与contentOffset.x有着函数关系的值。 运行程序,在屏幕上滑动,你将发现所有 item 按照你想要的方式在滑动。干得漂亮!

加分环节:优化

你已经成功的重现了风火轮导航,现在可以在拍拍自己肩膀说一句干得漂亮,然后架着二郎腿享受这美好时光。但是在存在优化空间的情况(滑动丝滑般流畅)下你为什么要停下来呢? 在prepareLayout()中为每个 item 创建了一个CircularCollectionViewLayoutAttributes实例,但是不是所有的 item 都会立刻展示在屏幕上。那些离屏的 item,你可以完全跳过对它们的计算,也不必创建CircularCollectionViewLayoutAttributes实例。 但是有一个棘手的问题是:我们需要确定哪些 item 正在屏幕上显示,哪些是离屏的。如下图所示,在 (-θ, θ)范围之外的所有 item 都是离屏的。

举个栗子,为了计算三角形 ABC 中的 θ 角,可以使用下面公式:


tanθ = (collectionView.width / 2) / (radius + (itemSize.height / 2) - (collectionView.height / 2))

prepareLayout()中的anchorPointY下一行加入如下代码:

// 1 
let theta = atan2(CGRectGetWidth(collectionView!.bounds) / 2.0, 
    radius + (itemSize.height / 2.0) - (CGRectGetHeight(collectionView!.bounds) / 2.0))
// 2
var startIndex = 0
var endIndex = collectionView!.numberOfItemsInSection(0) - 1 
// 3
if (angle < -theta) {
  startIndex = Int(floor((-theta - angle) / anglePerItem))
}
// 4
endIndex = min(endIndex, Int(ceil((theta - angle) / anglePerItem)))
// 5
if (endIndex < startIndex) {
  endIndex = 0
  startIndex = 0
}

这一步我们做了什么?

  • 1.使用反正切函数计算theta角
  • 2.初始化startIndexendIndex
  • 3.如果第0个 item 的角位置小于 -theta,那么它就是离屏的,屏上第 1 个 item 的 index 将为 angle 的差值再除以 anglePerItem
  • 4.同样的,屏幕上最后一个 item 是θangle 的差值再除以 anglePerItem,min 是保证endIndex不会越界
  • 5.最后做了一个容错处理,防止在快速滑动时所有 cell 都离屏时导致 endIndex小于 startIndex的情况

下图把上面的计算过程可视化:

既然我们知道了哪些正在显示,哪些是离屏的,我们需要更新用来计算布局属性的起始和结束的 index。使用

attributesList = (startIndex...endIndex).map { (i) 
    -> CircularCollectionViewLayoutAttributes in

来替换prepareLayout()中的:

attributesList = (0..<collectionView!.numberOfItemsInSection(0)).map { (i) 
    -> CircularCollectionViewLayoutAttributes in

运行程序,你会发现视觉上没有明显变化,因为所有的改变仅仅影响离屏的 item。我们可以打开 Xcode 内置的视图层级查看器

因为创建了更少的变量,你应该可以看到性能的提升。

何去何从

你可以在此下载完整代码。

恭喜,你已经成功使用了自定义的 Layout 来实现一个导航风火轮。在这篇教程中你应该学到不少东西,包括如何旋转 view、改变锚点、从头创建自定义的 Layout 以及如何优化让它变得更好。 你可以更改radiusanglePerItem来进一步了解它们是如何来改变最终的圆形布局排列的。这篇教程主要是改变2D 的 transform,你也可以使用3D transform 来创建更有趣的效果。 同样你也可以通过复写argetContentOffsetForProposedContentOffset(_:withScrollingVelocity:)方法来实现snapping行为。 我相信你已经开始跃跃欲试了吧?如果你遇到问题,可以参考下面的代码:

override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
  var finalContentOffset = proposedContentOffset
  let factor = -angleAtExtreme/(collectionViewContentSize().width - 
      CGRectGetWidth(collectionView!.bounds))
  let proposedAngle = proposedContentOffset.x*factor
  let ratio = proposedAngle/anglePerItem
  var multiplier: CGFloat
  if (velocity.x > 0) {
    multiplier = ceil(ratio)
  } else if (velocity.x < 0) {
    multiplier = floor(ratio)
  } else {
    multiplier = round(ratio)
  }
  finalContentOffset.x = multiplier*anglePerItem/factor
  return finalContentOffset
}

如果你有任何疑问、评论或者炫技,请加入下面的讨论。

本博客所有文章除特别声明外,均采用CC 4.0许可协议。转载请注明出处和作者。

关注微信公共号Vong或在微博上关注@Vong_HUST,永远不会错过新内容! 您的支持和鼓励将为我的博客写作增添更多的动力!

动态更新