为了将比萨饼减半,我们使用了两个UICollectionViewLayout
。 我说的是我们如何为iOS编写这样的布局,遇到和拒绝的内容。

样机
当我们完成从一半制作比萨饼界面的任务时,我们有些困惑。 我想要精美,清晰,方便,大型,互动和更多。 我想做酷。
设计师尝试了不同的方法:一叠比萨饼,水平和垂直纸牌,但将其固定在一半上。 我们不知道如何获得这样的结果,因此我们从一个实验开始,花了两个星期进行原型设计。 即使是粗略的布局也能取悦所有人。 反应记录在视频上:
UICollectionView如何工作
UICollectionView
是UIScrollView
的子类,它是常规的UIView,其bounds
从轻扫即可更改。 将其移动.origin
,我们将移动可见区域,并且更改.size
会影响比例。
当屏幕UICollectionView
创建(或重用)单元格,并且在UICollectionViewLayout
中描述了显示它们的规则。 我们将与他合作。
UICollectionViewLayout
的可能性很大,您可以指定单元格之间的任何关系。 例如,您可以执行与iCarousel可以执行的非常相似的操作 :

第一种方法
改变移动屏幕的外观使我更容易理解布局的布局。
我们已经习惯了单元格在屏幕周围移动的事实(绿色矩形是电话的屏幕):

但是反之亦然,此屏幕相对于单元格移动。 树木静止不动,这列火车行驶:

在此示例中,单元格框架不变,但集合本身的bounds
却变化。 此bounds
Origin
是我们已知的contentOffset
。
要创建布局,您需要经历两个阶段:
与UITableView中一样的简单布局
该布局不能直接与单元格一起使用。 而是使用UICollectionViewLayoutAttributes
这是将应用于单元格的参数集。 Frame
-主要的Frame
,负责单元的位置和大小。 其他参数:透明度,偏移量,屏幕深度的位置等。

首先, UICollectionViewLayout
编写一个简单的UICollectionViewLayout
,它重复UITableView
的行为:单元占据整个宽度,一个接一个地移动。
前进4个步骤:
- 在
prepare
方法中计算所有单元格的frame
。 - 在
layoutAttributesForElements(in:)
方法中返回可见的单元格。 - 通过在
layoutAttributesForItem(at:)
方法中的索引返回单元格参数。 例如,在调用集合方法scrollToItem(:)时使用此方法。 - 在
collectionViewContentSize
返回结果内容的尺寸。 因此,收集器将找出可以滚动到的边框。
在大多数设备上,比萨的大小为300点,然后是所有单元格的坐标和大小:

我在单独的课程中进行了计算。 它由两部分组成:它计算构造函数中的所有框架,然后仅提供对完成结果的访问:
class TableLayoutCache {
然后,在布局类中,您只需要传递来自缓存的参数。
prepare
方法调用所有帧的计算。- layoutAttributesForElements(在:中)将过滤框架。 如果框架与可见区域相交,则需要显示该单元:计算所有属性并将其返回到可见单元阵列。
- layoutAttributesForItem(at :)-计算单个单元格的属性。
class TableLayout: UICollectionViewLayout { override var collectionViewContentSize: CGSize { return cache.contentSize } override func prepare() { super.prepare() let numberOfItems = collectionView!.numberOfItems(inSection: section) cache = TableLayoutCache(itemSize: itemSize, collectionWidth: collectionView!.bounds.width) cache.recalculateDefaultFrames(numberOfItems: numberOfItems) } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { let indexes = cache.visibleRows(in: rect) let cells = indexes.map { (row) -> UICollectionViewLayoutAttributes? in let path = IndexPath(row: row, section: section) let attributes = layoutAttributesForItem(at: path) return attributes }.compactMap { $0 } return cells } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) attributes.frame = cache.defaultCellFrame(atRow: indexPath.row) return attributes } var itemSize: CGSize = .zero { didSet { invalidateLayout() } } private let section = 0 var cache = TableLayoutCache.zero }
我们根据您的需求进行更改
我们已经整理了表格视图,但是现在我们需要进行动态布局。 每次移动手指时,我们将重新计算单元格的属性:获取已经计数的帧,然后使用.transform
对其进行.transform
。 所有更改都将在PizzaHalfSelectorLayout
的子类中PizzaHalfSelectorLayout
。
我们阅读了当前的比萨索引
为了方便起见,您可以contentOffset
并将其替换为当前比萨饼的编号。 然后,您将不再需要考虑坐标,所有决定都将围绕比萨饼编号及其从屏幕中心的位移程度进行。
需要两种方法:一种将contentOffset
转换为比萨号码,反之亦然:
extension PizzaHalfSelectorLayout { func contentOffset(for pizzaIndex: Int) -> CGPoint { let cellHeight = itemSize.height let screenHalf = collectionView!.bounds.height / 2 let midY = cellHeight * CGFloat(pizzaIndex) + cellHeight / 2 let newY = midY - screenHalf return CGPoint(x: 0, y: newY) } func pizzaIndex(offset: CGPoint) -> CGFloat { let cellHeight = itemSize.height let proposedCenterY = collectionView!.screenCenterYOffset(for: offset) let pizzaIndex = proposedCenterY / cellHeight return pizzaIndex } }
屏幕中心的contentOffset
计算在extension
呈现:
extension UIScrollView { func screenCenterYOffset(for offset: CGPoint? = nil) -> CGFloat { let offsetY = offset?.y ?? contentOffset.y let contentOffsetY = offsetY + bounds.height / 2 return contentOffsetY } }
我们停在中心的披萨
我们需要做的第一件事是将比萨饼停在屏幕中央。 targetContentOffset(forProposedContentOffset:)
方法询问如果以当前速度将其在targetContentOffset(forProposedContentOffset:)
处停止,则在哪里停止。
计算很简单:查看proposedContentOffset
将落入哪个披萨并滚动,使其位于中间:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { let pizzaIndex = Int(self.pizzaIndex(offset: proposedContentOffset)) let projectedOffset = contentOffset(for: pizzaIndex) return projectedOffset }
UIScrollView
具有两种滚动.normal
: .fast
和.fast
。 .fast
更适合.fast
:
collectionView!.decelerationRate = .fast
但是有一个问题:如果我们只滚动一点,那么我们需要留在披萨上,而不是跳到下一个。 没有更改速度的方法,因此反向反弹(虽然距离很小但速度非常快):

小心,骇客!
如果单元格未更改,则返回当前的contentOffset
,因此滚动停止。 然后,我们自己将使用标准scrollToItem
滚动到先前的位置。 ,您还必须异步滚动,以便在return
之后调用代码,然后在动画过程中几乎不会出现褪色。
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { let pizzaIndex = Int(self.pizzaIndex(offset: proposedContentOffset)) let projectedOffset = contentOffset(for: pizzaIndex) let sameCell = pizzaIndex == currentPizzaIndexInt if sameCell { animateBackwardScroll(to: pizzaIndex) return collectionView!.contentOffset
问题消失了,现在比萨饼顺利返回:

增加中央比萨
我们在移动时重新叙述布局
有必要使中央比萨随着其接近中心而逐渐增加。 为此,您不仅需要在开始时计算一次参数,而无需在偏移处每次计算参数。 只需打开:
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
现在,使用每个偏移量,都会调用prepare
和layoutAttributesForElements(in:)
方法。 因此,我们可以UICollectionViewLayoutAttributes
多次更新UICollectionViewLayoutAttributes
,以平滑地更改位置和透明度。
在表格布局中,单元格彼此位于下方,并且对它们的坐标计数一次。 现在,我们将根据相对于屏幕中心的位置进行更改。 添加一种可以随时更改它们的方法。
在layoutAttributesForElements
方法中, layoutAttributesForElements
需要从超类获取属性,过滤掉单元格的属性,然后将它们传递给updateCells
方法:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let elements = super.layoutAttributesForElements(in: rect) else { return nil } let cells = elements.filter { $0.representedElementCategory == .cell } self.updateCells(cells) }
现在,我们将在一个函数中更改单元格的属性:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes])
在运动过程中,我们需要更改透明度,大小并保持比萨饼沿中心。
单元相对于屏幕中心的位置可以方便地以标准化形式显示。 如果单元格在中间,则参数为0;如果移动了单元格,则参数从上移时的-1变为移动时的1。 如果值距离零比1 / -1远,那么这意味着该单元格不再位于中心并且已停止更改。 我称这个比例参数:

您需要计算框架中心与屏幕中心之间的差异。 将差除以常数,我们将值归一化,并且min和max将导致范围为-1到+1:
extension PizzaHalfSelectorLayout { func scale(for row: Int) -> CGFloat { let frame = cache.defaultCellFrame(atRow: row) let scale = self.scale(for: frame) return scale.normalized } func scale(for frame: CGRect) -> CGFloat { let criticalOffset = PizzaHalfSelectorLayout.criticalOffsetFromCenter
尺码
具有标准化的scale
,您可以执行任何操作。 从-1到+1的变化太大,需要对其进行大小转换。 例如,我们希望将尺寸减小到最大中央披萨尺寸的0.6:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.transform = CGAffineTransform(scaleX: scale, y: scale) } }
.transform
相对于单元中心调整大小。 中央单元格的normScale = 0,其大小不变:

透明性
可以通过alpha
参数更改透明度。 我们在transform
使用的scale
值也是合适的。
cell.alpha = scale
现在,比萨饼会更改其大小和透明度。 已经不像常规表那样无聊了。

平分
在此之前,我们使用一个比萨饼:我们从中心设置参考系统,更改大小和透明度。 现在您需要分成两半。
为此使用一个集合太困难了:您将需要为每个一半编写自己的手势处理程序。 制作两个具有各自布局的收藏集更加容易。 只是现在,不是整个比萨饼,而是一半。
两个控制器,一个容器
几乎总是,我将一个屏幕分成几个UIViewController
,每个都有自己的任务。 这次结果是这样的:

- 主控制器:所有零件都组装在其中,并按下“混合”按钮。
- 控制器带有两个半容器,一个中央签名和滚动指示器。
- 带有收集器的控制器(右侧白色)。
- 底面板的价格。
为了区分左右一半,我开始enum
,它存储在布局中的.orientation .orientation
:
enum PizzaHalfOrientation { case left case right func opposite() -> PizzaHalfOrientation { switch self { case .left: return .right case .right: return .left } } }
我们将两半移到中间
先前的布局停止了我们期望的工作:两半开始转移到其集合的中心,而不是屏幕的中心:

修复很简单:您需要将单元格水平移动到屏幕中心的一半:
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = horizontalOffset(for: element, scale: scale) switch orientation { case .left:
两半之间的距离立即得到控制。

像元偏移
将圆形披萨放入一个正方形很容易,而一半需要一个正方形:

您可以重写框架的计算方法:将宽度减半,将框架的每半部分与中心对齐。 为简单起见,只需更改单元格内图片的contentMode
:
class PizzaHalfCell: UICollectionViewCell { var halfOrientation: PizzaHalfOrientation = .left { didSet { imageView?.contentMode = halfOrientation == .left ? .topRight : .topLeft self.setNeedsLayout() } } }

垂直按披萨
比萨饼减少了,但其中心之间的距离没有改变,出现了很大的空隙。 您可以按照将两半对准中心的相同方法来补偿它们。
private func verticalOffset(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGFloat { let offsetFromCenter = offsetFromScreenCenter(element.frame) let vOffset: CGFloat = PizzaHalfSelectorLayout.verticalOffset( offsetFromCenter: offsetFromCenter, scale: scale) return vOffset } static func verticalOffset(offsetFromCenter: CGFloat, scale: CGFloat) -> CGFloat { return -offsetFromCenter / 4 * scale }
结果,所有补偿如下所示:
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = horizontalOffset(for: element, scale: scale) let vOffset = verticalOffset (for: element, scale: scale) switch orientation { case .left:
单元格设置是这样的:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.alpha = scale cell.frame = centerAlignedFrame(for: cell, scale: scale) cell.transform = CGAffineTransform(scaleX: scale, y: scale) cell.zIndex = cellZLevel } }
不要混淆:帧设置应该在转换之前。 如果更改顺序,则计算结果将完全不同。
做完了! 我们将两半切开并对齐到中心:

添加字幕
标头的创建方式与单元格相同,只是需要使用构造函数UICollectionViewLayoutAttributes(forSupplementaryViewOfKind:)
并将它们与layoutAttributesForElements(in:)
的单元格参数一起layoutAttributesForElements(in:)
首先,我们描述一种通过IndexPath
获取标头的IndexPath
:
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { let attributes = UICollectionViewLayoutAttributes( forSupplementaryViewOfKind: elementKind, with: indexPath) attributes.frame = defaultFrameForHeader(at: indexPath) attributes.zIndex = headerZLevel return attributes }
框架计算隐藏在defaultFrameForHeader
方法中(稍后介绍)。
现在,您可以获取可见单元格的IndexPath
并显示它们的IndexPath
:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { … let visiblePaths = cells.map { $0.indexPath } let headers = self.headers(for: visiblePaths) updateHeaders(headers) return cells + headers }
headers(for:)
方法中隐藏了一个非常长的函数调用:
func headers(for paths: [IndexPath]) -> [UICollectionViewLayoutAttributes] { let headers: [UICollectionViewLayoutAttributes] = paths.map { layoutAttributesForSupplementaryView( ofKind: UICollectionView.elementKindSectionHeader, at: $0) }.compactMap { $0 } return headers }
索引
现在,单元和签名处于“高度”的同一级别,因此它们可以彼此层叠。 为了使标头始终较高,请给它们的zIndex
大于零。 例如100。
我们确定一个职位(实际上不是)
屏幕上固定的签名有些令人困惑。 您想要修复,但反之亦然,它会随着bounds
不断移动:

代码中的一切都很简单:我们获得了签名在屏幕上的位置,并将其移至contentOffset
:
func defaultFrameForHeader(at indexPath: IndexPath) -> CGRect { let inset = max(collectionView!.layoutMargins.left, collectionView!.layoutMargins.right) let y = collectionView!.bounds.minY let height = collectionView!.bounds.height let width = collectionView!.bounds.width let headerWidth = width - inset * 2 let headerHeight: CGFloat = 60 let vOffset: CGFloat = 30 let screenY = (height - itemSize.height) / 2 - headerHeight / 2 - vOffset return CGRect(x: inset, y: y + screenY, width: headerWidth, height: headerHeight) }
签名的高度可以不同,最好在委托中考虑(并在那里缓存)。
动画签名
一切都与细胞非常相似。 根据当前scale
,可以计算单元的透明度。 可以通过.transform
设置偏移量,因此铭文将相对于其框架移动:
func updateHeaders(_ headers: [UICollectionViewLayoutAttributes]) { for header in headers { let scale = self.scale(for: header.indexPath.row) let alpha = 1 - abs(scale) header.alpha = alpha let translation = 20 * scale header.transform = CGAffineTransform(translationX: 0, y: translation) } }

优化
添加标头后,性能将急剧下降。 发生这种情况是因为我们隐藏了签名,但仍然将它们返回给UICollectionViewLayoutAttributes
。 由此,标题将添加到层次结构中,参与布局,但不会显示。 我们只显示了与当前bounds
相交的像元,并且标头需要用alpha
过滤:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { … let visibleHeaders = headers.filter { $0.alpha > 0 } return cells + visibleHeaders }
我们同意中央签名(原始配方)
我们做得很好,但界面却有矛盾-如果您选择两个相同的两半,则它们会变成普通的披萨。
我们决定采用这种方式,但要正确处理条件,表明它是普通的披萨。 我们的新任务是在中心显示一个标签,用于相同的披萨,然后沿边缘隐藏。

单独的布局太难解决了,因为铭文是在两个收藏家的交界处。 如果包含两个集合的控制器协调所有签名的移动,将更加容易。
滚动时,我们将当前索引传递给控制器,控制器将索引发送给相对的另一半。 如果索引匹配,则显示原始披萨的标题,如果不同,则可见每个披萨的签名。
如何发明布局
最困难的部分是弄清楚如何将您的想法带入计算。 例如,我希望比萨饼像鼓一样滚动。 为了理解该问题,我执行了4个常规步骤:
- 我画了几个州。
- 我了解了元素与屏幕位置之间的关系(元素相对于屏幕中心移动)。
- 创建方便使用的变量(屏幕中心,比萨饼中心,缩放比例)。
- 我提出了简单的步骤,每个步骤都可以验证。
状态和动画很容易在Keynote中绘制。 我采用了标准布局,并绘制了前两个步骤:

视频结果如下:

进行了三处更改:
- 我们将使用
centerPizzaFrame
代替缓存中的帧。 - 使用
scale
从该帧读取偏移量。 重新计算zIndex
。
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = self.horizontalOffset(for: element, scale: scale) let vOffset = self.verticalOffset (for: element, scale: scale) switch self.pizzaHalf { case .left:
单元格重叠后,您需要使用设置正确的顺序zIndex
。这个想法是这样的:单元格越靠近中心披萨,它越靠近屏幕,并且越大zIndex
。
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = self.scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.alpha = 1
然后,如果第三个单元格是当前单元格,则结果如下所示:
row: zIndex` 0: 0 1: 1 2: 2 3: 10 — 4: 5 5: 4 6: 3 7: 2 8: 1 9: 0
我在生产中没有得到这样的布局,因此我需要具有透明背景的图片。
发布日期
创建这样的界面后,我们参与了一个小实验:第一次编写了这样一个不寻常的布局,后来又在卡片屏幕上添加了一个交互式过渡。实验获得了回报:半披萨的销量达到了顶峰,并位居第二,仅次于经典意大利辣香肠。
当然,生产需要做更多的工作:
- 将披萨图片切成两半,
- , : , ,
- ,
- ,
- -,
- «»,
- Voice Over.
:
github , .
UICollectionViewLayout
, A Tour of UICollectionView
, .