Чтобы сделать пиццу из половинок мы использовали два UICollectionViewLayout
. Рассказываю о том, как мы написали такой лейаут для iOS, с чем столкнулись и от чего отказались.
Прототип
Когда к нам попала задача сделать интерфейс для пиццы из половинок, мы немного растерялись. Хочется и красиво, и наглядно, и удобно, и крупно, и интерактивно и много как ещё. Хочется сделать круто.
Дизайнеры пробовали разные подходы: сетку из пицц, горизонтальные и вертикальный карточки, но остановились на свайпах половинок. Как достичь такого результата мы не знали, поэтому начали с эксперимента и взяли две недели на прототип. Даже сырой макет смог порадовать каждого. Реакцию записывали на видео:
Как работает UICollectionView
UICollectionView
— это сабкласс от UIScrollView
, а он — это обычный UIView, у которого от свайпа меняется bounds
. Перемещая его .origin
, мы смещаем видимую зону, а меняя .size
влияем на масштаб.
При смещении экрана UICollectionView
создаёт (или повторно использует) ячейки, а правила их отображения описаны в UICollectionViewLayout
. С ним мы и будем работать.
Возможности у UICollectionViewLayout
большие, можно задать любое отношение между ячейками. Например, iCarousel умеет вот так:
Первый подход
Смена взгляда на перемещение экрана помогла мне проще понять устройство лейаута.
Мы привыкли, что ячейки перемещаются по экрану (зелёный прямоугольник — это экран телефона):
Но всё наоборот, это экран перемещается относительно ячеек. Деревья неподвижные, это поезд едет:
На примере фреймы ячеек не меняются, а изменяется bounds
самого коллекшена. Origin
этого bounds
— известный нам contentOffset
.
Для создания лейаута надо пройти два этапа:
- просчитать размеры всех ячеек
- показать на экране только видимые.
Простой лейаут как в UITableView
Лейаут не работает с ячейками напрямую. Вместо них используются UICollectionViewLayoutAttributes
— это набор параметров, которые будут применены к ячейке. Frame
— основной из них, отвечает за положение и размер ячейки. Другие параметры: прозрачность, смещение, положение в глубине экрана и т.д.
Для начала напишем простой UICollectionViewLayout
, который повторяет поведение UITableView
: ячейки занимают всю ширину, идут одна за другой.
Впереди 4 шага:
- Рассчитать
frame
для всех ячеек в методеprepare
. - Вернуть видимые ячейки в методе
layoutAttributesForElements(in:)
. - Вернуть параметры ячейки по её индексу в методе
layoutAttributesForItem(at:)
. Например, этот метод используется при вызове у коллекшена метода scrollToItem(at:). - Вернуть размеры получившегося контента в
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]()
}
Тогда в классе лейаута нужно только передать параметры из кэша.
- Метод
prepare
вызывает расчёт всех фреймов. - layoutAttributesForElements(in:) отфильтрует фреймы. Если фрейм пересекается с видимой областью, то значит ячейку нужно отобразить: рассчитать все атрибуты и вернуть её в массиве видимых ячеек.
- 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
. Все изменения будем делать в подклассе 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:)
спрашивает, где остановиться, если с текущей скоростью он собирался остановиться в proposedContentOffset
.
Расчёт простой: посмотреть в какую пиццу попадёт 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
:
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
}
Теперь при каждом смещении будут вызываться методы prepare
и layoutAttributesForElements(in:)
. Так мы сможем обновлять UICollectionViewLayoutAttributes
много раз подряд, плавно меняя положение и прозрачность.
Трансформируем ячейки
В табличном лейауте ячейки лежали друг под другом и их координаты считались один раз. Теперь будем менять их в зависимости от положения относительно центра экрана. Добавим метод, который будет изменять их на лету.
В методе 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, то это значит, что ячейка больше не центральная и перестала меняться. Я назвал этот параметр scale:
Нужно посчитать разницу между центром фрейма и центром экрана. Разделив разницу на константу, мы нормализуем значение, а 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
. Подойдёт тоже значение scale
, которое мы использовали в transform
.
cell.alpha = scale
Теперь пицца меняет размер и прозрачность. Уже не так скучно, как в обычных таблицах.
Делим пополам
До этого мы работали с одной пиццей: задали систему отсчёта от центра, изменили размер и прозрачность. Теперь нужно поделить пополам.
Использовать один коллекшен для этого слишком сложно: нужно будет писать свой обработчик жеста для каждой половины. Проще сделать два коллекшена, в каждом свой лейаут. Только теперь вместо целой пиццы будут половинки.
Два контроллера, один контейнер
Почти всегда я разбиваю один экран на несколько UIViewController
, каждый со своей задачей. В этот раз получилось так:
- Основной контроллер: в нём собираются все части и кнопка «перемешать».
- Контроллер с двумя контейнерами для половинок, центральной подписью и скрол индикаторами.
- Контроллер с коллекшеном (правый белый).
- Нижняя панель с ценой.
Чтобы различать левую и правую половинку, я завёл enum
, он хранится в лейауте в проперти .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(forCellWith:)
нужно использовать конструктор UICollectionViewLayoutAttributes(forSupplementaryViewOfKind:)
и вернуть их вместе с параметрами ячеек в layoutAttributesForElements(in:)
Сначала опишем метод для получения хедера по 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
видимых ячеек и показать подписи для них:
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
Сейчас ячейки и подписи находятся на одном уровне «высоты», поэтому могут наслаиваться друг на друга. Чтобы заголовки всегда были выше, поставьте им 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 обычных шага:
- Нарисовал пару состояний.
- Понял, как элементы связаны с положением экрана (элементы двигаются относительно центра экрана).
- Создал переменные, с которыми удобно работать (центр экрана, фрейм центральной пиццы, scale).
- Придумал простые шаги, каждый из которых можно проверить.
Состояния и анимации легко рисовать в 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: // 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, а заказать пиццу из половинок в приложении.
А если вам интересны события поменьше, то подписывайтесь на канал в телеграмме.