

前言
通過本教程你可以了解到:
- 怎么使用collection view layout 做出自己喜歡的效果
- 輪子般轉(zhuǎn)動的原理
開始準備
首先下載個project,這個鏈接下載鏈接下載打開項目后,可以看到如圖的整齊排列好的item(卡片)

然后我們的任務就是將這些item弄為輪轉(zhuǎn)式。不bb了,上教程
原理
圖片里的黃色區(qū)域代表的是iPhone的屏幕,然后哪些綠色的卡片就代表著item,紅色的虛線就是卡片item運動路徑。

我們要用到的三個主要的參數(shù):
1.半徑(radius);
2.就是兩個item之間相差的角度(anglePerItem)
3.每個item的位置(用角度表示)
首先假設,第0個item的角度位置為 x 度,接著第1個 item的角度位置則為x + anglePerItem,第二個item為x + (2 * anglePerItem)然后以此類推。。
第i個item的位置則為:
angle_for_i = x + (i * anglePerItem)
如下,是角度的坐標圖,0度代表中間,正值代表向右旋轉(zhuǎn),負值代表向左旋轉(zhuǎn)。
例如item是0度則是垂直中間

了解了底層理論后,就let’s coding
Circular Collection View Layout
創(chuàng)建一個新的Swift文件用iOS\\Source\\Cocoa Touch Classtemplate,將其命名為CircularCollectionViewLayout,并令其繼承UICollectionViewLayout

點擊Next然后點擊Create.
在CircularCollectionViewLayout,添加兩個參數(shù),item大小的itemSize和半徑radius:
let itemSize = CGSize(width: 133, height: 173)
var radius: CGFloat = 500 {
didSet {
invalidateLayout()
}
}
當半徑radius變化的時候就重新設置Layout利用didSet里的invalidateLayout()
下面使用 radius 定義參數(shù)anglePerItem:
var anglePerItem: CGFloat {
return atan(itemSize.width / radius)
}
事實anglePerItem可以任意數(shù)值,但是用這表達式可以確保item之間不會相距太遠。顯得緊湊些。
接下來,使用 collectionViewContentSize() 定義collection view的content大小
override func collectionViewContentSize() -> CGSize {
return CGSize(width: CGFloat(collectionView!.numberOfItemsInSection(0)) * itemSize.width,
height: CGRectGetHeight(collectionView!.bounds))
}
好了,現(xiàn)在打開Main.storyboard,點擊Collection View:

打開Attributes Inspector然后將Layout設置為Custom, Class設置為CircularCollectionViewLayout:

Build 和 run,然后item(卡片)都變沒有了,別慌!,這正證明你成功地將CircularCollectionViewLayout作為Collection View的Layout

自定義 Layout Attributes
接著需要UICollectionViewLayoutAttributes類去存儲:
item的位置和參照點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.需要anchorPoint,是因為旋轉(zhuǎn)不是圍繞著每個item的中心點轉(zhuǎn)的
2.當angle參數(shù)設置時,就立即令其transform等于angle的角度,而zIndex則是使得后一個item覆蓋前一個item,從而實現(xiàn)右邊的item覆蓋在左邊的item的效果。
3.覆蓋copyWithZone(),是因為當collection view實施layout時會copy參數(shù),覆蓋這個method確保anchorPoint和angle會被copy。
好了,現(xiàn)在回過到CircularCollectionViewLayout并且實施layoutAttributesClass():
override class func layoutAttributesClass() -> AnyClass {
return CircularCollectionViewLayoutAttributes.self
}
這method會告訴collection view,你會使用CircularCollectionViewLayoutAttributes,而不是UICollectionViewLayoutAttributes作為你的layout參數(shù)。
為了保存這些layout參數(shù)對象,需要新建數(shù)組attributesList存儲其:
var attributesList = [CircularCollectionViewLayoutAttributes]()
Preparing the Layout
當collection view出現(xiàn)時,會調(diào)用UIcollectionViewLayout的方法prepareLayout(),并且每次layout被invalid都會調(diào)用這個方法。
這步是至關重要的步驟,因為這里是用來創(chuàng)建和存儲layout參數(shù)的。
在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
}
}
迭代collection view里的item并且執(zhí)行閉包里的代碼。
注釋:
1.創(chuàng)建每個idexPath的CircularCollectionViewLayoutAttributes對象,并且設置size
2.將每個item的位置都設置為屏幕中心
3.將每個item都旋轉(zhuǎn)(anglePerItem * i)度
為了能使用UICollectionViewLayout,你還需要覆蓋以下method。
這些method都會被引用很多次,所以要盡可能保持代碼小量簡潔。
//設置給出rect下的items的attributesList
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
return attributesList
}
//設置item用到的attribute
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath)
-> UICollectionViewLayoutAttributes! {
return attributesList[indexPath.row]
}
ok,Build 和 run,你會看見一堆圖片旋轉(zhuǎn)在中間。

為什么這樣?
是因為Anchor Point是每個item的中心
Anchor Point
Anchor Point是CALayer里的參數(shù),用來作為旋轉(zhuǎn)或者拉伸的參照點。默認值是中心。
我們之前把這個值設置為0.5,而沒有改變所以就出現(xiàn)前面的旋轉(zhuǎn)都是中心旋轉(zhuǎn)。如下圖,anchor point的y值等于radius + itemSize.height,然而anchor point是定義在單元坐標(1x1)里的,所以要除以itemSize.height
回到prepareLayout,定義anchorPointY:
let anchorPointY = ((itemSize.height / 2.0) + radius) / itemSize.height
然后在map(_:)的閉包里,將以下代碼添加在return前面:
attributes.anchorPoint = CGPoint(x: 0.5, y: anchorPointY)
下一步,在CircularCollectionViewCell.swift覆蓋函數(shù)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)
}
這里使用super來將默認的值設置好,例如:center和transform。但是anchorPoint是不是默認設置的,所以就添加代碼上去,。而且因為anchorPoint變化了center也會變化,所以進行補償。
build and run ,你會看到終于像個輪子了,但是當你向左滑時,它是平移而不是旋轉(zhuǎn)。

改進滾動效果
跳到CircularCollectionViewLayout添加如下代碼:
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return true
}
ruturn true 告訴collection view滾動時,調(diào)用prepareLayout()來重新計算每一個item的位置。
angle是用來表示第0個item的位置。接下來會將滾動時的contentOffset.x轉(zhuǎn)為用第0個item的角度位置angle
contentOffset.x滾動的最小值為0,最大值為collectionViewContentSize().width - CGRectGetWidth(collectionView!.bounds),將滾動的最大值contentOffset.x命名為maxContentOffset.滾動值為0時,第0個item垂直于中間,而到達極限值時,最后一個item也是垂直于中間。這意味著最后一個item的角度也是0

angle_for_last_item = angle_for_zero_item + (totalItems - 1) * anglePerItem
//將angle_for_last_item代入上式
0 = angle_for_zero_item + (totalItems - 1) * anglePerItem
angle_for_zero_item = -(totalItems - 1) * anglePerItem
那么如上就求得當?shù)竭_最后一個item時,第0個item的角度angle_for_zero_item
,接著將這個表達式-(totalItems - 1) * anglePerItem定義為第0個item的最大角度angleAtExtreme。
contentOffset.x = 0, angle = 0
contentOffset.x = maxContentOffset, angle = angleAtExtreme
從上面的表達式不難推斷出下面這條表達式:
angle = -angleAtExtreme * contentOffset.x / maxContentOffset
接下來將公式轉(zhuǎn)化為代碼寫在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))
}
接著將prepareLayout()下面這條代碼:
attributes.angle = (self.anglePerItem * CGFloat(i))
替換為:
attributes.angle = self.angle + (self.anglePerItem * CGFloat(i))
這條代碼將attributes.angle與contentOffset.x關聯(lián)起來了
bulid and run ,現(xiàn)在就達到我們想要的效果了。

優(yōu)化
在prepareLayout()里你為每一個item都創(chuàng)建一個CircularCollectionViewLayoutAttributes對象。但是不是所有都出現(xiàn)在屏幕上,對于哪些不出現(xiàn)在屏幕上的,能夠完全不去為它創(chuàng)建對象。
這里就需要檢測判斷哪些對象不在屏幕上。如圖,item出現(xiàn)在屏幕上的位置范圍是(-θ, θ) ,而超出這范圍的都不顯示。

為了計算θ,在三角形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.用tan的反函數(shù)求出theta
2.初始化startIndex和endIndex為0和最后一個
3.如果angle小于-theta,則代表其不在屏幕上。那么出現(xiàn)在屏幕的第一個item的index則為angle至-θ的角度除以anglePerItem,因為angle為負值,所以就先變?yōu)檎怠O蛳氯≌麆t代表item要完全不在屏幕才消失。
4.同樣,最后的item的idex則為angle加上θ除以anglePerItem,然后使用min確保不會超出范圍。
5.最后的會發(fā)生滑動過快,從而使所有的item消失在屏幕。

知道了哪些在屏幕,哪些不在屏幕后,接下來更新改變prepareLayout()的語句:
attributesList = (0..<collectionView!.numberOfItemsInSection(0)).map { (i)
-> CircularCollectionViewLayoutAttributes in
替換為
attributesList = (startIndex...endIndex).map { (i)
-> CircularCollectionViewLayoutAttributes in
buid and run,發(fā)現(xiàn)沒什么改變,但實際上你已經(jīng)改善了。如果item多起來的話就能看到效果了。
接下來要干什么呢?
實現(xiàn)中間的item總會停留在垂直中間

可以通過覆蓋CircularCollectionViewLayout的targetContentOffsetForProposedContentOffset(_:withScrollingVelocity:)
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
var finalContentOffset = proposedContentOffset
//1
let factor = -angleAtExtreme/(collectionViewContentSize().width -
CGRectGetWidth(collectionView!.bounds))
let proposedAngle = proposedContentOffset.x*factor
let ratio = proposedAngle/anglePerItem
var multiplier: CGFloat
//2
if (velocity.x > 0) {
multiplier = ceil(ratio)
} else if (velocity.x < 0) {
multiplier = floor(ratio)
} else {
multiplier = round(ratio)
}
//3
finalContentOffset.x = multiplier*anglePerItem/factor
return finalContentOffset
}
這些計算是干什么的?
1.計算出將要停下的角度proposedAngle,和比率ratio
2.接著將比率ratio取整
3.再用整數(shù)的比率求出最終的ContentOffset
最后
有了這些原理就可以實現(xiàn)一些你喜歡的效果了,或者加一些效果進去。
例如滾動時標題隨著中間的item變化:

使用scroll View的delegatescrollViewDidScroll(_:)
然后計算中間item的indexPath,用angle除以anglePerItem得出
文章挺長的,看到這里的估計都是真愛了
這篇文章是翻譯和修改這片文章的raywenderlich
(END and Thank U)