Зачастую для чтения хабра я использую мобильное приложение Хабрахабр для 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
Хабраклавиатура
С теорией разобрались, переходим к практике.
Создаем новый проект, выбираем 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. Почему я пишу в прошедшем времени? Да потому что все планы провалились. Дело в том, что в независимости от ориентации система назначает нам одинаковые 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
Демонстрация
Публикация
Процесс публикации, как клавиатуры так и других типов расширений, практически не отличается от публикации обычного приложения, но само расширение должно удовлетворять некоторым техническим требованиям:
- Расширение может быть опубликовано только в составе приложения-контейнера. В одиночку оно не может существовать. Удаляется оно при удалении приложения. Кстати, это ограничение только для iOS. Для Mac расширение можно распространять отдельно;
- Deployment Target для расширения должна быть >= iOS 8.0. Основное приложение при этом может публиковаться и для более ранних версий операционной системы. В старых версиях расширение просто не будет доступно. То есть, если, например, разработчики приложения Хабрахабр захотят реализовать что-нибудь подобное, то повышать Deployment Target будет совсем необязательно;
- Target для расширения должна содержать настройку для сборки под процессоры с 64-х битной архитектурой (arm64). В случае, если приложение реализует обмен данными с расширением, то оно должно удовлетворять таким же требованиям. Как ни крути, но о поддержке 64-х битных процессоров нужно задумываться. Для больших проектов со сложным низкоуровневым кодом это может потребовать значительных трудозатрат.
Также Apple добавила несколько новых пунктов в документ с рекомендациями по прохождению ревью. Они специфичны для расширений типа «Клавиатура»:
- Открыв вашу клавиатуру, пользователь должен иметь возможность перейти к следующей;
- Клавиатура должна оставаться работоспособной в случае отсутствия соединения с интернетом;
- Клавиатура должна реализовать следующие типы клавиатур: Number и Decimal (то есть, если поле для ввода подразумевает ввод числа, то клавиатура должна предоставить удобный для этого способ);
- Основная категория для приложения, которое содержит расширение в виде клавиатуры, должна быть Utilities. Также приложение должно предоставить пользователю собственную политику конфиденциальности (privacy policy);
- Клавиатура должна использовать пользовательские данные только в целях улучшения своей функциональности.
- Keyboard extensions must provide a method for progressing to the next keyboard;
- Keyboard extensions must remain functional with no network access or they will be rejected;
- Keyboard extensions must provide Number and Decimal keyboard types as described in the App Extension Programming Guide or they will be rejected;
- Apps offering Keyboard extensions must have a primary category of Utilities and a privacy policy or they will be rejected;
- Apps offering Keyboard extensions may only collect user activity to enhance the functionality of their keyboard extension on the iOS device or they may be rejected.
Если все выше описанные требования удовлетворены, то остается выполнить несколько в основном известных шагов:
- Перейти в портал разработчика и создать App ID для расширения. Он должен быть продолжением идентификатора основного приложения. Например, если App ID для приложения:
com.company.application
, то App ID для расширения может быть:com.company.application.keyboard
. Далее для нового App ID необходимо создать provisioning profile. Эти данные необходимо указать в настройках Target в Xcode; - Если расширение реализует обмен данными с основным приложением, то в портале разработчика необходимо создать App Group, а также включить возможность использования App Group в настройках App IDs для расширения и приложения. Собственно, этот шаг необходимо выполнить еще на этапе тестирования приложения, иначе обмена данными вам не видать;
- Добавить скриншоты и описание для вашего расширения в iTunesConnect. Apple не добавила специальных полей для этих целей. Эти данные заполняются наравне с информацией по основному приложению;
- Собрать Target основного приложения и отправить на ревью. Расширение уже содержится в пакете приложения;
- На этом все, остается только ждать.
Документ с рекомендациями по проверке приложений: App Store Review Guidelines
Автор: visput