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

в 15:39, , рубрики: dodomobile, iOS, uicollectionviewlayout, uikit, Блог компании Dodo Pizza Engineering, разработка под iOS

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

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

Прототип

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

collectionView!.decelerationRate = .fast

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

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

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

Если ячейка не меняется, то мы возвращаем текущий 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)
        }
    }

Проблема ушла, теперь пицца возвращается плавно:

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

Увеличиваем центральную пиццу

Пересчитываем лейаут при движении

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

    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:

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

Нужно посчитать разницу между центром фрейма и центром экрана. Разделив разницу на константу, мы нормализуем значение, а 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, её размер не меняется:

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

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

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

    cell.alpha = scale

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

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

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

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

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

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

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

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

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

Смещаем половинки к центру

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

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

Исправить просто: нужно горизонтально сместить ячейки наполовину к центру экрана:

    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
    }

Тут же контролируется расстояние между половинками.

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

Смещение внутри ячейки

Круглую пиццу легко было вписать в квадрат, а для половинки нужно пол квадрата:

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

Можно переписать расчёт фреймов: уменьшить ширину вдвое, выровнять фреймы к центру по-разному для каждой половины. Для простоты всего лишь поменяем contentMode картинки уже внутри ячейки:

class PizzaHalfCell: UICollectionViewCell {
    var halfOrientation: PizzaHalfOrientation = .left {
        didSet {
            imageView?.contentMode = halfOrientation == .left ? .topRight : .topLeft
            self.setNeedsLayout()
        }
    }
}

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

Прижимаем пиццы по вертикали

Пиццы уменьшились, но расстояние между их центрами не изменилось, появились большие разрывы. Компенсировать их можно так же, как мы выровняли половинки по центру.

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

Не перепутай: настройка фрейма должна быть до трансформа. Если поменять порядок, то результат вычислений будет совсем другой.

Готово! Мы разрезали половинки и выровняли их к центру:

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

Добавляем подписи

Хедеры создаются так же как и ячейки, только вместо 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:

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

В коде всё просто: получаем положение подписи на экране и смещаем его на 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)
        }
    }

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

Оптимизируем

После добавления заголовков производительность сильно просела. Так получилось, потому что мы скрыли подписи, но всё равно возвращаем их UICollectionViewLayoutAttributes. От этого хедеры добавляются в иерархию, участвуют в лейауте, но не отображаются. Ячейки мы показывали только те, которые пересекаются с текущим bounds, а хедеры нужно фильтровать по alpha:

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        …

        let visibleHeaders = headers.filter { $0.alpha > 0 }

        return cells + visibleHeaders
    }

Согласовываем с центральной подписью (оригинальный рецепт)

Мы проделали большую работу, но в интерфейсе нашлось противоречие — если выбрать две одинаковые половинки, то они превращаются в обычную пиццу.

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

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

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

При скроле мы передаём текущий индекс в контроллер, он отправляет индекс в противоположную половинку. Если индексы совпадают, то он показывает заголовок оригинальной пиццы, а если разные, то видны подписи для каждой половинки.

Как придумывать свои лейауты

Самым сложным было понять, как переложить свою идею на вычисления. Например, я хочу, чтобы пиццы скролились как барабан. Для понимания задачи я прошёл 4 обычных шага:

  1. Нарисовал пару состояний.
  2. Понял, как элементы связаны с положением экрана (элементы двигаются относительно центра экрана).
  3. Создал переменные, с которыми удобно работать (центр экрана, фрейм центральной пиццы, scale).
  4. Придумал простые шаги, каждый из которых можно проверить.

Состояния и анимации легко рисовать в Keynote. Я взял стандартную раскладку и нарисовал два первых шага:

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

На видео получается так:

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

Понадобилось три изменения: 

  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, а заказать пиццу из половинок в приложении.

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

Автор: akaDuality

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js