原文鏈接: UICollectionView Custom Layout Tutorial: A Spinning Wheel
本文翻譯有部分改動,使用OC編寫,原文使用的是Swift,如有需要,可以去原文下載Swift Demo,文章最后會提供OC的Demo。
開始
首先,去下載一個初始項目,原文是用的xib做了一個collectionView,我直接用代碼編寫了,最后得到的效果是這樣:

環(huán)形布局
在項目里創(chuàng)建一個CircularCollectionViewLayout,像這樣

因為這是UICollectionViewLayout的子類,而不是UICollectionViewFlowLayout的子類,所以你需要處理所有l(wèi)ayout的相關操作。
在CircularCollectionViewLayout里,創(chuàng)建itemSize和radius屬性
let itemSize = CGSize(width: 133, height: 173)
var radius: CGFloat = 500 {
didSet {
invalidateLayout()
}
}
invalidateLayout的解釋是:Call -invalidateLayout to indicate that the collection view needs to requery the layout information. 這里將該方法放在didset里,當radius屬性改變時,你重新重新計算所有的屬性進行布局。
在radius聲明下面,定義一個anglePerItem
var anglePerItem: CGFloat {
return atan(itemSize.width / radius)
}
這個屬性可以任意變化,不過這個方法可以保證使其不會間隔太遠。
接下來,重寫collectionViewContentSize方法來確定你的collection view的內(nèi)容大小
override func collectionViewContentSize() -> CGSize {
return CGSize(width: CGFloat(collectionView!.numberOfItemsInSection(0)) * itemSize.width, height: CGRectGetHeight(collectionView!.bounds))
}
contentSize的高度應該和你的collectionView的高度一樣,而寬度應該為 itemSize.width * numberOfItems。
現(xiàn)在,你還需要去把你的CollectionView的layout設置為CircularCollectionViewLayout。
自定義Layout Attributes
你需要自定義一個layout attributes繼承自UICollectionViewLayoutAttributes來存儲angular position 和 anchorPoint屬性。
在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
}
}
- 你需要一個anchorPoint屬性因為旋轉不是圍繞著item的中心點來的。
- 在設定angle屬性時,設置transform為旋轉,參數(shù)為angle radians。同時,你需要將右邊的item疊在左邊的item上,所以你設置了zIndex根據(jù)angle增大而增大。
- 最后,你需要重寫copyWithZone方法,這樣當你在copy該屬性時,會將你自定義的兩個屬性也copy進去。
現(xiàn)在,回到CircularCollectionViewLayout 類,編寫layoutAttributesClass方法
override class func layoutAttributesClass() -> AnyClass {
return CircularCollectionViewLayoutAttributes.self
}
這個方法將告知collectionView使用你自己創(chuàng)建的layoutAttributes類而不是默認的UICollectionViewLayoutAttributes。
為了保存所有的layout attributes的實例,創(chuàng)建一個數(shù)組attributesList在CircularCollectionViewLayout里
var attributesList = [CircularCollectionViewLayoutAttributes]()
準備Layout
當collectionView第一次顯示在屏幕上時,UICollectionViewLayout會執(zhí)行prepareLayout方法。這個方法在你調用invalidateLayout時也會執(zhí)行。
這也是layout過程中最重要的部分,因為這是你創(chuàng)建和保存所有l(wèi)ayout attributes的地方。
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
}
}
簡單點說,你對每個index path下的item做了一次遍歷,然后:
- 為每個index path創(chuàng)建CircularCollectionviewLayoutAttributes, 并設置它的大小。
- 將每個item都放在屏幕中央。
- 根據(jù)anglePerItem * I,將所有item旋轉。
此外,你還需要重寫下面這些方法。并且這些方法將經(jīng)常被執(zhí)行,所以要注意它們的效率。
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
return attributesList
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! {
return attributesList[indexPath.row]
}
運行程序,你會發(fā)現(xiàn)得到了一個這樣子的collectionView

Anchor Point
回到prepareLayout方法,在centerX定義下面,加上
let anchorPointY = ((itemSize.height / 2.0) + radius) / itemSize.height
然后在map閉包里,return前,加上
attributes.anchorPoint = CGPoint(x: 0.5, y: anchorPointY)
然后在CircularCollectionViewCell里,重寫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等等哪些默認已有的屬性,而自定義的屬性achorPoint需要我們自己手動作用在cell上。
再運行之后,

修復滾動效果
回到CircularCollectionViewLayout,然后在類的底部加上
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return true
}
這里返回true來告訴collectionView,每當滾動時,就重新計算layout,這也會調用prepareLayout方法。
下面,添加幾個參數(shù)
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))
}
然后在prepareLayout里,將這句
attributes.angle = (self.anglePerItem * CGFloat(i))
替換成
attributes.angle = self.angle + (self.anglePerItem * CGFloat(i))
這句代碼將angle添加給了每個item,再運行你會發(fā)現(xiàn),已經(jīng)成功啦。

優(yōu)化
在prepareLayout里,你給每個item都創(chuàng)建了attributes,但是并不是每個都顯示在了屏幕里,其實那些沒有顯示出來的item,你完全可以跳過計算。
添加下面這段代碼到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
}
你可以在原文看到這個算法是如何判定哪些item在屏幕內(nèi)的。
當你知道了這些以后,你需要在prepareLayout方法里將
attributesList = (0..<collectionView!.numberOfItemsInSection(0)).map { (i)
-> CircularCollectionViewLayoutAttributes in
替換成
attributesList = (startIndex...endIndex).map { (i)
-> CircularCollectionViewLayoutAttributes in
然后就大功告成啦!?。?/p>