- PVSM.RU - https://www.pvsm.ru -
Всем хэй хо!
Я работаю ios-разработчиком в провинциальном городе провинциальной страны ближайшего (по отношению к России) зарубежья. Около полутора лет назад страна решила, что я ей чего-то должен, а конкретно: должен год своей жизни, год низкоквалифицированного труда, год мечтаний о возвращении домой, к семье и работе… — одним словом, меня призвали в армию. И за этим делом я как-то пропустил выход iOS 6 со всеми ее фичами, в том числе и давно назревшего UICollectionView.
Разделавшись с нарядами, полигонами, уставом и прочими увлекательными вещами, я вернулся домой, снова начал работать, и конечно же проект, в котором заказчику было нужно отображение данных в виде того, что дизайнеры называют, «pinterest board», то есть собственно UICollectionView, не заставил себя ждать.
Проект является чем-то вроде iPad-каталога для аукционов одной компании, занимающейся оценкой антиквариата. Понятия не имею, как ребята отнесутся к их упоминанию на хабре, поэтому не буду давать ни ссылок, ни макеты дизайна, ни реальных скриншотов.
Приложение, в принципе, не сложное, из дизайнерских изысков только одна вещь меня немного насторожила — вид главного экрана. Он должен был представлять собой коллекцию изображений лотов, расположенных в три горизонтальных ряда, с горизонтальной прокруткой. Средний ряд с фиксированной высотой элемента и произвольной шириной (в зависимости от пропорций изображения). Высота элементов первого и третьего рядов должна немного варьироваться в меньшую сторону, создавая как бы рваные края. Если вам не совсем понятно мое сухое описание буквами, посмотрите на скриншот в конце статьи — образец конечного результата — он вам должен объяснить все. Или вот тут.
Это была первая попытка потратить минимум усилий. Поскольку я еще ни разу не сталкивался с этими элементами, моя надежда на класс от Apple была крайне сильна. Для начала я вообще забил на рваные края, сосредоточившись просто на выводе горизонтального «пинтерестбоарда». К сожалению, проскочить на шарике не удалось.
Вы можете видеть на скриншоте две вещи.
То ли мои навыки гугления крайне слабы, то ли RFQuiltLayout [2] — единственное готовое решение, которое вроде бы катит в моем случае.
Этот класс использует переменную 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 [3] и упомянутый выше код RFQuiltLayout (кстати, хочется выразить благодарность его автору Bryce Redd).
Сразу извиняюсь за стремное название класса.
Для начала я определил протокол для его делегата
@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 [4] для каждого элемента. Объекты класса 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;
}
На тот случай, если мои макароны кода и полуанглийские комментарии не очень понятны, постараюсь внести больше хаоса ясности псевдокодом:
[если расчетов не избежать, прикидываем, в каком ряду правая граница последнего фрейма находится ближе всего к началу таблицы — туда-то нам и нужно будет поставить текущий элемент (такая координата 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;
}
Сразу после того, как эйфория по поводу того, что все работает как надо, прошла, мой внутренний трудоголик вынес вердикт: оптимизировать! Но тот внутренний я, который оказался сильнее трудоголика, раздобыл из недр памяти словосочетание «преждевременная оптимизация» и, прикрываясь тем, что и при десяти тысячах элементов тестирование на iPad 2 не выявило никаких подтормаживаний, постановил отложить оптимизацию на-когда-нибудь-потом.
Надеюсь, что кому-нибудь было интересно, кому-нибудь — полезно, а у остальных просто хватило терпения прочитать этот пост. Спасибо за внимание.
Напоследок несколько ссылок:
Автор: tralf
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ios/51875
Ссылки в тексте:
[1] основоположника супрематизма: http://ru.wikipedia.org/wiki/%D0%9C%D0%B0%D0%BB%D0%B5%D0%B2%D0%B8%D1%87,_%D0%9A%D0%B0%D0%B7%D0%B8%D0%BC%D0%B8%D1%80_%D0%A1%D0%B5%D0%B2%D0%B5%D1%80%D0%B8%D0%BD%D0%BE%D0%B2%D0%B8%D1%87
[2] RFQuiltLayout: https://github.com/bryceredd/RFQuiltLayout
[3] документация от Apple: https://developer.apple.com/library/ios/documentation/WindowsViews/Conceptual/CollectionViewPGforIOS/CreatingCustomLayouts/CreatingCustomLayouts.html#
[4] UICollectionViewLayoutAttributes: https://developer.apple.com/library/ios/documentation/UIKit/Reference/UICollectionViewLayoutAttributes_class/Reference/Reference.html
[5] Гайд от Apple: https://developer.apple.com/library/ios/documentation/WindowsViews/Conceptual/CollectionViewPGforIOS/CreatingCustomLayouts/CreatingCustomLayouts.html
[6] Гайд от какого-то чувака: http://www.skeuo.com/uicollectionview-custom-layout-tutorial
[7] Мой код на гитхабе: https://github.com/tralf/SKRaggyCollectionViewLayout
[8] Источник: http://habrahabr.ru/post/207856/
Нажмите здесь для печати.