前言
通过本教程你可以了解到:
- 怎么使用collection view layout 做出自己喜欢的效果
- 轮子般转动的原理
开始准备
首先下载个project,这个链接下载链接下载打开项目后,可以看到如图的整齐排列好的item(卡片)
然后我们的任务就是将这些item弄为轮转式。不bb了,上教程
原理
图片里的黄色区域代表的是iPhone的屏幕,然后哪些绿色的卡片就代表着item,红色的虚线就是卡片item运动路径。
我们要用到的三个主要的参数:
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度代表中间,正值代表向右旋转,负值代表向左旋转。
例如item是0度则是垂直中间
了解了底层理论后,就let’s coding
Circular Collection View Layout
创建一个新的Swift文件用iOS\\Source\\Cocoa Touch Class
template,将其命名为CircularCollectionViewLayout,并令其继承UICollectionViewLayout
点击Next
然后点击Create
.
在CircularCollectionViewLayout
,添加两个参数,item大小的itemSize和半径radius:
let itemSize = CGSize(width: 133, height: 173)
var radius: CGFloat = 500 {
didSet {
invalidateLayout()
}
}
当半径radius变化的时候就重新设置Layout利用didSet里的invalidateLayout()
下面使用 radius 定义参数anglePerItem:
var anglePerItem: CGFloat {
return atan(itemSize.width / radius)
}
事实anglePerItem可以任意数值,但是用这表达式可以确保item之间不会相距太远。显得紧凑些。
接下来,使用 collectionViewContentSize() 定义collection view的content大小
override func collectionViewContentSize() -> CGSize {
return CGSize(width: CGFloat(collectionView!.numberOfItemsInSection(0)) * itemSize.width,
height: CGRectGetHeight(collectionView!.bounds))
}
好了,现在打开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,是因为旋转不是围绕着每个item的中心点转的
2.当angle参数设置时,就立即令其transform等于angle的角度,而zIndex则是使得后一个item覆盖前一个item,从而实现右边的item覆盖在左边的item的效果。
3.覆盖copyWithZone(),是因为当collection view实施layout时会copy参数,覆盖这个method确保anchorPoint和angle会被copy。
好了,现在回过到CircularCollectionViewLayout
并且实施layoutAttributesClass():
override class func layoutAttributesClass() -> AnyClass {
return CircularCollectionViewLayoutAttributes.self
}
这method会告诉collection view,你会使用CircularCollectionViewLayoutAttributes
,而不是UICollectionViewLayoutAttributes
作为你的layout参数。
为了保存这些layout参数对象,需要新建数组attributesList存储其:
var attributesList = [CircularCollectionViewLayoutAttributes]()
Preparing the Layout
当collection view出现时,会调用UIcollectionViewLayout的方法prepareLayout()
,并且每次layout被invalid都会调用这个方法。
这步是至关重要的步骤,因为这里是用来创建和存储layout参数的。
在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并且执行闭包里的代码。
注释:
1.创建每个idexPath的CircularCollectionViewLayoutAttributes
对象,并且设置size
2.将每个item的位置都设置为屏幕中心
3.将每个item都旋转(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,你会看见一堆图片旋转在中间。
为什么这样?
是因为Anchor Point是每个item的中心
Anchor Point
Anchor Point是CALayer里的参数,用来作为旋转或者拉伸的参照点。默认值是中心。
我们之前把这个值设置为0.5,而没有改变所以就出现前面的旋转都是中心旋转。如下图,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
覆盖函数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 ,你会看到终于像个轮子了,但是当你向左滑时,它是平移而不是旋转。
改进滚动效果
跳到CircularCollectionViewLayout
添加如下代码:
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return true
}
ruturn true
告诉collection view滚动时,调用prepareLayout()来重新计算每一个item的位置。
angle
是用来表示第0个item的位置。接下来会将滚动时的contentOffset.x
转为用第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
那么如上就求得当到达最后一个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
接下来将公式转化为代码写在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
关联起来了
bulid and run ,现在就达到我们想要的效果了。
优化
在prepareLayout()
里你为每一个item都创建一个CircularCollectionViewLayoutAttributes
对象。但是不是所有都出现在屏幕上,对于哪些不出现在屏幕上的,能够完全不去为它创建对象。
这里就需要检测判断哪些对象不在屏幕上。如图,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.用tan的反函数求出theta
2.初始化startIndex
和endIndex
为0和最后一个
3.如果angle
小于-theta
,则代表其不在屏幕上。那么出现在屏幕的第一个item的index则为angle至-θ的角度除以anglePerItem,因为angle为负值,所以就先变为正值。向下取整则代表item要完全不在屏幕才消失。
4.同样,最后的item的idex则为angle加上θ除以anglePerItem,然后使用min确保不会超出范围。
5.最后的会发生滑动过快,从而使所有的item消失在屏幕。
知道了哪些在屏幕,哪些不在屏幕后,接下来更新改变prepareLayout()
的语句:
attributesList = (0..<collectionView!.numberOfItemsInSection(0)).map { (i)
-> CircularCollectionViewLayoutAttributes in
替换为
attributesList = (startIndex...endIndex).map { (i)
-> CircularCollectionViewLayoutAttributes in
buid and run,发现没什么改变,但实际上你已经改善了。如果item多起来的话就能看到效果了。
接下来要干什么呢?
实现中间的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.再用整数的比率求出最终的ContentOffset
最后
有了这些原理就可以实现一些你喜欢的效果了,或者加一些效果进去。
例如滚动时标题随着中间的item变化:
使用scroll View的delegatescrollViewDidScroll(_:)
然后计算中间item的indexPath,用angle除以anglePerItem得出
文章挺长的,看到这里的估计都是真爱了
这篇文章是翻译和修改这片文章的raywenderlich
(END and Thank U)