- 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/
Нажмите здесь для печати.