- PVSM.RU - https://www.pvsm.ru -

Зачастую для чтения хабра я использую мобильное приложение Хабрахабр для iPhone и iPad. Оно достаточно удобное для чтения статей, но не очень удобное для написания комментариев, особенно если хочется написать что-нибудь этакое, с использованием тегов форматирования. Неудобно, потому что все теги необходимо набирать вручную, поэтому очень легко ошибиться и, как результат, оставить некрасивый комментарий.
Так у меня появилась идея написать свою клавиатуру, в которой по нажатию на клавишу добавляется открывающийся и закрывающийся тег в текстовое поле. Курсор при этом должен стать прямо между ними, чтобы сразу же приступить к написанию текста. Также необходимо иметь возможность перемещать курсор с помощью жестов свайпа, субъективно это удобней, чем тянуть палец к полю, ожидать появления лупы, перемещать палец и надеяться, что курсор попадет куда надо. И наконец, пора бы уже разобраться с тегами «Сарказм» и «Зануда», которые не поддерживаются парсером хабра. Клавиатура должна иметь специальные клавиши для этих целей, а оформление тегов должно быть конфигурируемым в настройках клавиатуры, чтобы каждый мог указать тот вид, который ему нравится.
С выходом iOS 8 Apple открывает новый API, который позволяет разрабатывать расширения к приложениям. Клавиатура (Custom Keyboard) является одним из представителей таких расширений. О ней и пойдет речь. В статье вы узнаете о том, какие возможности, ограничения и баги предоставляет новый API, как разработать хабраклавиатуру, и как сделать так, чтобы ваша клавиатура появилась в AppStore, а следовательно и на устройствах ваших пользователей.
Открыв доступ к API для создания сторонних клавиатур, Apple построила узкий мостик между приложениями. С одной стороны каждое приложение по-прежнему находится в своей песочнице, но с другой — введенные данные в одном приложении теперь могут попадать в другое, либо напрямую отправляться на сервер. Такая функциональность является достаточно серьезной с точки зрения безопасности пользовательских данных, поэтому Apple строго определила, что можно делать, а что нельзя. Прежде, чем перейти к детальному описанию, хочу пояснить, что все типы расширений, в том числе и клавиатура, могут быть установлены на мобильное устройство только в составе основного приложения (приложения-контейнера). Например, в последнюю версию приложения Хабрахабр было добавлено расширение в виде виджета в Notification Center.
И так, какие же возможности мы имеем:
Это, собственно, все основные возможности. Перейдем к ограничениям:
secureTextEntry = YES). То есть, если поле предназначено для ввода пароля, то пользователь сможет воспользоваться только стандартной клавиатурой;UIKeyboardTypePhonePad и UIKeyboardTypeNamePhonePad;application:shouldAllowExtensionPointIdentifier: протокола UIApplicationDelegate. Для идентификатора с именем UIApplicationKeyboardExtensionPointIdentifier необходимо возвращать NO. К слову, для iOS 8 это единственный тип расширений, который можно запретить использовать.Полная документация по разработке кастомных клавиатур: Custom Keyboard [1]
С теорией разобрались, переходим к практике.
Создаем новый проект, выбираем Application, все стандартно.

Далее необходимо добавить новый Target «Custom Keyboard».

В результате Xcode генерирует класс – наследник от UIInputViewController и Info.plist.
Класс UIInputViewController является контроллером клавиатуры. Все взаимодействие с полем ввода происходит через него. Рассмотрим интерфейс класса более подробно.
Основные методы:
- (void)dismissKeyboard – позволяет скрыть клавиатуру. Это та возможность, которая отсутствует во всех стандартных клавиатурах в iPhone;- (void)advanceToNextInputMode – выполняет отображение следующей клавиатуры. Список доступных клавиатур определяется пользователем в настройках устройства;- (void)requestSupplementaryLexiconWithCompletion:(void (^)(UILexicon *))completionHandler – предоствляет массив пар строк. Каждая пара состоит из строки, которую может ввести пользователь userInput и строки, которая является автодополнением или автокоррекцией documentText. Например, на моем iPhone этот метод возвращает 151 пару.
Для взаимодействия с полем предоставляется свойство textDocumentProxy. Приведу описание только наиболее важных для разработки методов:
- (void)adjustTextPositionByCharacterOffset:(NSInteger)offset – позволяет управлять курсором;- (NSString *)documentContextBeforeInput – возвращает строку до курсора;- (NSString *)documentContextAfterInput – возвращает строку после курсора;- (void)insertText:(NSString *)text – вставляет строку после курсора;- (void)deleteBackward – удаляет один символ перед курсором;- (UIKeyboardAppearance)keyboardAppearance – позволяет определить, какая тема используется: светлая или темная;- (UIKeyboardType)keyboardType – позволяет определить, какой тип клавиатуры требует поле ввода.
Помимо выше описанных методов класс UIInputViewController реализует протокол UITextInputDelegate:
@protocol UITextInputDelegate <NSObject>
- (void)selectionWillChange:(id<UITextInput>)textInput;
- (void)selectionDidChange:(id<UITextInput>)textInput;
- (void)textWillChange:(id<UITextInput>)textInput;
- (void)textDidChange:(id<UITextInput>)textInput;
@end
Вызовы этих методов должны сообщать о выделении и изменении текста в поле ввода, при этом объект textInput должен предоставлять информацию о самом поле ввода и тексте, который он содержит.
Но по факту мы имеем следующее поведение:
textInput всегда nil.Похоже на баг. На Stackoverflow люди пишут, что столкнулись с такой же проблемой, решения нет. Хочу отметить, что выше описанное поведение воспроизводится на релизной версии iOS8.
Второй точкой соприкосновения для разработчика является файл Info.plist. Помимо уже известных полей он содержит группу NSExtension:
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IsASCIICapable</key>
<false/>
<key>PrefersRightToLeft</key>
<false/>
<key>PrimaryLanguage</key>
<string>ru</string>
<key>RequestsOpenAccess</key>
<false/>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.keyboard-service</string>
<key>NSExtensionPrincipalClass</key>
<string>VPKeyboardViewController</string>
</dict>
В ней указывается тип разрабатываемого расширения, имя класса-контроллера и атрибуты. Обратите внимание на атрибут RequestsOpenAccess. С помощью его система понимает необходим ли вам расширенный доступ: обмен данными с приложением-контейнером или сервером, доступ к геолокации и адресной книге. Если укажете true, то будьте готовы объяснять Apple для чего вам это все нужно.
На этом ознакомление с API завершаем и приступаем к непосредственной разработке.
Для начала определим лэйаут. Я планировал реализовать поддержку портретной и альбомной ориентаций для iPhone и со временем доработать для iPad. Лэйаут для портретной и альбомной ориентаций должен был немного отличаться. Для этих целей отлично подходила новоиспеченная технология Sizes Classes [2]. Почему я пишу в прошедшем времени? Да потому что все планы провалились. Дело в том, что в независимости от ориентации система назначает нам одинаковые Size Classes: wCompact и hCompact, что соответствует альбомной ориентации для iPhone. Скорее всего это связано с тем, что фрейм клавиатуры занимает не весь экран, а только нижнюю половину. В принципе это логичное поведение, и, чтобы обойти эту проблему, можно вручную назначить произвольный Size Class для контроллера. Для этого необходимо воспользоваться методом setOverrideTraitCollection:forChildViewController:. Но не тут-то было, по факту вызов этого метода ни на что не влияет, то есть UITraitCollection дочернего контроллера остается неизменным. Если у кого-либо из вас был положительный опыт использования этого метода, прошу им поделиться. Версию кода с выше описанным поведением я залил в отдельный бранч, если кому-то интересно можете там поковыряться. Пока проблема не решена будем довольствоваться одним лэйаутом для всех ориентаций:

Для удобства управления курсором добавим разпознование жестов Swipe. В xib добавляем два объекта UISwipeGestureRecognizer, в коде реализуем обработчики событий:
- (IBAction)onLeftSwipeRecognized:(id)sender {
if (self.textDocumentProxy.documentContextBeforeInput.length > 0) {
[self.textDocumentProxy adjustTextPositionByCharacterOffset:-1];
}
}
- (IBAction)onRightSwipeRecognized:(id)sender {
if (self.textDocumentProxy.documentContextAfterInput.length > 0) {
[self.textDocumentProxy adjustTextPositionByCharacterOffset:1];
}
}
Далее добавляем обработчики для закрытия клавиатуры и перехода к следующей:
- (IBAction)onNextInputModeButtonPressed:(id)sender {
[self advanceToNextInputMode];
}
- (IBAction)onDismissKeyboardButtonPressed:(id)sender {
[self dismissKeyboard];
}
Для удаления введенного текста реализуем две возможности:
- (IBAction)onDeleteButtonPressed:(id)sender {
if (self.textDocumentProxy.documentContextBeforeInput.length > 0) {
[self.textDocumentProxy deleteBackward];
}
}
UILongTapGestureRecognizer:
- (IBAction)onClearButtonPressed:(id)sender {
NSInteger endPositionOffset = self.textDocumentProxy.documentContextAfterInput.length;
[self.textDocumentProxy adjustTextPositionByCharacterOffset:endPositionOffset];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// We can't know when text position adjustment is finished
// Hack: Call this code after delay. In other case these changes won't be applied
while (self.textDocumentProxy.documentContextBeforeInput.length > 0) {
[self.textDocumentProxy deleteBackward];
}
});
}
Для достижения цели приходится использовать хак с выполнением кода с задержкой. Дело в том, что API позволяет удалять текст только перед курсором. То есть для того, чтобы удалить весь текст, необходимо сначала переместить курсор в конец строки, но сам процесс перемещения является асинхронным, в то же время я не нашел возможности узнать момент времени, когда этот процесс завершен. Поэтому ставим задержку в 0.1 секунду и считаем, что курсор достиг свой цели.
Остается разобраться с тем, ради чего мы здесь собственно собрались: с вводом тегов форматирования.
Для хранения стандартных тегов, которые поддерживаются хабром, будем использовать JSON файл:
{
"Жирный": "<b></b>",
"Курсив": "<i></i>",
"Подчеркнутый": "<u></u>",
"Зачеркнутый": "<s></s>",
"Цитата": "<blockquote></blockquote>",
"Код": "<code></code>",
"Ссылка": "<a href="http://"></a>",
"Картинка": "<img src="http://"/>",
"Видео": "<video>http://</video>",
"Спойлер": "<spoiler title=""></spoiler>",
"читатель": "<hh user=""/>"
}
Для тегов «Сарказм» и «Зануда» необходимо создать настройки, чтобы каждый пользователь мог сам установить значения для открывающегося и закрывающегося тегов. Добавляем Settings Bundle:

Переходим в Settings.bundle -> Root.plist и заполняем все необходимые поля. Ниже представлен исходный код настроек и то, что должен увидеть пользователь:

Но в реальности при установке клавиатуры заначения для тегов не отображаются, то есть по факту поля пустые. Эти поля задаются по ключу Default Value. Сначала я подумал, что делаю что-то не так. Но даже, если зайти в настройки и вручную заполнить эти поля, то при выходе из настроек значения не сохраняются. Это баг. С аналогичной проблемой столкнулись и другие пользователи, несколько топиков на Stackoverflow тому подтверждение, то есть проблема не является локальной. Такое ощущение, что разработчики забыли вызвать метод synchronize у объекта NSUserDefaults. Печально, но остается только ждать обновления iOS 8.1 или iOS 8.0.1. Чтобы учесть эту проблему, я использую дефолтные значения в коде, если из настроек загрузить не удалось.
С хранением тегов разобрались, теперь напишем обработчик нажатия клавиш для добавления тегов в поле ввода:
- (IBAction)onHabraButtonPressed:(id)sender {
NSString *tagKey = [sender titleForState:UIControlStateNormal];
NSString *tagValue = self.tagsDictionary[tagKey];
[self.textDocumentProxy insertText:tagValue];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// We can't know when text insert is finished
// Hack: Call this code after delay. In other case these changes won't be applied
[self moveTextPositionToInputPointForTag:tagValue];
});
}
- (void)moveTextPositionToInputPointForTag:(NSString *)tag {
static NSArray *inputPointLabels = nil;
if (inputPointLabels == nil) {
inputPointLabels = @[@"]", @"://", @"="", @">"];
}
for (NSString *label in inputPointLabels) {
NSRange labelRange = [tag rangeOfString:label];
if (labelRange.location != NSNotFound) {
NSInteger offset = labelRange.location + labelRange.length - tag.length;
[self.textDocumentProxy adjustTextPositionByCharacterOffset:offset];
break;
}
}
}
Здесь используется уже описанный ранее хак с задержкой выполнения кода. Связано это с тем же курсором. Когда мы вызываем метод insertText: курсор перемещается не мгновенно, и это необходимо учитывать. Чтобы объяснить, для чего здесь нужно учитывать смещение курсора, приведу пример: допустим мы хотим добавить ссылку на какого-либо читательа. Для этого необходимо добавить тег <hh user=""/> и далее вписать имя пользователя между кавычек. Для удобства я сделал так, чтобы курсор автоматически устанавливался в позицию между кавычек. Аналогично и для других тегов. Для этих целей используется выше описанный метод moveTextPositionToInputPointForTag:, который, используя массив строк-меток, определяет позицию, в которую необходимо установить курсор.
Реализацию завершили, выбираем расширение в качестве активной схемы и запускаем. Для удобства отладки рекомендую зайти в «Edit Scheme» и поставить галочку напротив «Debug executable». Это позволит одновременно отлаживать и расширение, и основное приложение:

Для установки клавиатуры необходимо перейти в Настройки -> Основные -> Клавиатура -> Клавиатуры -> Новые клавиатуры…

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

Полная версия исходного кода доступна на GitHub: Habrakeyboard [3]

Процесс публикации, как клавиатуры так и других типов расширений, практически не отличается от публикации обычного приложения, но само расширение должно удовлетворять некоторым техническим требованиям:
Также Apple добавила несколько новых пунктов в документ с рекомендациями по прохождению ревью. Они специфичны для расширений типа «Клавиатура»:
Если все выше описанные требования удовлетворены, то остается выполнить несколько в основном известных шагов:
com.company.application, то App ID для расширения может быть: com.company.application.keyboard. Далее для нового App ID необходимо создать provisioning profile. Эти данные необходимо указать в настройках Target в Xcode;Документ с рекомендациями по проверке приложений: App Store Review Guidelines [4]
Автор: visput
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/razrabotka/69973
Ссылки в тексте:
[1] Custom Keyboard: https://developer.apple.com/library/prerelease/ios/documentation/General/Conceptual/ExtensibilityPG/Keyboard.html
[2] Sizes Classes: http://habrahabr.ru/post/235181/
[3] Habrakeyboard: https://github.com/Visput/Habrakeyboard
[4] App Store Review Guidelines: https://developer.apple.com/app-store/review/guidelines/
[5] Источник: http://habrahabr.ru/post/235917/
Нажмите здесь для печати.