轮转式卡片效果 - 个性化UICollectionView Layout(译)

轮转式卡片
最终效果

前言

通过本教程你可以了解到:

  • 怎么使用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 Classtemplate,将其命名为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里的参数,用来作为旋转或者拉伸的参照点。默认值是中心。

anchor point

我们之前把这个值设置为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也会变化,所以进行补偿。

center 因anchor 不同而不同,所以需要补偿

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.anglecontentOffset.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.初始化startIndexendIndex为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总会停留在垂直中间

snap

可以通过覆盖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变化:

个人项目25min

使用scroll View的delegatescrollViewDidScroll(_:)
然后计算中间item的indexPath,用angle除以anglePerItem得出


文章挺长的,看到这里的估计都是真爱了

这篇文章是翻译和修改这片文章的raywenderlich

(END and Thank U)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容