Здравствуйте!
— Не хотите ли войти
Оглавление

Трудно ли сделать хорошую программу?

7 января 2011
Уровень сложности: для разработчиков с небольшим опытом

Аннотация

Ответ, в принципе, простой. Очень трудно. Но во что это все выливается? В физический труд мешкотаскания, то есть кодописания? Или самое сложное — дизайн? Или алгоритмическая основа? Что?

Некоторое время назад в стор попала моя очередная программа, которую я делал «для себя». То есть так, чтобы мне было приятно. Мне также очень повезло, что над этой программой удалось поработать с очень крутым дизайнером, Ильей Бирманом. Он обеспечивал дизайн и концепцию интерфейса, а я — реализовывал его безумные идеи и кодил код.

Программа эта называется «Правила русского языка» и представляет собой офлайновую версию сайта therules.ru. Если ее запустить, то все кажется предельно просто. Но за этой простотой, как обычно… за ней таится страшное. Страшно много работы. Страшно много решений. Страшно много вопросов, которые требуют ответа. И страшно много увлекательного.

Я расскажу несколько эпизодов из разработки, которые, надеюсь, помогут и как советы разработчикам, и просто как веселые истории.

В этой статье посмотрим на разные элементы интерфейса, какие проблемы с ними были и какие решения получились. А в следующей — сосредоточимся на текстовых элементах.

Полоска сверху

Илья нарисовал отличный UINavigationBar, серо-металлический. Такого цвета фон у UISearchBar'ов в iOS. А еще он придумал совместить UINavigationBar и UISearchBar, чтобы зря место не терять.

Хорошая идея, но напрямую не решается. «Почему же?, — скажет разработчик, — поставил tintColor и вперед! Добавил UISearchBar, как titleView, и готово!» Во-первых, хотелось сохранить цвет кнопок (стандартный синий), так как то, что получается при смене tintColor на нужный, мягко говоря, некрасиво.

Во-вторых, если класть UISearchBar как titleView в UINavigationBar, то по бокам остаются дырки (видимо, оставляемые системой для кнопок), которые ну совсем никуда не годятся.

Придумалось (в общем-то, это обычная практика) класть UISearchBar в UINavigationBar напрямую, как сабвью. Возникает, правда, интересный сайд-эффект, заключающийся в том, что сёрчбар не уезжает при переходе на более глубокий уровень навигации. Что вполне логично (если понимать механизм работы навигейшн-бара), но нужно что-то с этим делать, так как это никуда не годится. Как решить эту пачку проблем? Несложно. Нужно только знать один хак, немножко почитать документацию и экспериментально подобрать параметры анимации.

Во-первых, кладем-таки UISearchBar в UINavigationBar. Делаем это в рутовом контроллере навигейшн-бара, причем в тот момент, когда первый раз получаем сообщение viewWillAppear. Это необходимо, чтобы уже был создан UINavigationController и проставлены на него ссылки в контроллере. В частности, из-за последней проблемы нельзя, например, делать это в viewDidLoad, как хочется изначально.

- (void)viewWillAppear:(BOOL)animated {
   [super viewWillAppear:animated];

   if (_searchBar == nil) {
       _searchBar = [[UISearchBar alloc] init];
   }

   if (_searchBar.superview != self.navigationController.navigationBar) {
       _searchBar.frame = CGRectMake(0, 0, 
             self.navigationController.navigationBar.frame.size.width, 
             self.navigationController.navigationBar.frame.size.height);
       [self.navigationController.navigationBar addSubview:_searchBar];
   }
}

Полдела сделано, теперь нужно научиться скрывать/показывать UISearchBar также, как это делает навигейшн контроллер. Добавляем код в viewWillAppear, и в viewWillDisappear

- (void)viewWillAppear:(BOOL)animated {
   // ... тут - почти тот же код, что из предыдущего примера

   if (animated) {
       [UIView beginAnimations:@"searchBar" context:nil];
       [UIView setAnimationDuration:0.35];
   }

   _searchBar.alpha = 1;
   _searchBar.frame = CGRectMake(0, 0, 320, _searchBar.frame.size.height);

   if (animated) {
       [UIView commitAnimations];
   }
}
- (void)viewWillDisappear:(BOOL)animated {
   [super viewWillDisappear:animated];

   if (animated) {
       [UIView beginAnimations:@"searchBar" context:nil];
       [UIView setAnimationDuration:0.35];
   }

   _searchBar.alpha = 0;
   _searchBar.frame = CGRectMake(-320, 0, 320, 
                        _searchBar.frame.size.height);

   if (animated) {
       [UIView commitAnimations];
   }
}

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

Так, я забыл про сам цвет бара. С ним тоже проблемы. Я уже говорил про цвет кнопок. Туда же прилетает цвет заголовка (белый на сером — ужас-ужас).

Как это все решить? Цвет заголовка меняется кастомными titleView. Создаются они примерно вот так:

UILabel *_titleView = 
   [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 150, 42)];
_titleView.opaque = NO;
_titleView.backgroundColor = [UIColor clearColor];
_titleView.textAlignment = UITextAlignmentCenter;
_titleView.numberOfLines = 1;
_titleView.font = [UIFont boldSystemFontOfSize:20];
_titleView.textColor = [UIColor blackColor];
_titleView.adjustsFontSizeToFitWidth = NO;
_titleView.shadowColor = 
   [UIColor colorWithRed:1 green:1 blue:1 alpha:0.5];
_titleView.shadowOffset = CGSizeMake(0, 1);
_titleView.alpha = 1;
self.navigationItem.titleView = _titleView;
[_titleView release];

Нужно отметить одну смешную вещь. Я сначала расширил titleView на всю ширину, то есть, 320 поинтов (помним, что теперь, из-за ретины, рисуем все в поинтах, а не пикселях, да?). И получил сдвинутый относительно центра заголовок. Судя по-всему, навигейшн бар центрирует саму titleView, как может. А если она не помещается, то сжимает до максимально возможного размера. Если справа и слева кнопки разной величины (или одна есть, а другой — нет), то вьюшка занимает все оставшееся место. И, естественно, заголовок, отцентрированный в неотцентрированной вьюшке, оказывается не по-центру.

Именно поэтому ширина titleView подобрана так, чтобы и заголовок помещался, и iOS смогла бы его отцентрировать.

Вернемся к цвету заголовка. Я пробовал класть в навигейшн бар вьюху (как UISearchBar). Оказалось, что это не работает (иногда, например, тайтлВью, пропадает). Зато на просторах интернета нашелся великолепный хак. Выглядит это так:

@implementation UINavigationBar (CustomBackground)

- (void)drawRect:(CGRect)rect {
   UIImage *image = [UIImage imageNamed:@"toolbarbg.png"];
   [image drawInRect:CGRectMake(0, 0, self.frame.size.width, self.frame.size.height)];
}

@end

Тут toolbarbg.png — вертикальная полоска с градиентом. А сам код я поместил в AppDelegate.

С навигейшном, кажется, все. Перейдем к забавным фоновым рисункам.

Фон под табличками и UIWebView

Сделать кастомный фон подо всеми вьюшками — следующая задача, стандартный серый — это же скучно!

Для установки фона под UITableView — нужно сделать две вещи.

  • Поставить ему backgroundColor в [UIColor clearColor].
  • Подложить правильную вьюху под таблицу.

Правда, возникает проблема (а как же без нее). Фон начинает просвечивать под ячейками тоже. Разделители при этом остаются, получается совсем погано. Ну, не беда, поставим в ячейки в backgroundView непрозрачную вьюшку. Но что делать, если ячейки не закрывают экран целиком? Я с этим столкнулся в экране просмотра темы (на главном экране ячеек хватает), и у меня было два варианта. Первый — сделать несколько фейковых ячеек, как это обычно делает стандартный UITableView. Второй — подкладывать некую белую вьюшку под пустые ячейки и двигать ее одновременно со скроллингом таблицы. Правда, не смотря на то, что вариантов два, я быстро придумал и сделал третий — одну белую ячейку снизу под остальными, которая дополняет при необходимости таблицу до высоты родительской вьюшки. Код выглядит примерно так:

Сначала мы возвращаем на одну строку больше, чем у нас есть.

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
   NSInteger count = (NSInteger)[_topic paragraphsAndChaptersCount];
   return count + 1; // обратите внимание на эту "+1"
}

Потом правильным образом кэшируем ячейки, выбрав корректный идентификатор (ячеек на этом экране два типа, параграфы и разделы, этим объясняется не два идентификатора, учитывая пустой вариант, а три):

NSString *CellIdentifier = paragraph.isChapter ? @"Paragraph Cell" : @"Chapter Cell";
if (paragraph == nil) {
   CellIdentifier = @"Empty Cell";
}

В методе, вычисляющем высоту ячейки, выдаем «высота вьюхи минус размер всех». Количество ячеек невелико, иначе пришлось бы кэшировать высоту ячеек с текстом и кода было бы чуть больше, но тут я поленился.

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
   if (indexPath.row == [_topic paragraphsAndChaptersCount]) {
       CGFloat previousSizes = 0;
       for (int i = 0; i < indexPath.row; i++) {
           previousSizes += [self tableView:tableView heightForRowAtIndexPath:[NSIndexPath indexPathForRow:i inSection:indexPath.section]];
       }

       if (tableView.bounds.size.height > previousSizes) {
           return tableView.bounds.size.height - previousSizes;
       } else {
           return 0;
       }
   } else {
       ... // тут код расчета высоты обычной ячейки
   }
}

Работает этот странный код совершенно великолепно, что несколько удивительно и приятно.

Забавный эпизод получился с UIWebView. Я, намучавшись с UITableView, стал делать там фоновую картинку аналогичным образом. То есть прозрачность, вьюшка под низ, все дела… и не как не мог добиться нормального поведения. Всегда были ситуации, когда часть белого фона страницы пропадала и из-под нее просвечивал фон. И только потом, перепробовав кучу вариантов, заметил, что это — штатная фича, нужно лишь проставить backgroundColor в паттерн с нужной картинкой.

_webView.backgroundColor = [UIColor colorWithPatternImage:[UIImage imageNamed:@"bg.jpg"]];

Все тут же заработало, а я избавился от кучки хаков. Приятно.

UISearchViewController

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

Для начала, пришлось избавиться от стандартной таблицы и стандартного «засерения». Так как у нас были некоторые элементы, которые с ними плохо дружили — раз, и нам не нравилась анимация пропадания всего того, что было напридумывано. Плюс, если результатов нет, в этой таблице появляется надпись «No Results», которая очень плохо смотрелась в полностью тогда русской программе (но с английской локализацией ОС), поэтому таблица проявилась своя. Благо, переключить с одной на другую почти элементарно. Я просто создал свой UITableViewController, добавил его tableView во вьюшку поискового контроллера и аккуратно двигаю его в зависимости от текущих действий пользователя.

Далее, нужно было разобраться с кнопкой Cancel. С тем, что она называется «Cancel» в английской локализации ОС — пришлось смириться (я знаю несколько хаков, но они все достаточно «грязные» и я использовать их не люблю), убирать ее не хотелось, но нужно было хотя бы поменять ее цвет.

И тут ужос с цветом, что ж такое?

Я уже про это писал в заметках, но это такой безумный хак, что нужно повторить. Итак, для того, чтобы фон под UISearchBar (и кнопкой Cancel) был серебряным, а сама кнопка Cancel — красивого темно-серого цвета, нужно поставить ей умолчальный стиль и сделать ей tintColor с нулевой прозрачностью! Это невозможно понять, можно только запомнить.

Кнопоськи примерчиков

Для примеров Илья придумал использовать кнопки, которые используются в iOS в СМС и в почтовой программе для обозначения получателей, вот такие:

Стандартного контрола нет, пришлось делать самому. Вроде бы нет ничего сложного. Переопределяем drawRect, рисуем там градиент, отклиппенный по кривому Path, сверху — линию по тому же пути, все? Конечно же нет! Проблема пришла откуда не ждали. Оказалось, что сделать ровные (попиксельно) края как-то слишком трудно. Была еще маленькая проблема с тем, что линия-граница у Apple — не сплошная, а градиентная. Рисовать я ее научился с помощью функции CGContextReplacePathWithStrokedPath(context) и последующей заливки, приятно, что об этом подумали.

Попробовав несколько способов выравнивания линий (по границе пикселей и по половинкам пикселей, там «есть нюансы»), понял, что все равно получается размытая граница. Помог клиппинг по кривой. То есть я:

  • устанавливаю клип для градиента заливки,
  • рисую фоновый градиент,
  • делаю клип для линии (этой функцией с непроизносимым названием),
  • рисую градиентную линию заливкой.

Подозреваю, что клиппинг (надо бы протестить, возможно, и не всегда) всегда обрезает точно по границам пикселей, без использования альфа-канала. В результате получились четкие границы. Смотрите, как красиво:

Регулярные выражения

Как там говорится…

Если у вас была одна проблема, и вы решили использовать регулярные выражения, то теперь у вас есть две проблемы.»

Но у меня выбора не было. В очень даже крутом, как выяснилось в процессе, алгоритме поиска Романа Парпалака, который тот сделал на PHP для сайта therules.ru, используются именно они. И, чтобы сделать поменьше ошибок при переделывании этого самого поиска (чтобы использовать те же индексы, что и на сайте, и так далее), было решено делать один-к-одному, то есть с регэкспами. Тут я готовился, как к войне, так как знал, что официальный API появился только в iOS 4, до этого были только огрызки, которые меня совершенно не устраивали.

Этапы разработки были следующие:

  • Сделать для iOS 4;
  • Выделить всю работу с регэкспами в отдельный класс;
  • Сделать этот класс работоспособным в iOS 3.

Первый этап достаточно прямолинеен, NSRegularExpression — великолепно отдокументированный класс, и достаточно грамотно продуманный. Выделение работы с регэкспами тоже не доставило проблем (благо вариаций использования регэкспов оказалось немного, а JetBrains CiDR к тому моменту уже заматерел для почти-не-падающего несложного рефакторинга). С третьим пунктом («сделать этот класс работоспособным»), тем не менее, пришлось немного повозиться.

Для начала была найдена библиотека, которая поддерживала регэкспы для iOS 3. Я попробовал посмотреть на «голые» PCRE и ICU, но остановился на более высокоуровневой библиотеке (и великолепно реализованной, надо сказать), которая называется RegexKitLite.

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

Итак, как же сделать код, работающий и там и там? Вот так:

  • делаем класс, который содержит нужные методы, а инициализируется строкой и параметрами выражения (вроде регистронезависимости, у меня получилось, что используются три параметра, их я и запоминаю);
  • в конструкторе проверяем, существует ли класс NSRegularExpression, запоминаем выбор, чтобы постоянно не выяснять, потом либо создаем экземпляр класса NSRegularExpression, либо просто запоминаем строку, чтобы воспользоваться категорией, которую добавляет в NSString библиотека RegexKitLite;
  • в методах проверяем опять же, есть ли нужный класс, после чего посылаем нужные сообщения нужным объектам. Естественно, пришлось написать немножко обвязки (вроде небольшого класса информации о найденной группе совпадения), чтобы за пределами класса NSRegularExpression не упоминался.

Инициализация получилась вот такой (зачем там рантайм? Можно и без него, но раз знаю…):


static BOOL useNSRegularExpression = NO;

- (id)initWithString:(NSString*)aString u:(BOOL)aU i:(BOOL)aI s:(BOOL)aS {
   static Class regexpClass = nil;

   if (regexpClass == nil) {
       regexpClass = NSClassFromString(@"NSRegularExpression");
       if (regexpClass != nil) {
           useNSRegularExpression = YES;
       }
   }

   if (self = [super init]) {
       if (useNSRegularExpression) {
           NSError *error;
           NSRegularExpressionOptions options = 0;
           options |= aU ? NSRegularExpressionUseUnicodeWordBoundaries : 0;
           options |= aI ? NSRegularExpressionCaseInsensitive : 0;
           options |= aS ? NSRegularExpressionDotMatchesLineSeparators : 0;

           NSMethodSignature *methodSignature = [NSMethodSignature signatureWithObjCTypes:"@@:@I^@"];
           NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
           [invocation setSelector:NSSelectorFromString(@"initWithPattern:options:error:")];
           [invocation setTarget:[regexpClass alloc]];
           [invocation setArgument:&aString atIndex:2];
           [invocation setArgument:&options atIndex:3];
           [invocation setArgument:&error atIndex:4];
           [invocation invoke];
           [invocation getReturnValue:&_regexp];

           _regexpString = [aString copy];
       } else {
           RKLRegexOptions options = 0;
           options |= aU ? RKLUnicodeWordBoundaries : 0;
           options |= aI ? RKLCaseless : 0;
           options |= aS ? RKLDotAll : 0;

           _regexpString = [aString copy];
           _regexOptions = options;
       }
   }

   return self;
}

Методы оказались вообще вполне прямолинейны:

- (NSString*)stringByReplacingMatchesInString:(NSString*)aString withTemplate:(NSString*)aTemplate {
   if (useNSRegularExpression) {
       return [_regexp stringByReplacingMatchesInString:aString options:0 range:NSMakeRange(0, [aString length]) withTemplate:aTemplate];
   } else {
       NSError *error;
       NSString *result = [aString stringByReplacingOccurrencesOfRegex:_regexpString withString:aTemplate options:_regexOptions range:NSMakeRange(0, [aString length]) error:&error];

       return result;
   }
}

Был еще вставлен код, который для RegexKitLite сверял результат с «эталонным» NSRegularExpression (то есть результат вычислялся два раза, обоими библиотеками, чтобы отловить возможные проблемы и несоответствия), но он отработал свое при разработке и был убит.

Итого

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

Сложно ли сделать хорошее приложение? Безумно сложно. Это ведь только часть вопросов, которые возникли, и только часть решений, сквозь которые пришлось пройти. Возможно, даже сами решения — далеко не оптимальные. Но в результате:

  • Получилась программа, которая работает, которой я лично вполне горжусь. В ней есть ошибки, это не страшно. Главное — программа быстро и красиво выполняет свою работу и не ставит на пути пользователя препятствий;
  • Удалось найти непростые решения, имея которые в голове, я теперь смогу быстрее разрабатывать гораздо более качественные приложения;
  • Я вспомнил некоторые правила русского языка. Это ж здорово!
  • Поработал с Ильей Бирманом. Это очень, очень круто. Спасибо!
Комментарии к документу
Зарегистрируйтесь или войдите, чтобы оставить комментарий.

А как сделать такие же section header как и у эппл? Нарисовать свой градиент (похожий на эппловский), засунуть его во вьюшку и вьюшку подсунуть под текст?
Или может все как то проще?

rusik

«Такие же» заголовки делаются соответствующими колбэками делегата. Посмотрите на секции в UITableView.

alex

Я немного не понял ваш ответ.

У делегата есть метод tableView:viewForHeaderInSection:, который возвращает нужный view для header. Но этот view ведь надо вручную создавать (подгонять фон, шрифт, тень и тд)? Или может уже есть какой то стандартный view для заголовка, похожий на эппловский?

rusik

- tableView:titleForHeaderInSection:  у DataSource.

alex

Круть! Спасибо!

rusik
© 2009-2012, ООО «Инру»
Вход
Имя пользователя:
Пароль:
Или…
Twi
Отмена
Войти
Восстановить забытый пароль…