Кастомизация UICollectionViewLayout. Во имя искусства

в 10:33, , рубрики: iOS, objective-c, uicollectionview, разработка под iOS, метки: , ,

Всем хэй хо!

Интро

Я работаю ios-разработчиком в провинциальном городе провинциальной страны ближайшего (по отношению к России) зарубежья. Около полутора лет назад страна решила, что я ей чего-то должен, а конкретно: должен год своей жизни, год низкоквалифицированного труда, год мечтаний о возвращении домой, к семье и работе… — одним словом, меня призвали в армию. И за этим делом я как-то пропустил выход iOS 6 со всеми ее фичами, в том числе и давно назревшего UICollectionView.
Разделавшись с нарядами, полигонами, уставом и прочими увлекательными вещами, я вернулся домой, снова начал работать, и конечно же проект, в котором заказчику было нужно отображение данных в виде того, что дизайнеры называют, «pinterest board», то есть собственно UICollectionView, не заставил себя ждать.

Проект

Проект является чем-то вроде iPad-каталога для аукционов одной компании, занимающейся оценкой антиквариата. Понятия не имею, как ребята отнесутся к их упоминанию на хабре, поэтому не буду давать ни ссылок, ни макеты дизайна, ни реальных скриншотов.
Приложение, в принципе, не сложное, из дизайнерских изысков только одна вещь меня немного насторожила — вид главного экрана. Он должен был представлять собой коллекцию изображений лотов, расположенных в три горизонтальных ряда, с горизонтальной прокруткой. Средний ряд с фиксированной высотой элемента и произвольной шириной (в зависимости от пропорций изображения). Высота элементов первого и третьего рядов должна немного варьироваться в меньшую сторону, создавая как бы рваные края. Если вам не совсем понятно мое сухое описание буквами, посмотрите на скриншот в конце статьи — образец конечного результата — он вам должен объяснить все. Или вот тут.

UICollectionViewFlowLayout

Это была первая попытка потратить минимум усилий. Поскольку я еще ни разу не сталкивался с этими элементами, моя надежда на класс от Apple была крайне сильна. Для начала я вообще забил на рваные края, сосредоточившись просто на выводе горизонтального «пинтерестбоарда». К сожалению, проскочить на шарике не удалось.

Ой, картинка!

Рисунок #0 — Разработчик предполагает, а UICollectionViewFlowLayout располагает

UICollectionViewFlowLayout

Вы можете видеть на скриншоте две вещи.

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

RFQuiltLayout

То ли мои навыки гугления крайне слабы, то ли RFQuiltLayout — единственное готовое решение, которое вроде бы катит в моем случае.
Этот класс использует переменную blockPixels, в которой хранится CGSize размера ячейки по умолчанию. Метод делегата

- (CGSize) blockSizeForItemAtIndexPath:(NSIndexPath *)indexPath;

возвращает множители для blockPixels каждой ячейки. То есть, если blockPixels = {100, 100}, а blockSizeForItemAtIndexPath = {2.2, 0.8}, то размер у ячейки будет {220, 80}.
На мой взгляд, немного странная система, так и хочется установить blockPixels в {1, 1} и возвращать требуемый размер для элемента в методе делегата, однако алгоритм размещения в таком случае очень долго думает даже для 15 элементов, а для размещения 100 элементов ей требуются вычислительные мощности покруче чем у iPad'а. Разбирать алгоритм терпения у меня не хватило, так что я методом подбора выбрал значение {20, 20} для blockPixels, что при моих 15 ячейках давало нормальное быстродействие и неплохую точность размещения.
Для создания рваных краев пришлось применить небольшой обман — собственно, сами размеры ячеек я не трогал, потому что на этапе размещения не мог узнать, в каком ряду ячейка находится, а вот уже при установке картинки проверял ряд и для первого и последнего рядов уменьшал высоту самих картинок. Изображения получались немного обрезанные сверху и снизу. Если лот, картинка которого попадала под обрезание, был портретом, то люди лишались макушки и низа груди, если лот был китайской статуэткой, то дракон отображался без гребня, а у персидских ковров мой грязный хак подрезал ворс. Но клиент был доволен, а значит был доволен и я, пока техзадание немного не было подкорректировано. Вместо жалких 15 элементов на главном экране должны были быть отображены все лоты. Все полторы тысячи.

Больше искусства!

Полторы тысячи изображений картин, ваз, нефритовых статуэток и всего остального, не менее прекрасного. Суровая правда была такова, что никакие сторонние решения меня не спасали. А значит настало время написания своего менеджера размещения.
Как и следовало ожидать, найти какой-нибудь информации на русском мне не удалось (не очень-то и надеялся), но и обучалки на английском, где не было бы кучи ненужного и была бы хотя бы горочка полезного, тоже как-то не забивали первые десять страниц результатов поиска. Как итог, моим пособием были, собственно, документация от Apple и упомянутый выше код RFQuiltLayout (кстати, хочется выразить благодарность его автору Bryce Redd).

Итак, SKRaggyCollectionViewLayout

Сразу извиняюсь за стремное название класса.
Для начала я определил протокол для его делегата

@protocol SKRaggyCollectionViewLayoutDelegate <UICollectionViewDelegate>

- (float)collectionLayout:(SKRaggyCollectionViewLayout*)layout preferredWidthForItemAtIndexPath:(NSIndexPath *)indexPath;

@optional

- (UIEdgeInsets)collectionLayout:(SKRaggyCollectionViewLayout*)layout edgeInsetsForItemAtIndexPath:(NSIndexPath *)indexPath;

@end

Поскольку на высоту ячейки делегат повлиять никак не может, то он передает в менеджер только ширину, которую он хотел бы видеть у конкретной ячейки. Ну и метод, возвращающий UIEdgeInsets (внутренние отступы для ячейки), куда же без него.
Ну и конечно же свойство, хранящее количество рядов — гулять так гулять, пусть класс будет универсальным, а не только для трех рядов!

@property (nonatomic, assign) NSUInteger numberOfRows;

Теперь реализация.
Apple говорит нам, что если мы не хотим заморачиваться со всякими добавочными элементами UICollectionView, такими как supplementary и decoration view, нам надо переопределить хотя бы следующие методы:

- (CGSize)collectionViewContentSize;

- (NSArray*)layoutAttributesForElementsInRect:(CGRect)bounds;

- (UICollectionViewLayoutAttributes*)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath;

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds;

С последним все понятно — вернуть YES, если newBounds не совпадает с текущими границами коллекции.
Оставим легкий в реализации первый метод на потом, и перейдем к методу layoutAttributesForItemAtIndexPath. Как ясно из его имени, в нем мы должны рассчитать UICollectionViewLayoutAttributes для каждого элемента. Объекты класса UICollectionViewLayoutAttributes содержат кучу информации о расположении объекта, включая даже transform3D, что позволяет создавать всякие красивости для ячеек, однако в нашем случае можно обойтись одним лишь банальным frame.

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    UIEdgeInsets insets = UIEdgeInsetsZero;
    if ([self.delegate respondsToSelector:@selector(collectionLayout:edgeInsetsForItemAtIndexPath:)]) {
        insets = [self.delegate collectionLayout:self edgeInsetsForItemAtIndexPath:indexPath];
    }
// Get saved frame and edge insets for given path and create attributes object with them
    CGRect frame = [self frameForIndexPath:indexPath];
    UICollectionViewLayoutAttributes* attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
    attributes.frame = UIEdgeInsetsInsetRect(frame, insets);
    return attributes;
}

Собственно, ничего интересного — получаем UIEdgeInsets от делегата, если он нам их предоставляет, получаем frame с помощью метода frameForIndexPath, создаем и возвращаем атрибуты с полученными UIEdgeInsets и CGRect. А вот в методе frameForIndexPath у меня и скрывается основная часть шаманства.

- (CGRect)frameForIndexPath:(NSIndexPath*)path {
// if there is saved frame for given path, return it
    NSValue *v = [self.framesByIndexPath objectForKey:path];
    if (v) return [v CGRectValue];
    
// Find X-coordinate and a row which are the closest to the collection left corner. A cell for this path should be placed here.
    int currentRow = 0;
    float currentX = MAXFLOAT;
    for (int i = 0; i < self.edgeXPositions.count; i++) {
        float x = [[self.edgeXPositions objectAtIndex:i] floatValue];
        if (x < currentX) {
            currentRow = i;
            currentX = x;
        }
    }
// Calculate cell frame values based on collection height, current row, currentX, the number of rows and delegate's preferredWidthForItemAtIndexPath: value
// If variableFrontierHeight is YES this value will be adjusted for the first and last rows
    float maxH = self.collectionView.frame.size.height;
    float rowMaxH = maxH / self.numberOfRows;
    float x = currentX;
    float y = rowMaxH * currentRow;
    float w = [self.delegate collectionLayout:self preferredWidthForItemAtIndexPath:path];
    float h = self.collectionView.frame.size.height / self.numberOfRows;
    float newH = h;
// Adjust height of the frame if we need raggy style
    if (self.variableFrontierHeight) {
        if (currentRow == 0) {
            float space = arc4random() % self.randomFirstRowVar;
            if (self.prevWasTallFirst) {
                space += self.fixedFirstRowVar;
            }
            self.prevWasTallFirst = !self.prevWasTallFirst;
            y += space;
            newH -= space;
        } else if (currentRow == self.numberOfRows - 1) {
            float space = arc4random() % self.randomLastRowVar;
            if (self.prevWasTallLast) {
                space += self.fixedLastRowVar;
            }
            self.prevWasTallLast = !self.prevWasTallLast;
            newH -= space;
        }
    }
// Assure that we have preferred height more than 1
    h = h <= 1 ? 1.f : h;
// Adjust frame width with new value of height to save cell's right proportions
    w = w * newH / h;
// Save new calculated data ad return
    [self.edgeXPositions replaceObjectAtIndex:currentRow withObject:[NSNumber numberWithFloat:x + w]];
    CGRect currentRect = CGRectMake(x, y, w, newH);
    NSValue *value = [NSValue valueWithCGRect:currentRect];
    [self.indexPathsByFrame setObject:path forKey:value];
    [self.framesByIndexPath setObject:value forKey:path];
    return currentRect;
}

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

Вот и он

[просматриваем NSMutableDictionary, в котором мы сохранили рассчитанные ранее фреймы по ключам indexPath, если находим — победа, больше делать ничего не надо]

[если расчетов не избежать, прикидываем, в каком ряду правая граница последнего фрейма находится ближе всего к началу таблицы — туда-то нам и нужно будет поставить текущий элемент (такая координата x для каждого ряда хранится в NSMutableArray edgeXPositions)]

[теперь, зная ряд и позицию по оси X, зная требуемую делегатом ширину для элемента, можем рассчитать его положение его верхнего левого угла; зная высоту коллекции и количество строк, вычисляем заодно и высоту элемента]

[если пресловутые «рваные края» нам нужны, и ряд является первым или последним, немножечко уменьшаем рассчитанную высоту]

[перестраховываемся на случай, если высота получается меньше единицы, уменьшаем ширину пропорционально уменьшению высоты]

[сохраняем полученное значение в словари indexPathsByFrame и framesByIndexPath для быстрого доступа в дальнейшем и возвращаемся из метода]

Кстати, надо не забыть очистить все эти indexPathsByFrame, framesByIndexPath и что-там-еще-закешированно в методе invalidateLayout. Естественно, не упустив [super invalidateLayout].

Вернемся к contentSize'у. Очевидно, в нашем случае с горизонтальным скролом, он должен выглядеть примерно так:

- (CGSize)collectionViewContentSize {
    return CGSizeMake(self.edgeX, self.collectionView.frame.size.height);
}

где edgeX — координата по X самой далеко расположившейся ячейки. Ведь мы же уже знаем, как расположились все ячейки. Или не знаем. Или все-таки знаем… Чтобы быть уверенным, нужно переопределить метод prepareLayout, не забыв вызвать в нем [super prepareLayout], и рассчитать фреймы для каждой ячейки

- (void)prepareLayout {
    [super prepareLayout];
// calculate and save frames for all indexPaths. Unfortunately, we must do it for all cells to know content size of the collection
    for (int i = 0; i < [self.collectionView.dataSource collectionView:self.collectionView numberOfItemsInSection:0]; i++) {
        NSIndexPath *path = [NSIndexPath indexPathForItem:i inSection:0];
        [self frameForIndexPath:path];
    }
}

Да, при наличии сотни тысяч ячеек, коллекция не очень-то будет торопиться загрузиться, но иного тривиального выхода я не вижу.
И в конце концов, остается переопределить последний метод — layoutAttributesForElementsInRect. В нем нужно вернуть атрибуты для всех элементов, которые попадают в данную область. Вызывается он каждый раз, когда коллекция проскролливается на размер своего фрейма. Потом, кажется, все это кешируется, так что вызван метод будет всего contentSize.width / frame.size.width раз.
Моя реализация, что называется, «в лоб»: просматриваем фреймы для каждого элемента, если они пересекаются с данной областью, — добавляем в возвращаемый массив.

- (NSArray*)layoutAttributesForElementsInRect:(CGRect)bounds {
    if (CGRectEqualToRect(bounds, self.previousLayoutRect)) {
        return self.previousLayoutAttributes;
    }
    [self.previousLayoutAttributes removeAllObjects];
    self.previousLayoutRect = bounds;
    NSArray *allFrames = self.framesByIndexPath.allValues;
    for (NSValue *frameValue in allFrames) {
        CGRect rect = [frameValue CGRectValue];
        if (CGRectIntersectsRect(rect, bounds)) {
            [self.previousLayoutAttributes addObject:[self layoutAttributesForItemAtIndexPath:[self.indexPathsByFrame objectForKey:[NSValue valueWithCGRect:rect]]]];
        }
    }
    return self.previousLayoutAttributes;
}

Ого, картинка!

Рисунок #1 — Вуаля

Сразу после того, как эйфория по поводу того, что все работает как надо, прошла, мой внутренний трудоголик вынес вердикт: оптимизировать! Но тот внутренний я, который оказался сильнее трудоголика, раздобыл из недр памяти словосочетание «преждевременная оптимизация» и, прикрываясь тем, что и при десяти тысячах элементов тестирование на iPad 2 не выявило никаких подтормаживаний, постановил отложить оптимизацию на-когда-нибудь-потом.

Послесловие

Надеюсь, что кому-нибудь было интересно, кому-нибудь — полезно, а у остальных просто хватило терпения прочитать этот пост. Спасибо за внимание.
Напоследок несколько ссылок:

Автор: tralf

Источник



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