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

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

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



В iOS5 ввели замечательную фичу — Storyboard, а также возможность создавать прототипы ячеек прямо внутри создаваемой таблицы (которые, тем не менее, компилятся в отдельные NIB-ы). Однако новый функционал решили не внедрять в обычные XIB-ы.

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

Так мы подошли к вопросу «а зачем»:

Допустим, есть два больших сториборда. Почему два? Потому что, если пихать гору вьюшек, кнопочек и табличек в один, то даже новенький и резвый (правда год назад) MacBook Pro Retina 13" превращается в тыкву.

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

Пытливый читатель заметит, что в iOS9 ввели ссылки на внешний storyboard, но что если проект требует поддержки iOS8, а то и 7? К сожалению, обратной совместимости ребята из Купертино добавлять не любят.

Возможным решением будет создать ViewController без View и внешний Xib с таким же именем класса, чтобы он автоматически подгрузился методом loadView. Или явно устанавливаем свойство nibName:


Кажется, раньше для этого было явное поле, но в Xcode 7 я его не нашел.

И тут мы сталкиваемся с проблемой, что прототипы ячеек для таблицы придется класть во внешние Xib-ы, причем по одному в файл, и явно регистрировать их в коде. После использования Storyboard совсем не хочется так делать.

И вот как можно поступить (код будет на Objective C, так как далее используется чёрная магия):

Создаем .h файл со следующим содержанием:
@interface UITableView (XibCells)
@property (nonatomic, strong) IBOutletCollection(UITableViewCell) NSArray* cellPrototypes;
@end

Это позволит закинуть в файл Interface Builder ячейки (правда, не внутрь таблицы, а рядом), и подключить их к IBOutletCollection cellPrototypes



Теперь надо как-то подсунуть загруженные ячейки таблице, чтобы она их подгружала по мере необходимости.
Для этого в .m файле создадим наследника UINib с предварительно загруженными данными и переопределим метод instantiateWithOwner:options:
@interface PrepopulatedNib: UINib
@property (nonatomic, strong) NSData* nibData;
@end

@implementation PrepopulatedNib
+ (instancetype)nibWithObjects:(NSArray*)objects {
  PrepopulatedNib* nib = [[self alloc] init];
  nib.nibData = [NSKeyedArchiver archivedDataWithRootObject:objects];
  return nib;
}
- (NSArray *)instantiateWithOwner:(id)ownerOrNil options:(NSDictionary *)optionsOrNil {
  return [NSKeyedUnarchiver unarchiveObjectWithData:_nibData];
}
@end

При инициализации объекта PrepopulatedNib переданный массив архивируется в NSData с помощью NSKeyedArchiver.
Далее UITableView вызывает метод instantiateWithOwner:nil options:nil, и мы разархивируем массив обратно, создавая таким образом копию объектов. Ячейки, полученные таким образом 100% идентичны, так как только что были разархивированы из NIB-a и соответствуют протоколу NSCoding.

Последний штрих: заставить таблицу связать переданные ячейки и PrepopulatedNib:
@implementation UITableView (XibCells)
- (void)setCellPrototypes:(NSArray*)cellPrototypes {
  for (UITableViewCell* cell in cellPrototypes) {
      [self registerNib:[PrepopulatedNib nibWithObjects:@[cell]] forCellReuseIdentifier:cell.reuseIdentifier];
  }
}
@end

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

Итак, об автоматической калькуляции высоты ячеек
В iOS8 наконец-то ввели out-of-the-box вычисление высоты ячеек при использовании Layout Constraints (хотя и глючило оно сильно). В iOS9 эту функцию отполировали и добавили Stack Views. Опять же ни о какой обратной совместимости речи нет.

Предлагаю удобное решение этой задачи с использованием кода для подгрузки ячеек из одного XIB-а

Одним из способов вычисления высоты является хранение по одному невидимому экземпляру UITableViewCell с установленными constraint-ами. Для этого в процедуре tableView:heightForRowAtIndexPath: в таком экземпляре устанавливается item/text будущей ячейки, и, после вызова метода [cell layoutIfNeeded], возвращается cell.frame.size.height.

Воспользуемся нашими предзагруженными ячейками для этого способа. Для этого будем хранить ячейки в NSDictionary, ассоциированном с таблицей. Для этого нужно добавить в .m файл инструкцию
#import <objc/runtime.h>

В методе setCellPrototypes: создадим NSDictionary с ячейками, где ключ — reuseIdentifier:
@implementation UITableView (XibCells)
static char cellPrototypesKey;
- (void)setCellPrototypes:(NSArray<UITableViewCell *> *)cellPrototypes {
  NSMutableDictionary* dict = [NSMutableDictionary dictionaryWithCapacity:cellPrototypes.count];
  for (UITableViewCell* cell in cellPrototypes) {
      [self registerNib:[PrepopulatedNib nibWithObjects:@[cell]] forCellReuseIdentifier:cell.reuseIdentifier];
      dict[cell.reuseIdentifier] = cell;
  }
  objc_setAssociatedObject(self, &cellPrototypesKey, cellPrototypes, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSArray*)cellPrototypes { return nil; } //Чтобы не было warning-a
- (UITableViewCell *)cellPrototypeWithIdentifier:(NSString *)reuseIdentifier {
  NSDictionary* dict = (NSDictionary*)objc_getAssociatedObject(self, &cellPrototypesKey);
  return dict[reuseIdentifier];
}
@end

Объявление cellPrototypeWithIdentifier: нужно будет вынести в .h файл, чтобы его можно было использовать в коде.
@interface UITableView (XibCells)
@property (nonatomic, strong) IBOutletCollection(UITableViewCell) NSArray* cellPrototypes;
- (UITableViewCell*)cellPrototypeWithIdentifier:(NSString*)reuseIdentifier;
@end

Теперь в коде datasource можно использовать прототипы для вычисления высоты:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    id cellItem = _items[indexPath.section][indexPath.row];
    MyTableViewCell* cell = [tableView cellPrototypeWithIdentifier:@"Cell"];
    cell.item = cellItem;
    [cell layoutIfNeeded];
    return cell.frame.size.height;
}


Код нарочно не представляет собой all-in-one решения, так как является Proof of concept и предоставляется исключительно в ознакомительных целях.

Спасибо за внимание.

Комментарии (3)


  1. nepster
    25.11.2015 22:41

    Пытливый читатель заметит, что в iOS9 ввели ссылки на внешний storyboard, но что если проект требует поддержки iOS8, а то и 7? К сожалению, обратной совместимости ребята из Купертино добавлять не любят.

    под iOS8 работает :)


  1. aspcartman
    26.11.2015 00:35
    +3

    Статья рассказывает о двух (имхо — совершенно) не связанных задачах и их так же не связанных решениях. Думаю было бы лучше разобрать что-то одно, но очень подробно, в техническом плане: не просто, куда нажать, чтобы заработало, а глубокий экскурс в то, как это вообще работает.

    Пояснение:
    Я не пользуюсь сторибордами, ксибами и строю весь интерфейс на аутолейауте с помощью KeepLayout. Сторонник мнения, что сториборды — зло. По этому вопросы могут показаться странными.

    Вопросы:
    1. Почему не реализовать нужные клетки в отдельных ксибах, по старинке, и просто зарегистрировать нужные классы так же, как вы делаете в статье? Кода, как либо связанного с IBOutletCollection не будет, и клетки могут быть использованы в разных контроллерах, без какой-либо привязки к сторибордам.

    2. Что значит

    И тут мы сталкиваемся с проблемой, что прототипы ячеек для таблицы придется класть во внешние Xib-ы, причем по одному в файл, и явно регистрировать их в коде. После использования Storyboard совсем не хочется так делать.
    ? Вы же именно это и делаете, регистрируете каждый по отдельности, только создаете ксибу для целого вью контроллера. А если клетки используются в разных контроллерах? Таким образом вы жестко привязали клетки к классу контроллера, но с теоретической стороны связи быть не должно и потребуются дополнительные телодвижения для того, чтобы позже это исправить, плюс вы уже совершили больше телодвижений, чем можно было для достижение цели строить интерфейс графически.

    3. Что значит
    Теперь таблица может работать, как будто ее загрузили из Storyboard.
    ? В чем отличие поведения UITableView, созданной в сториборде, от онной, созданной в коде?

    4.
    - (NSArray*)cellPrototypes { return nil; } //Чтобы не было warning-a
    
    Серьезно? Вы только что все сломали. Обьясните, пожалуйста, читателям, почему класс, которому добавили аксессорный метод, возвращающий nil, когда либо будет возвращать по этому проперти не nil? Расскажите, как работает Extensions в Obj-C? Метод, который я имплементировал в Extension, когда и как добавляется в класс? Расскажите, как работает IBOutlet/IBOutletCollection для property? При загрузке из nib выполняется -setPropertyName: или же в рантайме имплементируется геттер -propertyName? Если первое, то почему нет краша, ведь поведение проперти по умолчанию это
    @sythnesize propertyName = _propertyName;
    
    , а ClassExtensions не умеет добавлять iVar'ы в класс по очевидным причинам, и для Extensions пропертя делается (скорее всего, я не компилировал такой случай на моей практике без имплементации аксессорных методов в extension, чтобы посмотреть, что будет)
    @dynamic propertyName
    

    и ожидает, что вы имплементируете свои методы, и если вы этого не сделаете, то будет краш. Если же второе, то почему ваша имплементация, если код рабочий, оверрайдится имплементацией для IBOutlet? Это противоречит ожидаемому поведению dynamic пропертей в Foundation и ко. Например NSManagedObject в CoreData. Да и о каком конкретно ворнинге речь, и почему вы просто от него отмахиваетесь?

    Предложения:
    5. Все связанное с высотой клеток, имхо, стоит вынести в классы самих клеток, а точнее имплементировать их суперкласс, который будет хранить «невидимые» клетки по именам классов и блоки их инициализаций, заместо нагружения лишним кодом ваш контроллер.

    6.
    static char cellPrototypesKey;
    // ...
    objc_setAssociatedObject(self, &cellPrototypesKey, cellPrototypes, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    
    Вы только что создали бестолковую переменную, которая торчит в скопе всего файла. Вместо этого предлагается (http://nshipster.com/associated-objects/) делать
    objc_setAssociatedObject(self, @selector(getterName), cellPrototypes, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    


    1. mallexxx
      26.11.2015 09:28
      +1

      Статья рассказывает о двух (имхо — совершенно) не связанных задачах

      Действительно, вторая задача всплыла спонтанно при написании статьи. Возможно, вы правы, но статья не задумывалась как пособие для совсем новичков (о чем я честно и предупреждаю в начале), а чтобы показать как можно (если очень хочется) решить данные конкретные задачи.
      А заодно возможно кому-то привести пример использования наследования, категорий(extensions), outlet-collections, и одного из способов вычисления высоты ячеек.
      Почему не реализовать нужные клетки в отдельных ксибах

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

      Чтобы иметь возможность проставить аутлет в Interface Builder нужна пропертя, по сути только сеттер, мы этот массив в таблице не храним и геттер нам не нужен. В категории (exntension) ivar-ы не сгенерятся, как вы верно заметили, и будет варнинг, что геттера нет, и его вызов приведет к крашу.
      Все связанное с высотой клеток, имхо, стоит вынести в классы самих клеток

      Согласен, иногда так лучше. Но для простых ячеек я предпочитаю не плодить сущности, а просто накидать лейблов и загружать их по тэгу (да-да, я знаю что Apple на WWDC сказала никогда-никогда так не делать, но что страшного если в ячейке 2 лэйбла?)
      Вы только что создали бестолковую переменную, которая торчит в скопе всего файла

      Спасибо, действительно, учту, не думал о такой реализации.