UICollectionViewLayout适用于不同一半的披萨

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



样机


当我们完成从一半制作比萨饼界面的任务时,我们有些困惑。 我想要精美,清晰,方便,大型,互动和更多。 我想做酷。


设计师尝试了不同的方法:一叠比萨饼,水平和垂直纸牌,但将其固定在一半上。 我们不知道如何获得这样的结果,因此我们从一个实验开始,花了两个星期进行原型设计。 即使是粗略的布局也能取悦所有人。 反应记录在视频上:



UICollectionView如何工作


UICollectionViewUIScrollView的子类,它是常规的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 { // MARK: - Calculation func recalculateDefaultFrames(numberOfItems: Int) { defaultFrames = (0..<numberOfItems).map { defaultCellFrame(atRow: $0) } } func defaultCellFrame(atRow row: Int) -> CGRect { let y = itemSize.height * CGFloat(row) let defaultFrame = CGRect(x: 0, y: y, width: collectionWidth, height: itemSize.height) return defaultFrame } // MARK: - Access func visibleRows(in frame: CGRect) -> [Int] { return defaultFrames .enumerated() // Index to frame relation .filter { $0.element.intersects(frame)} // Filter by frame .map { $0.offset } // Return indexes } var contentSize: CGSize { return CGSize(width: collectionWidth, height: defaultFrames.last?.maxY ?? 0) } static var zero: TableLayoutCache { return TableLayoutCache(itemSize: .zero, collectionWidth: 0) } init(itemSize: CGSize, collectionWidth: CGFloat) { self.itemSize = itemSize self.collectionWidth = collectionWidth } private let itemSize: CGSize private let collectionWidth: CGFloat private var defaultFrames = [CGRect]() } 

然后,在布局类中,您只需要传递来自缓存的参数。


  1. prepare方法调用所有帧的计算。
  2. layoutAttributesForElements(在:中)将过滤框架。 如果框架与可见区域相交,则需要显示该单元:计算所有属性并将其返回到可见单元阵列。
  3. 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 // Stop scroll, we've animated manually } return projectedOffset } /// A bit of magic. Without that, high velocity moves cells backward very fast. /// We slow down the animation private func animateBackwardScroll(to pizzaIndex: Int) { let path = IndexPath(row: pizzaIndex, section: 0) collectionView?.scrollToItem(at: path, at: .centeredVertically, animated: true) // More magic here. Fix double-step animation. // You can't do it smoothly without that. DispatchQueue.main.async { self.collectionView?.scrollToItem(at: path, at: .centeredVertically, animated: true) } } 

问题消失了,现在比萨饼顺利返回:



增加中央比萨


我们在移动时重新叙述布局


有必要使中央比萨随着其接近中心而逐渐增加。 为此,您不仅需要在开始时计算一次参数,而无需在偏移处每次计算参数。 只需打开:


  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true } 

现在,使用每个偏移量,都会调用preparelayoutAttributesForElements(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 // 200 pt let centerOffset = offsetFromScreenCenter(frame) let relativeOffset = centerOffset / criticalOffset return relativeOffset } func offsetFromScreenCenter(_ frame: CGRect) -> CGFloat { return frame.midY - collectionView!.screenCenterYOffset() } } extension CGFloat { var normalized: CGFloat { return CGFloat.minimum(1, CGFloat.maximum(-1, self)) } } 

尺码


具有标准化的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 ,每个都有自己的任务。 这次结果是这样的:



  1. 主控制器:所有零件都组装在其中,并按下“混合”按钮。
  2. 控制器带有两个半容器,一个中央签名和滚动指示器。
  3. 带有收集器的控制器(右侧白色)。
  4. 底面板的价格。

为了区分左右一半,我开始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: // Align to right return element.frame.offsetBy(dx: +hOffset - spaceBetweenHalves / 2, dy: 0) case .right: // Align to left return element.frame.offsetBy(dx: -hOffset + spaceBetweenHalves / 2, dy: 0) } } private func horizontalOffset(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGFloat { let collectionWidth = collectionView!.bounds.width let scaledElementWidth = element.frame.width * scale let hOffset = (collectionWidth - scaledElementWidth) / 2 return hOffset } 

两半之间的距离立即得到控制。



像元偏移


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



您可以重写框架的计算方法:将宽度减半,将框架的每半部分与中心对齐。 为简单起见,只需更改单元格内图片的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: // Align to right return element.frame.offsetBy(dx: +hOffset - spaceBetweenHalves / 2, dy: vOffset) case .right: // Align to left return element.frame.offsetBy(dx: -hOffset + spaceBetweenHalves / 2, dy: vOffset) } } 

单元格设置是这样的:


  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个常规步骤:


  1. 我画了几个州。
  2. 我了解了元素与屏幕位置之间的关系(元素相对于屏幕中心移动)。
  3. 创建方便使用的变量(屏幕中心,比萨饼中心,缩放比例)。
  4. 我提出了简单的步骤,每个步骤都可以验证。

状态和动画很容易在Keynote中绘制。 我采用了标准布局,并绘制了前两个步骤:



视频结果如下:



进行了三处更改:


  1. 我们将使用centerPizzaFrame代替缓存中的帧。
  2. 使用scale从该帧读取偏移量。
  3. 重新计算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: // Align to right return centerPizzaFrame.offsetBy(dx: hOffset - spaceBetweenHalves / 2, dy: vOffset) case .right: // Align to left return centerPizzaFrame.offsetBy(dx: -hOffset + spaceBetweenHalves / 2, dy: vOffset) } } private func horizontalOffset(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGFloat { let collectionWidth = self.collectionView!.bounds.width let scaledElementWidth = centerPizzaFrame.width * scale let hOffset = (collectionWidth - scaledElementWidth) / 2 return hOffset } private func verticalOffset(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGFloat { let totalProgress = self.scale(for: element.frame).normalized(by: 1) let criticalOffset = PizzaHalfSelectorLayout.criticalOffsetFromCenter * 1.1 return totalProgress * criticalOffset } 


单元格重叠后,您需要使用设置正确的顺序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//scale cell.frame = self.centerAlignedFrame(for: cell, scale: scale) cell.transform = CGAffineTransform(scaleX: scale, y: scale) cell.zIndex = self.zIndex(row: cell.indexPath.row) } } private func zIndex(row: Int) -> Int { let numberOfCells = self.cache.defaultFrames.count if row == self.currentPizzaIndexInt { return numberOfCells } else if row < self.currentPizzaIndexInt { return row } else { return numberOfCells - row - 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


, .

Source: https://habr.com/ru/post/zh-CN452876/


All Articles