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

原文:UICollectionView Custom Layout Tutorial: A Spinning Wheel

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

  • UICollectionView Tutorial Part 1: Getting Started

  • UICollectionView Tutorial Part 2: Reusable Views and Cell Selection

  • Video Series: Collection Views

  • Video Series: Custom Collection View Layouts

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

  • 从头开始创建自定义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 = 0contentOffset.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))// 2var startIndex = 0var endIndex = collectionView!.numberOfItemsInSection(0) - 1 // 3if (angle < -theta) { startIndex = Int(floor((-theta - angle) / anglePerItem)) }// 4endIndex = min(endIndex, Int(ceil((theta - angle) / anglePerItem)))// 5if (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 内置的视图层级查看器

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

何去何从

你可以在此 : http://cdn4.raywenderlich.com/wp-content/uploads/2015/06/CircularCollectionView-Final.zip下载完整代码。

恭喜,你已经成功使用了自定义的 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 }

转载于:https://www.cnblogs.com/xvewuzhijing/p/5003759.html

iOS开发学无止境 - UICollectionView自定义布局之风火轮[译]相关推荐

  1. UICollectionView自定义布局(二)

    这是UICollectionView自定义布局的第二篇,实现类似UltravisualApp的视差效果,同样这篇文章的教程来自Ray家的Swift Expanding Cells in iOS Col ...

  2. UICollectionView 自定义布局!看这篇就够了

    各位同学早上好,新的一周又开始啦!眨眼之间,我们就要与3月挥手告别了,时间过得可真快,不禁感慨道"无可奈何花落去,似曾相识燕归来". 最近,我花了很多的时间整理了 UICollec ...

  3. UICollectionView 自定义布局教程: Pinterest

    原文: UICollectionView Custom Layout Tutorial: Pinterest 作者:Paride Broggi 译者:kmyhy 更新说明:本教程由 Paride Br ...

  4. iOS开发多线程篇—自定义NSOperation

    iOS开发多线程篇-自定义NSOperation 一.实现一个简单的tableView显示效果 实现效果展示: 代码示例(使用以前在主控制器中进行业务处理的方式) 1.新建一个项目,让控制器继承自UI ...

  5. iOS开发那些事--自定义单元格实现

    自定义单元格 当苹果公司提供给的单元格样式不能我们的业务需求的时候,我们需要自定义单元格.在iOS 5之前,自定义单元格可以有两种实现方式:代码实现和用xib技术实现.用xib技术实现相对比较简单,创 ...

  6. iOS开发学无止境 - NSFileManager文件操作的十个小功能

    (配图的小故事还记得嘛) NSFileManager是一个单列类,也是一个文件管理器.可以通过NSFileManager创建文件夹.创建文件.写文件.读文件内容等等基本功能. 下面将介绍NSFileM ...

  7. iOS开发学无止境 - 异步图片加载优化与常用开源库分析

    作者:罗轩(@luoyibu) 网址:http://www.jianshu.com/p/3b2c95e1404f 1. 网络图片显示大体步骤:   下载图片 图片处理(裁剪,边框等) 写入磁盘 从磁盘 ...

  8. iOS开发使用UIKeyInput自定义密码输入框

    前言 开发中很多地方都会遇到密码输入,这时候往往需要根据UI设计自定义.这里遵守UIKeyInput,实现协议中的方法,让自定义View可以进行文字输入:再通过func draw(_ rect: CG ...

  9. iOS 开发 解决UICollectionView的多组头部视图样式不一样复用时发生错乱问题

    UICollectionView用起来比UITableView麻烦多了,如何解决多组头部视图复用时出现的错乱问题就很关键 头部视图有几种样式就注册几种头部视图 // 防止cell和头部视图复用出现错乱 ...

最新文章

  1. pandas使用sum函数计算dataframe单数据列的加和或者对所有的数据列进行求和(sum column or all columns of dataframe)
  2. 报错解决:InvalidArgumentError: Received a label value of 101 which is outside the valid range of [0, 101
  3. [Swift]LeetCode373. 查找和最小的K对数字 | Find K Pairs with Smallest Sums
  4. pythonweb开发-如何用Python做Web开发?——Django环境配置
  5. mysql删除表命令_MySQL创建和删除表操作命令实例讲解
  6. HDU 1564 简单博弈 水
  7. Dockerfile 布局的良好实践
  8. (Spinner) android中Spinner的使用
  9. webflow如何使用_我如何使用Webflow构建辅助项目以帮助设计人员进行连接
  10. 发现很多人的基础都不好
  11. PhpStorm 配置debug断点调试
  12. html5 viewpor,FileViewPro
  13. Android Java类编写规范+优化建议
  14. 三维重建——相机几何参数标定
  15. jdk StringBuilder实现
  16. go并发编程之美(二)、go内存模型
  17. 数学家刘徽李善兰陈景润华罗庚
  18. 35款提升工作效率的工具软件
  19. Android-透明半透明效果
  20. 如何使用gdb调试java虚拟机_Eclispe+qemu+gdb调试linux Kernel

热门文章

  1. .Net开发时有没有好的页面开发框架?
  2. R语言文件下载:谁来帮我把这个128个音频下载一下
  3. 乒乓球比赛赛程_丁宁休战,刘诗雯做手术!李隼、秦志戬做介绍,国乒最新赛程曝光...
  4. yolo python_YOLO目标检测快速上手
  5. jsp注册里密码强弱怎么弄_jsp+servlet实战酷炫博客+聊天系统
  6. python django报错 no such column:
  7. java 存取xml数据_JAVA读取XML文件数据
  8. html如何呈现在显示器,lcd显示器采用什么显示方式
  9. 10远程连接连接不上华为云_从云手机到云游戏,5G会在多大程度上改变我们的生活?...
  10. tensorflow 无法执行sess =tf .session ()_深度学习|费解的tensorflow