Привет читателям хабра! В прошлой статье рассказывал про тени и маски у CALayer-ов. В этой расскажу про некоторые подходы при работе с коллекциями и кастомными layout-ами, опять же демонстрируя всё на довольно интересных, на мой взгляд, примерах. Предполагаю, что читатель достаточно хорошо знаком с UICollectionView, т.е. понимает, куда и как прокидывать данные, как задавать layout, что вообще такое UICollectionViewLayout и т.п. На момент написания были некоторые сомнения об актуальности материала, но SwiftUI вроде бы ещё не занял доминирующую позицию, да и слышал от разработчиков, что на нём сильно кастомные компоненты проблематично создавать.Все примеры взяты из жизни с незначительными модификациями. Во вставках с кодом умышленно допущены неточности для более удобной читаемости. Крайние случаи вроде всяких пограничных переходов позволил себе не рассматривать, чтобы не загромождать код в примерах. Если у вас будет что-то падать при попытке запуска примеров кода, попробуйте выставить estimatedItemSize в ноль. С точки зрения пользователя имеем горизонтально скроллящийся список карточек, в котором после каждого свайпа и прекращения движения по инерции ближайшая к центру экрана карточка автоматически оказывается ровно посередине. В банковских приложениях такое частенько можно увидеть. Прежде чем пускаться в детали, напомню вкратце про самые, пожалуй, важные методы, которые нужно переопределять при создании кастомного layout-а: в функции prepare мы создаём layout attributes (UICollectionViewLayoutAttributes), которые затем "скармливаем" функции layoutAttributesForElements(in:). И ещё важно переопределить collectionViewContentSize, чтобы всем ячейкам хватило места.В случаях таких layout-ов, мне кажется, удобно задавать ширину ячейки как "ширина коллекции минус константное расстояние по бокам", а высоту уже брать как захочется, потому что она на scroll, можно сказать, не влияет. Т.е. в начале функции prepare можно написать что-то вроде: Также расстояние между элементами коллекции можно взять за константу и подгонять её так, чтобы покрасивее смотрелось. Расположение ячеек - дело нехитрое, в коде будет выглядеть примерно так: Внутри collectionContentSize будет что-то очень похожее. Самое интересное в этом примере - выравнивание item-ов по центру. В этом нам поможет функция targetContentOffset(forProposedContentOffset:withScrollingVelocity), которая переопределяется внутри layout. Её смысл примерно в следующем: после того как юзер проводит чем-нибудь по экрану и потом отпускает экран, коллекция продолжает проматывать содержимое по инерции, зная заранее, в какой точке она остановится. TargetContentOffset позволяет вмешаться в этот процесс и сказать collection, где ей остановиться. А это как раз то, что нам нужно. Т.е. эта функция работает так, что на вход подаётся contentOffset, в котором коллекция собирается "остановиться", а её возвращаемое значение - скорректированный contetOffset.Итак, мы знаем, при каком contentOffset коллекция собирается перестать скроллить контент. В этот момент положение центра экрана относительно всего контента равно Логично, что нужно найти item, центр которого ближе всего к этой величине. Для этого, как мне показалось, проще мысленно провести разделительные линии посередине между элементами коллекции и представить, что мы имеем дело с "мнимыми" элементами большего размера, располагающимися вплотную друг к другу: При таком раскладе точка в центре экрана обязательно попадет в какой-то мнимый item, индекс которого легко вычисляется: Однако это даёт не совсем точный результат из-за несоответствия расстояния между ячейками и ширины самих ячеек по отношению к ширине коллекции - мнимые элементы в нашем случае в сумме дают большую ширину, чем contentSize.width. Выражаться это будет в том, что в каких-то пограничных случаях (например, центр видимой области экрана попадает ровно посередине между item-ами) подскролливаться к центру будет не тот элемент, который мы ожидаем. Исправить проблему просто - добавить (или отнять) недостающее расстояние в contentCenterX: и после этого уже вычислять индекс элемента. В итоге в функции targetContentOffset нужно вернуть новый contentOffset в виде: В этом примере пользователь выбирает из стопки карточек нужную путем перебора, смахивая в сторону неподходящие. При смахивании следующая карточка увеличивается и занимает место той, что ушла вбок. Остальные карточки при этом тоже немного увеличиваются. Коллекция не останавливается в промежуточных состояниях, а автоматически размещает текущую или следующую карточку посередине экрана над всеми остальными.На первый взгляд может показаться, что мы имеем дело с совершенно другим layout-ом, но на самом деле он получается из предыдущего всего лишь добавлением нескольких строк кода! Если присмотреться, то здесь концептуально только пара дополнительных эффектов - центрирование всех элементов и зум.Сразу оговорюсь насчёт одной вещи. В этом (и следующем) примере по сути на каждое изменение bounds у collection нужно пересчитывать все атрибуты элементов. Для этого у layout-а есть функция shouldInvalidateLayout(forBoundsChange:), которую нужно переопределить и, в самом простом случае, вернуть true. Конечно, можно это делать более эффективно, но я не гнался за эффективностью в данной статье. Цель была - скорее показать, что можно делать в UI с помощью UICollectionView.Сначала будет удобнее разобраться с зумом. Мне показалась простой и лаконичной следующая идея. Возьмём число, меньшее единицы (например, 0.4), и будем считать, что это - минимальный scale для всех элементов в коллекции. Достигается этот минимум у последнего элемента, когда коллекция проскроллена к началу, т.е. когда contentOffset = 0. Соответственно, все айтемы, находящиеся правее центра экрана относительно контента (далее буду это называть просто центр экрана, что соответствует именно contentCenterX, обсуждавшемуся выше), будем равномерно увеличивать по мере приближения к этому центру. Другими словами, мы вводим некоторую величину вида: где something - другая величина, изменяющаяся в пределах от 0 до 1 и зависящая от расположения элемента относительно центра.  Т.е. что-то вроде (1 - distance) / maxDistance, где maxDistance - наибольшее расстояние, на которое элемент коллекции может удалиться от центра экрана. Очевидно, достигается это расстояние при contentOffset = 0. Размер и расстояние между элементами по сути константны, поэтому мы можем без труда посчитать maxDistance: Все остальные величины для расчёта scale известны. С расположением элементов по центру друг под другом всё попроще - мы уже знаем, что такое contentCenterX. Соответственно, если мы в конце цикла создания layout attributes добавим пару строк вида: то при скролле все элементы, которые должны были находиться правее центра экрана, соберутся друг под другом, и мы получим нужный эффект. При этом contentSize не меняется, и до всех карточек удаётся добраться несмотря на то, что визуально может казаться, что весь content занимает фиксированную ширину, равную ширине экрана девайса.Наконец, смещение по вертикальной оси. Эмпирическим путём выяснил, что удобно поступать следующим образом: если ячейка уменьшена в scale раз, то относительно изначального размера сверху и снизу образуeтся зазор, на который уменьшенный item должен "торчать" из под item-а оригинального размера: У такого подхода есть интересный эффект: расстояние, на которое соседние ячейки "вылезают" друг из-под друга постоянно и не зависит от порядковых номеров ячеек, кроме разве что между первой и второй (между первой и второй - сами понимаете). Т.е. item с номером 12 будет вылезать из-под item-а с номером 11 ровно на столько же, на сколько item с номером 17 будет вылезать из-под item-а с номером 16. Этот эффект можно прямо математически доказать, это несложно, но, думаю, здесь это не суть. Да и математики хватит в следующем примере.В итоге, имея layot из предыдущего примера, дописываем в конец итерации цикла создания layout attribures внутри функции prepare примерно следующие строки: Единственное, про что не упомянул - zIndex, но, думаю, что в этом смысле всё довольно очевидно, и уделять этому особого внимания не стоит. Здесь пользователь буквально вращает барабан, и элемент по центру является выбираемым. Так же, как и в предыдущих примерах, в промежуточных состояниях коллекция не "останавливается" - в итоге всё время какой-то из элементов находится ровно посередине.Пожалуй, это самый интересный пример с точки зрения разработки. Дальнейшее объяснение условно разобью на три этапа: сначала про радиус барабана и размеры item-ов; затем про contentSize, расположение элементов и их форму; под конец про зум выбираемого элемента.Итак, первое - внешняя окружность и размеры item-ов. На картинке выше расположена ровно половина круга, потому что высота коллекции это позволяет. Вообще говоря, могло бы быть иначе (как некогда и произошло в моём случае), т.е. высота коллекции ниже, и половина круга не вписывается в рамки collection: В целом это не влияет на механику барабана, только внешний вид немного меняется. Как, имея рамки коллекции, посчитать внешнюю окружность барабана? Обозначим высоту коллекции как h, половину ширины как w, радиус внешней окружности как R и нарисуем картинку, попутно вспоминая пару фактов из школьной геометрии: Пожалуй, сложнее всего вспомнить то, что центральный угол в два раза больше вписанного угла, если они опираются на одну и ту же дугу. А тригонометрические формулы можно подсмотреть в википедии. Из формул получаем радиус внешней окружности: Заодно мы можем получить угол, в котором заключены видимые элементы коллекции: Также, взяв количество видимых на экране элементов за константу numberOfVisibleItems, получаем угол, соответствующий каждому item-у: Для расчёта размера item-а проделаем примерно то же самое, что и для расчёта радиуса внешней окружности, но в каком-то смысле в обратную сторону: Здесь мы взяли высоту каждой ячейки как константу, и из уравнений на картинке в результате получаем ширину item-а и радиус внутренней окружности: Также, поскольку все элементы имеют одинаковый размер, и их верхние границы имеют одну и ту же форму в виде дуги окружности, можно легко получить длину этой дуги: Это пригодится для дальнейших расчётов.Теперь перейдем к contentSize и расположению ячеек. Школьная задачка: на какой угол повернётся колесо радиусом R вокруг своей оси, если проедет расстояние L по ровной поверхности без проскальзывания? Ответ простой на самом деле - L / R. Понимание этого, возможно, требует некоторой интуиции и воображения. Если x1 - точка поверхности колеса, касающаяся пола до начала движения, x2 - точка касания пола в конце движения, то расстояние вдоль поверхности колеса между x1 и x2 будет равно в точности L, потому что колесо катится без проскальзывания и в каждый момент времени касается поверхности. Длина дуги окружности равна радиусу, умноженному на величину центрального угла, высекающего эту дугу - снова школа.К чему это всё? А к тому, что в нашем случае есть практически то же самое колесо, только касается оно не пола, а "потолка" - воображаемой горизонтальной линии, касающейся внешней окружности барабана в верхней точке. Чтобы промотать всё содержимое коллекции до конца, нужно сдвинуть bounds примерно на сумму длин всех дуг, являющихся верхними гранями элементов коллекции. И, поскольку изначально первый и последний item-ы располагаются посередине экрана, получаем, что ширина всего содержимого collection равна: Здесь number - число элементов в коллекции. По краям добавляем по половине ширины collection, чтобы была возможность доскроллить контент до крайних item-ов.Из рассуждений про колесо также следует, что расстояние вдоль поверхности барабана от его верхней точки в изначальном положении (contentOffset = 0) до середины верхней грани каждой ячейки равно: Однако для вычисления угла поворота нам нужно расстояние до центра экрана, который, как мы уже выяснили, перемещается с изменением collection.bounds. В итоге отрезок, соединяющий центр барабана и центр item-а с индексом index, равен: А теперь, впоминая формулы перехода от полярных координат к декартовым, мы можем вычислить положение центра item-а: И тут сразу возникает пара нюансов. Во-первых, если все расчёты так и оставить, то при достаточно большом количестве элементов в коллекции они начнут наслаиваться друг на друга по кругу, потому что углы, скажем, в 30 градусов и 360 + 30 градусов ничем не будут отличаться с точки зрения вычислений. Эту проблему можно убрать следующим образом: Т.е. мы по сути все ячейки, которые не должны быть видны в конкретный момент времени, складываем в одно и то же место где-то за пределами видимой части коллекции.Во-вторых, когда мы проматываем collection в самое начало, мы хотим также видеть и последние элементы левее самого первого, чтобы в барабане не было "дырок". Оказалось довольно удобно решать это на уровне индексов элементов, в каком-то смысле обманывая вычисления: последнему элементу будем приписывать индекс, равный минус единице, предпоследнему - минус двойке и т.д. При таком подходе для последних item-ов будут получены правильные углы, и они встанут туда, куда нужно. Функцию можно будет посмотреть в полном коде для всего layout-а.До сих пор мы рассматривали общие вещи, связанные с положением элементов, contentSize и прочее. А как же насчёт самой формы ячеек? Тут какие-то полуокружности... В этом нам помогут кастомные layout attributes! Опять же напомню, что если мы хотим использовать класс, производный от UICollectionViewLayoutAttributes, в layout-е нужно переопределить свойство layoutAttributesClass, возвращая в нём соответствующий тип, а также создавать экземпляры этого класса внутри функции prepare для каждого элемента. Для придания нужной формы достаточно знать внешний радиус, внутренний радиус и угол: Не забудьте переопределить copy(with:) и isEqual(_:)! Коллекция что-то там делает с атрибутами у себя внутри - копирует их, сравнивает. Без этих методов что-то может не работать. Наверное, вы уже встречали на stackoverflow, что у класса UICollectionReusableView есть метод apply(_ layoutAttributes:). В принципе его название говорит само за себя - метод даёт возможность применить  какие-то параметры, насчитанные в функции prepare, уже внутри UICollectionViewCell. Я бы даже сказал, это удобный способ передачи меняющихся параметров в каждую ячейку. Внутри ячейки, зная радиусы и угол, мы просто создаём слой-маску и задаём у него path вдоль дуг окружностей, образуемых поворотом имеющихся радиусов на имеющийся угол. Если кто не помнит, как задавать маски у слоёв, прошу сюда. Наконец, остался зум центрального элемента. С величиной зума всё довольно просто: максимальное значение достигается тогда, когда элемент находится ровно по центру (т.е. angle = 0), и по мере удаления от центра по окружности item уменьшается до первоначальных размеров. Причём уменьшается очень быстро - после того, как angle превысит anglePerItem, величина зума становится равной единице. Учитывая, что всё это изменяется линейно, получаем что-то вроде: Осталось только скорректировать форму ячейки во время зума. Дамы и господа, должен признать, что примерно в этом месте у меня уже поехала крыша, и радиусы подбирались мной чисто эмпирическим путём. В результате нескольких попыток получилось такое: Объяснение этому примерно следующее. Зум физически осуществляется с помощью transform, при котором все расстояния в собственной плоскости item-а пропорционально увеличиваются относительно координат коллекции; мне хотелось сделать так, чтобы после зума центр окружности в "растянутой" плоскости ячейки, из которого рисуются дуги, совпал с центром окружности барабана в системе координат коллекции. Такой подход позволяет линиям, образующим боковые стороны секторов окружности, в которых по сути и находятся ячейки, как бы накладываться друг на друга, хоть они и находятся в разных системах координат. Визуально это выражается в том, что при зуме элементы "урезаются" по краям так, что не наезжают друг на друга, сохраняя изначальную ширину.Посчитать, какими должны быть радиусы в старой системе координат после зума, несложно: внешний равен текущему + величина, на которую увеличенный item "вылезает" над оригинальным (itemHeight * scale - itemHeight) / 2; внутренний же радиус должен остаться без изменений, чтобы нижние грани ячеек образовывали сплошную гладкую линию без изломов. Но, поскольку мы находимся в другой системе координат, где всё растянуто, нужно итоговые величины разделить на scale.   На этих трёх примерах пытался показать, что можно делать с помощью коллекций, layout-ов и капли воображения. Копируйте код, играйтесь с параметрами, используйте у себя в проектах! Надеюсь, было интересно.Things to remember: contentSize - пожалуй первое, с чего надо начать, если хотите "поиздеваться" над ячейками коллекции; targetContentOffset - можно повлиять на момент остановки скролла; shouldInvalidateLayout - можно переопределять, если необходимы частые обновления атрибутов ячеек при скролле; layoutAttributesClass + apply(_ layoutAttributes:) - возможность передать дополнительные атрибуты в каждую из ячеек; школьная математика не так уж и бесполезна. Всем добра, пишите классные приложения и делайте красоту в интерфейсах! iOS developer