Как стать автором
Обновить

iOS. UI. Приёмы. Часть 2

Время на прочтение19 мин
Количество просмотров6.2K

Привет читателям хабра! В прошлой статье рассказывал про тени и маски у CALayer-ов. В этой расскажу про некоторые подходы при работе с коллекциями и кастомными layout-ами, опять же демонстрируя всё на довольно интересных, на мой взгляд, примерах.

Введение

Предполагаю, что читатель достаточно хорошо знаком с UICollectionView, т.е. понимает, куда и как прокидывать данные, как задавать layout, что вообще такое UICollectionViewLayout и т.п. На момент написания были некоторые сомнения об актуальности материала, но SwiftUI вроде бы ещё не занял доминирующую позицию, да и слышал от разработчиков, что на нём сильно кастомные компоненты проблематично создавать.

Все примеры взяты из жизни с незначительными модификациями. Во вставках с кодом умышленно допущены неточности для более удобной читаемости. Крайние случаи вроде всяких пограничных переходов позволил себе не рассматривать, чтобы не загромождать код в примерах. Если у вас будет что-то падать при попытке запуска примеров кода, попробуйте выставить estimatedItemSize в ноль.

Селектор в виде горизонтального списка

При скролле ближайший элемент автоматически выравнивается по центру
При скролле ближайший элемент автоматически выравнивается по центру

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

Прежде чем пускаться в детали, напомню вкратце про самые, пожалуй, важные методы, которые нужно переопределять при создании кастомного layout-а: в функции prepare мы создаём layout attributes (UICollectionViewLayoutAttributes), которые затем "скармливаем" функции layoutAttributesForElements(in:). И ещё важно переопределить collectionViewContentSize, чтобы всем ячейкам хватило места.

В случаях таких layout-ов, мне кажется, удобно задавать ширину ячейки как "ширина коллекции минус константное расстояние по бокам", а высоту уже брать как захочется, потому что она на scroll, можно сказать, не влияет. Т.е. в начале функции prepare можно написать что-то вроде:

itemSize = CGSize(
	width: collection.bounds.width - 2 * Constants.itemInset,
	height: Constants.itemHeight
)

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

attr.frame = CGRect(
	x: Constants.itemInset + i * (itemSize.width + Constants.distance),
  y: collection.bounds.height / 2 - Constants.itemHeight / 2,
  width: itemSize.width,
  height: itemSize.height
)

Внутри collectionContentSize будет что-то очень похожее. Самое интересное в этом примере - выравнивание item-ов по центру. В этом нам поможет функция targetContentOffset(forProposedContentOffset:withScrollingVelocity), которая переопределяется внутри layout. Её смысл примерно в следующем: после того как юзер проводит чем-нибудь по экрану и потом отпускает экран, коллекция продолжает проматывать содержимое по инерции, зная заранее, в какой точке она остановится. TargetContentOffset позволяет вмешаться в этот процесс и сказать collection, где ей остановиться. А это как раз то, что нам нужно. Т.е. эта функция работает так, что на вход подаётся contentOffset, в котором коллекция собирается "остановиться", а её возвращаемое значение - скорректированный contetOffset.

Итак, мы знаем, при каком contentOffset коллекция собирается перестать скроллить контент. В этот момент положение центра экрана относительно всего контента равно

let contentCenterX = contentOffset + collection.width / 2

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

При таком раскладе точка в центре экрана обязательно попадет в какой-то мнимый item, индекс которого легко вычисляется:

let imaginaryItemWidth = itemWidth + Constants.distance
let index = Int(contentCenterX / imaginaryItemWidth)

Однако это даёт не совсем точный результат из-за несоответствия расстояния между ячейками и ширины самих ячеек по отношению к ширине коллекции - мнимые элементы в нашем случае в сумме дают большую ширину, чем contentSize.width. Выражаться это будет в том, что в каких-то пограничных случаях (например, центр видимой области экрана попадает ровно посередине между item-ами) подскролливаться к центру будет не тот элемент, который мы ожидаем.

Исправить проблему просто - добавить (или отнять) недостающее расстояние в contentCenterX:

let diff = (Constants.distance / 2 - Constants.itemInset)
contentCenterX = proposedContentOffset.x + collection.width / 2 + diff

и после этого уже вычислять индекс элемента. В итоге в функции targetContentOffset нужно вернуть новый contentOffset в виде:

CGPoint(
	x: index * imaginaryItemWidth,
	y: proposedContentOffset.y
)
Полный код для layout-а из примера 1
import UIKit

final class SelectorSimpleLayout: UICollectionViewFlowLayout {
  
  private var attrs: [UICollectionViewLayoutAttributes] = []
  
  // MARK: Override
  
  override var collectionViewContentSize: CGSize {
    guard let collection = collectionView,
          collection.numberOfSections > 0 else { return .zero }
    let number = collection.numberOfItems(inSection: 0)
    let itemWidth = collection.bounds.width - 2 * Constants.itemInset
    let resultWidth = Constants.itemInset + CGFloat(number) * (Constants.distance + itemWidth)
      - Constants.distance
      + Constants.itemInset
    return CGSize(
      width: resultWidth,
      height: collection.bounds.height
    )
  }
  
  override func prepare() {
    super.prepare()
    
    attrs = []
    
    guard let collection = collectionView,
          collection.numberOfSections > 0 else { return }
    
    itemSize = CGSize(
      width: collection.bounds.width - 2 * Constants.itemInset,
      height: Constants.itemHeight
    )
    
    for i in 0..<collection.numberOfItems(inSection: 0) {
      let attr = UICollectionViewLayoutAttributes(forCellWith: IndexPath(row: i, section: 0))
      attr.frame = CGRect(
        x: Constants.itemInset + CGFloat(i) * (itemSize.width + Constants.distance),
        y: collection.bounds.height / 2 - Constants.itemHeight / 2,
        width: itemSize.width,
        height: itemSize.height
      )
      attrs.append(attr)
    }
  }
  
  override func layoutAttributesForElements(
    in rect: CGRect
  ) -> [UICollectionViewLayoutAttributes]? {
    attrs.filter { $0.frame.intersects(rect) }
  }
  
  override func layoutAttributesForItem(
    at indexPath: IndexPath
  ) -> UICollectionViewLayoutAttributes? {
    attrs[indexPath.row]
  }
  
  override func targetContentOffset(
    forProposedContentOffset proposedContentOffset: CGPoint,
    withScrollingVelocity velocity: CGPoint
  ) -> CGPoint {
    guard let collection = collectionView else { return proposedContentOffset }
    
    let diff = (Constants.distance / 2 - Constants.itemInset)
    let contentCenterX = proposedContentOffset.x + collection.bounds.width / 2 + diff
    let imaginaryItemWidth = itemSize.width + Constants.distance
    
    let index = Int(contentCenterX / imaginaryItemWidth)
    
    return CGPoint(
      x: CGFloat(index) * imaginaryItemWidth,
      y: proposedContentOffset.y
    )
  }
  
  // MARK: Constants
  
  // Every cell has length = collection's bounds minus itemInsets
  private enum Constants {
    static let itemHeight: CGFloat = 200
    static let itemInset: CGFloat = 36
    static let distance: CGFloat = 100
  }
}

Селектор в виде стопки карточек со свайпом влево

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

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

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

Сразу оговорюсь насчёт одной вещи. В этом (и следующем) примере по сути на каждое изменение bounds у collection нужно пересчитывать все атрибуты элементов. Для этого у layout-а есть функция shouldInvalidateLayout(forBoundsChange:), которую нужно переопределить и, в самом простом случае, вернуть true. Конечно, можно это делать более эффективно, но я не гнался за эффективностью в данной статье. Цель была - скорее показать, что можно делать в UI с помощью UICollectionView.

Сначала будет удобнее разобраться с зумом. Мне показалась простой и лаконичной следующая идея. Возьмём число, меньшее единицы (например, 0.4), и будем считать, что это - минимальный scale для всех элементов в коллекции. Достигается этот минимум у последнего элемента, когда коллекция проскроллена к началу, т.е. когда contentOffset = 0. Соответственно, все айтемы, находящиеся правее центра экрана относительно контента (далее буду это называть просто центр экрана, что соответствует именно contentCenterX, обсуждавшемуся выше), будем равномерно увеличивать по мере приближения к этому центру. Другими словами, мы вводим некоторую величину вида:

let scale = 0.4 + something * (1 - 0.4)

где something - другая величина, изменяющаяся в пределах от 0 до 1 и зависящая от расположения элемента относительно центра.  Т.е. что-то вроде (1 - distance) / maxDistance, где maxDistance - наибольшее расстояние, на которое элемент коллекции может удалиться от центра экрана. Очевидно, достигается это расстояние при contentOffset = 0. Размер и расстояние между элементами по сути константны, поэтому мы можем без труда посчитать maxDistance:

let maxDistance = (number - 1) * (itemWidth + Constants.distance)

Все остальные величины для расчёта scale известны.

С расположением элементов по центру друг под другом всё попроще - мы уже знаем, что такое contentCenterX. Соответственно, если мы в конце цикла создания layout attributes добавим пару строк вида:

if attr.center.x > contentCenterX {
	attr.center.x = contentCenterX
}

то при скролле все элементы, которые должны были находиться правее центра экрана, соберутся друг под другом, и мы получим нужный эффект. При этом contentSize не меняется, и до всех карточек удаётся добраться несмотря на то, что визуально может казаться, что весь content занимает фиксированную ширину, равную ширине экрана девайса.

Наконец, смещение по вертикальной оси. Эмпирическим путём выяснил, что удобно поступать следующим образом: если ячейка уменьшена в scale раз, то относительно изначального размера сверху и снизу образуeтся зазор, на который уменьшенный item должен "торчать" из под item-а оригинального размера:

У такого подхода есть интересный эффект: расстояние, на которое соседние ячейки "вылезают" друг из-под друга постоянно и не зависит от порядковых номеров ячеек, кроме разве что между первой и второй (между первой и второй - сами понимаете). Т.е. item с номером 12 будет вылезать из-под item-а с номером 11 ровно на столько же, на сколько item с номером 17 будет вылезать из-под item-а с номером 16. Этот эффект можно прямо математически доказать, это несложно, но, думаю, здесь это не суть. Да и математики хватит в следующем примере.

В итоге, имея layot из предыдущего примера, дописываем в конец итерации цикла создания layout attribures внутри функции prepare примерно следующие строки:

if attr.center.x > screenCenterX {
	let distance = attr.center.x  - screenCenterX
  let maxDistance = (number - 1) * (itemSize.width + Constants.distance)
  let scale = Constants.minScale 
  	+ abs(1 - distance / maxDistance) * (1 - Constants.minScale)
 
	attr.transform = CGAffineTransform(scaleX: scale, y: scale)
	attr.center.x = screenCenterX
	attr.center.y += itemSize.height * (1 - scale)
}

Единственное, про что не упомянул - zIndex, но, думаю, что в этом смысле всё довольно очевидно, и уделять этому особого внимания не стоит.

Полный код для layout-а из примера 2
import UIKit

final class CardsSwipeLayout: UICollectionViewFlowLayout {
  
  private var attrs: [UICollectionViewLayoutAttributes] = []
  
  // MARK: Override
  
  override func prepare() {
    super.prepare()
    
    attrs = []
    
    guard let collection = collectionView,
          collection.numberOfSections > 0 else {
      return
    }
    
    let itemSize = CGSize(
      width: collection.bounds.width - 2 * Constants.itemInset,
      height: Constants.itemHeight
    )
    
    let screenCenterX = collection.contentOffset.x + collection.bounds.width / 2
    let number = collection.numberOfItems(inSection: 0)
    
    for i in 0..<number {
      let attr = UICollectionViewLayoutAttributes(forCellWith: IndexPath(row: i, section: 0))
      attr.frame = CGRect(
        x: Constants.itemInset + CGFloat(i) * (itemSize.width + Constants.distance),
        y: collection.bounds.height / 2 - Constants.itemHeight / 2,
        width: itemSize.width,
        height: itemSize.height
      )
      
      attr.zIndex = -i

      if attr.center.x > screenCenterX {
        let distance = attr.center.x  - screenCenterX
        let maxDistance = CGFloat(number - 1) * (itemSize.width + Constants.distance)
        let scale = Constants.minScale + abs(1 - distance / maxDistance) * (1 - Constants.minScale)

        attr.transform = CGAffineTransform(scaleX: scale, y: scale)
        attr.center.x = screenCenterX
        attr.center.y += itemSize.height * (1 - scale)
      }
      
      attrs.append(attr)
    }
  }
  
  override var collectionViewContentSize: CGSize {
    guard let collection = collectionView,
          collection.numberOfSections > 0 else { return .zero }
    let number = collection.numberOfItems(inSection: 0)
    let itemWidth = collection.bounds.width - 2 * Constants.itemInset
    let resultWidth = Constants.itemInset + CGFloat(number) * (Constants.distance + itemWidth)
      - Constants.distance
      + Constants.itemInset
    return CGSize(
      width: resultWidth,
      height: collection.bounds.height
    )
  }
  
  override func layoutAttributesForElements(
    in rect: CGRect
  ) -> [UICollectionViewLayoutAttributes]? {
    return attrs.filter { $0.frame.intersects(rect) }
  }
  
  override func layoutAttributesForItem(
    at indexPath: IndexPath
  ) -> UICollectionViewLayoutAttributes? {
    return attrs[indexPath.row]
  }
  
  override func targetContentOffset(
    forProposedContentOffset proposedContentOffset: CGPoint,
    withScrollingVelocity velocity: CGPoint
  ) -> CGPoint {
    guard let collection = collectionView else { return proposedContentOffset }
    
    let itemSize = CGSize(
      width: collection.bounds.width - 2 * Constants.itemInset,
      height: Constants.itemHeight
    )

    let diff = (Constants.distance / 2 - Constants.itemInset)
    let screenCenterX = proposedContentOffset.x + collection.bounds.width / 2 + diff
    let imaginaryItemWidth = itemSize.width + Constants.distance

    let index = Int(screenCenterX / imaginaryItemWidth)

    return CGPoint(
      x: CGFloat(index) * imaginaryItemWidth,
      y: proposedContentOffset.y
    )
  }
  
  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { true }
  
  // MARK: Constants
  
  // Every cell has length = collection's bounds minus itemInsets
  private enum Constants {
    static let itemHeight: CGFloat = 200
    static let itemInset: CGFloat = 36
    static let distance: CGFloat = 50
    
    static let minScale: CGFloat = 0.4
    static let itemYOffset: CGFloat = 20
  }
}

Селектор-барабан с зумом выбираемого элемента

При скролле ближайший к центру элемент автоматически выравнивается и увеличивается
При скролле ближайший к центру элемент автоматически выравнивается и увеличивается

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

Пожалуй, это самый интересный пример с точки зрения разработки. Дальнейшее объяснение условно разобью на три этапа: сначала про радиус барабана и размеры item-ов; затем про contentSize, расположение элементов и их форму; под конец про зум выбираемого элемента.

Итак, первое - внешняя окружность и размеры item-ов. На картинке выше расположена ровно половина круга, потому что высота коллекции это позволяет. Вообще говоря, могло бы быть иначе (как некогда и произошло в моём случае), т.е. высота коллекции ниже, и половина круга не вписывается в рамки collection:

В целом это не влияет на механику барабана, только внешний вид немного меняется. Как, имея рамки коллекции, посчитать внешнюю окружность барабана? Обозначим высоту коллекции как h, половину ширины как w, радиус внешней окружности как R и нарисуем картинку, попутно вспоминая пару фактов из школьной геометрии:

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

Из формул получаем радиус внешней окружности:

let radius = h > w ? w : (w * w + h * h) / (2 * h)

Заодно мы можем получить угол, в котором заключены видимые элементы коллекции:

let angle = h > w ? .pi : 4 * atan(h / w)

Также, взяв количество видимых на экране элементов за константу numberOfVisibleItems, получаем угол, соответствующий каждому item-у:

let anglePerItem = ange / numberOfVisibleItems

Для расчёта размера item-а проделаем примерно то же самое, что и для расчёта радиуса внешней окружности, но в каком-то смысле в обратную сторону:

Здесь мы взяли высоту каждой ячейки как константу, и из уравнений на картинке в результате получаем ширину item-а и радиус внутренней окружности:

let width = 2 * radius * sin(anglePerItem / 2)
let innerRadius = (radius - itemHeight) / cos(anglePerItem / 2)

Также, поскольку все элементы имеют одинаковый размер, и их верхние границы имеют одну и ту же форму в виде дуги окружности, можно легко получить длину этой дуги:

let lengthPerItem = radius * anglePerItem

Это пригодится для дальнейших расчётов.

Теперь перейдем к contentSize и расположению ячеек. Школьная задачка: на какой угол повернётся колесо радиусом R вокруг своей оси, если проедет расстояние L по ровной поверхности без проскальзывания? Ответ простой на самом деле - L / R. Понимание этого, возможно, требует некоторой интуиции и воображения. Если x1 - точка поверхности колеса, касающаяся пола до начала движения, x2 - точка касания пола в конце движения, то расстояние вдоль поверхности колеса между x1 и x2 будет равно в точности L, потому что колесо катится без проскальзывания и в каждый момент времени касается поверхности. Длина дуги окружности равна радиусу, умноженному на величину центрального угла, высекающего эту дугу - снова школа.

К чему это всё? А к тому, что в нашем случае есть практически то же самое колесо, только касается оно не пола, а "потолка" - воображаемой горизонтальной линии, касающейся внешней окружности барабана в верхней точке. Чтобы промотать всё содержимое коллекции до конца, нужно сдвинуть bounds примерно на сумму длин всех дуг, являющихся верхними гранями элементов коллекции. И, поскольку изначально первый и последний item-ы располагаются посередине экрана, получаем, что ширина всего содержимого collection равна:

let width = collection.width + (number - 1) * lengthPerItem

Здесь number - число элементов в коллекции. По краям добавляем по половине ширины collection, чтобы была возможность доскроллить контент до крайних item-ов.

Из рассуждений про колесо также следует, что расстояние вдоль поверхности барабана от его верхней точки в изначальном положении (contentOffset = 0) до середины верхней грани каждой ячейки равно:

let absX = lengthPerItem * index

Однако для вычисления угла поворота нам нужно расстояние до центра экрана, который, как мы уже выяснили, перемещается с изменением collection.bounds. В итоге отрезок, соединяющий центр барабана и центр item-а с индексом index, равен:

var angle = (absX - collection.width / 2) / radius

А теперь, впоминая формулы перехода от полярных координат к декартовым, мы можем вычислить положение центра item-а:

let itemCenterRadius = radius - itemHeight / 2
attr.center = CGPoint(
  x: contentCenter.x + itemCenterRadius * sin(angle),
  y: contentCenter.y - itemCenterRadius * cos(angle)
)

И тут сразу возникает пара нюансов. Во-первых, если все расчёты так и оставить, то при достаточно большом количестве элементов в коллекции они начнут наслаиваться друг на друга по кругу, потому что углы, скажем, в 30 градусов и 360 + 30 градусов ничем не будут отличаться с точки зрения вычислений. Эту проблему можно убрать следующим образом:

if abs(angle) > CGFloat.pi / 2 + anglePerItem {
	angle = CGFloat.pi
}

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

Во-вторых, когда мы проматываем collection в самое начало, мы хотим также видеть и последние элементы левее самого первого, чтобы в барабане не было "дырок". Оказалось довольно удобно решать это на уровне индексов элементов, в каком-то смысле обманывая вычисления: последнему элементу будем приписывать индекс, равный минус единице, предпоследнему - минус двойке и т.д. При таком подходе для последних item-ов будут получены правильные углы, и они встанут туда, куда нужно. Функцию можно будет посмотреть в полном коде для всего layout-а.

До сих пор мы рассматривали общие вещи, связанные с положением элементов, contentSize и прочее. А как же насчёт самой формы ячеек? Тут какие-то полуокружности... В этом нам помогут кастомные layout attributes!

Опять же напомню, что если мы хотим использовать класс, производный от UICollectionViewLayoutAttributes, в layout-е нужно переопределить свойство layoutAttributesClass, возвращая в нём соответствующий тип, а также создавать экземпляры этого класса внутри функции prepare для каждого элемента. Для придания нужной формы достаточно знать внешний радиус, внутренний радиус и угол:

final class CarouselLayoutAttributes: UICollectionViewLayoutAttributes {
  var angle: CGFloat = 0
  var outerRadius: CGFloat = 0
  var innerRadius: CGFloat = 0
}

Не забудьте переопределить copy(with:) и isEqual(_:)! Коллекция что-то там делает с атрибутами у себя внутри - копирует их, сравнивает. Без этих методов что-то может не работать.

Наверное, вы уже встречали на stackoverflow, что у класса UICollectionReusableView есть метод apply(_ layoutAttributes:). В принципе его название говорит само за себя - метод даёт возможность применить какие-то параметры, насчитанные в функции prepare, уже внутри UICollectionViewCell. Я бы даже сказал, это удобный способ передачи меняющихся параметров в каждую ячейку. Внутри ячейки, зная радиусы и угол, мы просто создаём слой-маску и задаём у него path вдоль дуг окружностей, образуемых поворотом имеющихся радиусов на имеющийся угол. Если кто не помнит, как задавать маски у слоёв, прошу сюда.

Полный код ячейки из примера 3
import UIKit

final class Cell: UICollectionViewCell {
  
  var title: String? {
    get { titleLabel.text }
    set { titleLabel.text = newValue }
  }
  
  var color: UIColor = .white {
    didSet { contentView.backgroundColor = color }
  }
  
  private var titleLabel: UILabel = {
    let label = UILabel()
    label.font = .systemFont(ofSize: 30, weight: .bold)
    return label
  }()
  
  private var angle: CGFloat = 0
  private var outerRadius: CGFloat = 0
  private var innerRadius: CGFloat = 0
  
  // MARK: Override
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    setup()
  }
  
  required init?(coder: NSCoder) {
    super.init(coder: coder)
    setup()
  }
  
  override func layoutSubviews() {
    super.layoutSubviews()
    
    let path = UIBezierPath()
    path.addArc(withCenter: CGPoint(x: bounds.width / 2, y: outerRadius),
                radius: outerRadius,
                startAngle: -CGFloat.pi / 2 - angle / 2,
                endAngle: -CGFloat.pi / 2 + angle / 2,
                clockwise: true)
    path.addArc(withCenter: CGPoint(x: bounds.width / 2, y: outerRadius),
                radius: innerRadius,
                startAngle: -CGFloat.pi / 2 + angle / 2,
                endAngle: -CGFloat.pi / 2 - angle / 2,
                clockwise: false)
    path.close()

    (layer.mask as? CAShapeLayer)?.path = path.cgPath
  }
  
  override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
    guard let attr = layoutAttributes as? CarouselLayoutAttributes else { return }
    angle = attr.angle
    outerRadius = attr.outerRadius
    innerRadius = attr.innerRadius
  }
  
  // MARK: Setup
  
  private func setup() {
    contentView.backgroundColor = color
    layer.mask = CAShapeLayer()
    
    contentView.addSubview(titleLabel)
    titleLabel.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
      titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
      titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
    ])
  }
}

Наконец, остался зум центрального элемента. С величиной зума всё довольно просто: максимальное значение достигается тогда, когда элемент находится ровно по центру (т.е. angle = 0), и по мере удаления от центра по окружности item уменьшается до первоначальных размеров. Причём уменьшается очень быстро - после того, как angle превысит anglePerItem, величина зума становится равной единице. Учитывая, что всё это изменяется линейно, получаем что-то вроде:

let scale = max(Constants.maxScale - abs(angle) / anglePerItem, 1)

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

let heightDiff = itemSizeHeight * (scale - 1)
attr.outerRadius = (radius + heightDiff / 2) / scale
attr.innerRadius = innerRadius / scale

Объяснение этому примерно следующее. Зум физически осуществляется с помощью transform, при котором все расстояния в собственной плоскости item-а пропорционально увеличиваются относительно координат коллекции; мне хотелось сделать так, чтобы после зума центр окружности в "растянутой" плоскости ячейки, из которого рисуются дуги, совпал с центром окружности барабана в системе координат коллекции. Такой подход позволяет линиям, образующим боковые стороны секторов окружности, в которых по сути и находятся ячейки, как бы накладываться друг на друга, хоть они и находятся в разных системах координат. Визуально это выражается в том, что при зуме элементы "урезаются" по краям так, что не наезжают друг на друга, сохраняя изначальную ширину.

Посчитать, какими должны быть радиусы в старой системе координат после зума, несложно: внешний равен текущему + величина, на которую увеличенный item "вылезает" над оригинальным (itemHeight * scale - itemHeight) / 2; внутренний же радиус должен остаться без изменений, чтобы нижние грани ячеек образовывали сплошную гладкую линию без изломов. Но, поскольку мы находимся в другой системе координат, где всё растянуто, нужно итоговые величины разделить на scale.

Полный код для layout-а из примера 3
import UIKit

final class CarouselLayout: UICollectionViewFlowLayout {
  
  private var attrs: [UICollectionViewLayoutAttributes] = []
  
  // MARK: Override
  
  override static var layoutAttributesClass: AnyClass { CarouselLayoutAttributes.self }
  
  override var collectionViewContentSize: CGSize {
    guard let collection = collectionView,
          collection.numberOfSections > 0 else { return .zero }
    
    let radius = getRadius()
    let angle = getAngle()
    let anglePerItem = angle / CGFloat(Constants.numberOfVisibleItems)
    let lengthPerItem = anglePerItem * radius
    
    let width = collection.bounds.width + CGFloat(collection.numberOfItems(inSection: 0) - 1) * lengthPerItem 
    
    return CGSize(width: width, height: collection.bounds.height)
  }
  
  override func prepare() {
    super.prepare()
    
    attrs = []
    
    guard let collection = collectionView else { return }
    
    let radius = getRadius()
    let angle = getAngle()
    let anglePerItem = angle / CGFloat(Constants.numberOfVisibleItems)
    let lengthPerItem = anglePerItem * radius
    
    let itemSize = getItemSize(radius: radius, anglePerItem: anglePerItem)
    let contentCenter = getContentCenter(radius: radius)
    let itemCenterRadius = radius - itemSize.height / 2
    let innerRadius = (radius - itemSize.height) / cos(anglePerItem / 2)
    
    let numberOfItems = collection.numberOfItems(inSection: 0)
    let centerItemIndex = Int(collection.contentOffset.x / lengthPerItem)
    
    for i in 0..<numberOfItems {
      let attr = CarouselLayoutAttributes(forCellWith: IndexPath(row: i, section: 0))
      attr.angle = anglePerItem
      
      let index = fakeItemIndexFor(itemIndex: i, centerItemIndex: centerItemIndex, numberOfItems: numberOfItems)
      
      let absX = lengthPerItem * CGFloat(index)
      let relativeX = absX - collection.contentOffset.x
      var angle = relativeX / radius
      
      if abs(angle) > CGFloat.pi / 2 + anglePerItem {
        angle = CGFloat.pi
      }
      
      let scale = max(Constants.maxScale - abs(angle) / anglePerItem, 1)
      
      attr.size = itemSize
      attr.center = CGPoint(x: contentCenter.x + itemCenterRadius * sin(angle),
                            y: contentCenter.y - itemCenterRadius * cos(angle))
      attr.transform = CGAffineTransform(rotationAngle: angle).scaledBy(x: scale, y: scale)
      attr.zIndex = Int(scale * 10) // just in caseй
      
      let heightDiff = itemSize.height * (scale - 1)
      attr.outerRadius = (radius + heightDiff / 2) / scale
      attr.innerRadius = innerRadius / scale
            
      attrs.append(attr)
    }
  }
  
  override func layoutAttributesForElements(
    in rect: CGRect
  ) -> [UICollectionViewLayoutAttributes]? {
    attrs
  }
  
  override func layoutAttributesForItem(
    at indexPath: IndexPath
  ) -> UICollectionViewLayoutAttributes? {
    attrs[indexPath.row]
  }
  
  override func targetContentOffset(
    forProposedContentOffset proposedContentOffset: CGPoint,
    withScrollingVelocity velocity: CGPoint
  ) -> CGPoint {
    let radius = getRadius()
    let angle = getAngle()
    let anglePerItem = angle / CGFloat(Constants.numberOfVisibleItems)
    let lengthPerItem = anglePerItem * radius
    
    let floatIndex = floor((proposedContentOffset.x + lengthPerItem / 2) / lengthPerItem)

    let x = floatIndex * lengthPerItem

    return CGPoint(x: x, y: proposedContentOffset.y)
  }
  
  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { true }
  
  // MARK: Helpers
  
  private func getRadius() -> CGFloat {
    guard let bounds = collectionView?.bounds else { return 0 }
    
    let w = bounds.width / 2
    let h = bounds.height
    
    return h > w ? w : (w * w + h * h) / (2 * h)
  }
  
  private func getAngle() -> CGFloat {
    guard let bounds = collectionView?.bounds else { return 0 }
    
    let w = bounds.width / 2
    let h = bounds.height
    
    return h > w ? .pi : 4 * atan(h / w)
  }
  
  func getContentCenter(radius: CGFloat) -> CGPoint {
    guard let collection = collectionView else { return .zero }
    
    let w = collection.bounds.width / 2
    let h = collection.bounds.height
    let offset = collection.contentOffset.x
    
    return h > w ? .init(x: w + offset, y: h) : .init(x: w + offset, y: radius)
  }
  
  private func getItemSize(radius: CGFloat, anglePerItem: CGFloat) -> CGSize {
    let width = 2 * radius * sin(anglePerItem / 2)
    return CGSize(width: width, height: Constants.itemHeight)
  }
  
  private func fakeItemIndexFor(itemIndex: Int, centerItemIndex: Int, numberOfItems: Int) -> Int {
    if centerItemIndex < Constants.numberOfVisibleItems {
      return itemIndex >= numberOfItems - Constants.numberOfVisibleItems ? itemIndex - numberOfItems : itemIndex
    } else if centerItemIndex >= numberOfItems - Constants.numberOfVisibleItems {
      return itemIndex < Constants.numberOfVisibleItems ? numberOfItems + itemIndex : itemIndex
    } else {
      return itemIndex
    }
  }
  
  // MARK: Constants
  
  private enum Constants {
    static let numberOfVisibleItems = 5
    static let itemHeight: CGFloat = 70
    static let maxScale: CGFloat = 1.7
  }
}

Заключение

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

Things to remember:

  • contentSize - пожалуй первое, с чего надо начать, если хотите "поиздеваться" над ячейками коллекции;

  • targetContentOffset - можно повлиять на момент остановки скролла;

  • shouldInvalidateLayout - можно переопределять, если необходимы частые обновления атрибутов ячеек при скролле;

  • layoutAttributesClass + apply(_ layoutAttributes:) - возможность передать дополнительные атрибуты в каждую из ячеек;

  • школьная математика не так уж и бесполезна.

Всем добра, пишите классные приложения и делайте красоту в интерфейсах!

Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+8
Комментарии5

Публикации

Истории

Работа

Swift разработчик
15 вакансий
iOS разработчик
15 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань