Хабрахабр

UICollectionViewLayout для пиццы из разных половинок

Рассказываю о том, как мы написали такой лейаут для iOS, с чем столкнулись и от чего отказались. Чтобы сделать пиццу из половинок мы использовали два UICollectionViewLayout.

Прототип

Хочется и красиво, и наглядно, и удобно, и крупно, и интерактивно и много как ещё. Когда к нам попала задача сделать интерфейс для пиццы из половинок, мы немного растерялись. Хочется сделать круто. 

Как достичь такого результата мы не знали, поэтому начали с эксперимента и взяли две недели на прототип. Дизайнеры пробовали разные подходы: сетку из пицц, горизонтальные и вертикальный карточки, но остановились на свайпах половинок. Реакцию записывали на видео: Даже сырой макет смог порадовать каждого.

Как работает UICollectionView

Перемещая его .origin, мы смещаем видимую зону, а меняя .size влияем на масштаб. UICollectionView — это сабкласс от UIScrollView, а он — это обычный UIView, у которого от свайпа меняется bounds.

С ним мы и будем работать. При смещении экрана UICollectionView создаёт (или повторно использует) ячейки, а правила их отображения описаны в UICollectionViewLayout.

Например, iCarousel умеет вот так: Возможности у UICollectionViewLayout большие, можно задать любое отношение между ячейками.

Первый подход

Смена взгляда на перемещение экрана помогла мне проще понять устройство лейаута. 
Мы привыкли, что ячейки перемещаются по экрану (зелёный прямоугольник — это экран телефона):

Деревья неподвижные, это поезд едет: Но всё наоборот, это экран перемещается относительно ячеек.

Origin этого bounds — известный нам contentOffset. На примере фреймы ячеек не меняются, а изменяется bounds самого коллекшена.

Для создания лейаута надо пройти два этапа: 

  • просчитать размеры всех ячеек
  • показать на экране только видимые.

Простой лейаут как в UITableView

Вместо них используются UICollectionViewLayoutAttributes — это набор параметров, которые будут применены к ячейке. Лейаут не работает с ячейками напрямую. Другие параметры: прозрачность, смещение, положение в глубине экрана и т.д. Frame — основной из них, отвечает за положение и размер ячейки.

Для начала напишем простой UICollectionViewLayout, который повторяет поведение UITableView: ячейки занимают всю ширину, идут одна за другой.

Впереди 4 шага:

  • Рассчитать frame для всех ячеек в методе prepare.
  • Вернуть видимые ячейки в методе layoutAttributesForElements(in:).
  • Вернуть параметры ячейки по её индексу в методе layoutAttributesForItem(at:). Например, этот метод используется при вызове у коллекшена метода scrollToItem(at:). 
  • Вернуть размеры получившегося контента в collectionViewContentSize. Так коллекшен узнает, где граница, до которой можно скролить. 

На большинстве устройств размер пиццы будет 300 точек, тогда координаты и размеры всех ячеек:

Он состоит из двух частей: просчитывает все фреймы в конструкторе, а потом лишь даёт доступ к готовым результатам: Расчёты я вынес в отдельный класс.

class TableLayoutCache } 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(in:) отфильтрует фреймы. Если фрейм пересекается с видимой областью, то значит ячейку нужно отобразить: рассчитать все атрибуты и вернуть её в массиве видимых ячеек. 
  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. С табличным представлением разобрались, но теперь нам нужно сделать динамичный лейаут. Все изменения будем делать в подклассе 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 }

Нам больше подойдёт .fast: У UIScrollView есть две скрости прокрутки: .normal и .fast.

collectionView!.decelerationRate = .fast

Метода для изменения скорости нет, поэтому обратный отскок хоть и на маленькое расстояние, но с очень большой скоростью: Но есть одна проблема: если мы проскролили совсем чуть-чуть, то нужно остаться на пицце, а не перескакивать на следующую.

Осторожно, хак!

Затем, мы сами скролим до прежнего места с помощью стандартного scrollToItem. Если ячейка не меняется, то мы возвращаем текущий contentOffset, так скрол остановится. Увы, скролить придётся ещё и асинхронно, чтобы код вызывался уже после 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 }

Так мы сможем обновлять UICollectionViewLayoutAttributes много раз подряд, плавно меняя положение и прозрачность. Теперь при каждом смещении будут вызываться методы prepare и layoutAttributesForElements(in:).

Трансформируем ячейки

Теперь будем менять их в зависимости от положения относительно центра экрана. В табличном лейауте ячейки лежали друг под другом и их координаты считались один раз. Добавим метод, который будет изменять их на лету.

В методе 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 при движении. Положение ячейки относительно центра экрана удобно представить в нормализованном виде. Я назвал этот параметр scale: Если значения стали дальше от ноля чем 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)) }
}

Размер

Изменения от -1 до +1 слишком сильные, для размера их нужно преобразовать. Имея нормализованный scale, можно делать что угодно. 6 от размера центральной пиццы: Например, мы хотим, чтобы размер уменьшался максимум до 0.

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) } }

У центральной ячейки normScale = 0, её размер не меняется: .transform изменяет размер относительно центра ячеек.

Прозрачность

Подойдёт тоже значение scale, которое мы использовали в transform. Прозрачность можно поменять через параметр alpha.

cell.alpha = scale

Уже не так скучно, как в обычных таблицах. Теперь пицца меняет размер и прозрачность.

Делим пополам

Теперь нужно поделить пополам. До этого мы работали с одной пиццей: задали систему отсчёта от центра, изменили размер и прозрачность.

Проще сделать два коллекшена, в каждом свой лейаут. Использовать один коллекшен для этого слишком сложно: нужно будет писать свой обработчик жеста для каждой половины. Только теперь вместо целой пиццы будут половинки.

Два контроллера, один контейнер

В этот раз получилось так: Почти всегда я разбиваю один экран на несколько UIViewController, каждый со своей задачей.

  1. Основной контроллер: в нём собираются все части и кнопка «перемешать».
  2. Контроллер с двумя контейнерами для половинок, центральной подписью и скрол индикаторами.
  3. Контроллер с коллекшеном (правый белый).
  4. Нижняя панель с ценой.

Чтобы различать левую и правую половинку, я завёл 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 обычных шага:

  1. Нарисовал пару состояний.
  2. Понял, как элементы связаны с положением экрана (элементы двигаются относительно центра экрана).
  3. Создал переменные, с которыми удобно работать (центр экрана, фрейм центральной пиццы, scale).
  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, а заказать пиццу из половинок в приложении.

А если вам интересны события поменьше, то подписывайтесь на канал в телеграмме.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть