Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

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

Anna | 2.07.2014 | нет комментариев
Я тружусь 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 (внутренние отступы для ячейки), куда же без него.
Ну и безусловно же качество, хранящее число рядов — гулять так гулять, пускай класс будет универсальным, а не только для 3 рядов!

@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 не совпадает с нынешними границами коллекции.
Оставим легкий в реализации 1-й способ на потом, и перейдем к способу 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 не выявило никаких подтормаживаний, постановил отложить оптимизацию на-когда-нибудь-потом.

Послесловие

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

 

Источник: programmingmaster.ru

 

Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB