Как написать «скорочиталку» для iOS за полчаса

в 11:08, , рубрики: Без рубрики

Прочитав на хабре посты про скорочтение QuisyReader и 500 слов в минуту без подготовки, захотелось реализовать данную идею для смартфонов Apple своими силами. Для этого я разработал API, исходные коды, которого опубликованы на github.

Как написать «скорочиталку» для iOS за полчаса

О принципе функционирования API и о том, как создать программу для скорочтения на его основе, я расскажу под катом

Как это работает?

За основу созданного API был взят принцип работы Spritz и его метод окрашивания и позиционирования слов.
Сначала API запускает повторяющийся таймер, срабатывающий несколько раз в секунду. Таймер вызывает метод, который запрашивает очередное слово для отрисовки у своего источника данных. После получения искомого слова нам нужно узнать позицию буквы, которую будем раскрашивать. С помощью эмпирических изысканий удалось установить, что буквы раскрашиваются следующим образом:

  • Длина слова 1. Раскрашиваемая буква 1.
  • Длина слова 2-5. Раскрашиваемая буква 2.
  • Длина слова 6-9. Раскрашиваемая буква 3.
  • и т.д.

Соответственно позиция нужной буквы вычисляется по простейшей формуле:

(([word length] + 6) / 4) - 1;

Зная позицию, используем NSAttributedString для окрашивания букв в разные цвета, черный для основного слова и красный для акцентируемой буквы.
Теперь необходимо рассчитать координаты центра нужной буквы. Для этого подсчитываем ширину символов с помощью метода sizeWithFont: или sizeWithAttributes: в зависимости от версии iOS.
Дело за малым создаем UILabel, помещаем в него NSAttributedString и устанавливаем фрейм с шириной рассчитанной для выбранного шрифта, с помощью всё тех же методов. Полученный UILabel и позицию центра акцентируемой буквы передаем классу RRTargetView, который занимается отрисовкой мишени, помогающей сконцентрировать внимание на нужной букве, и позиционированием UILabel в пределах этой мишени.

Пишем свой Reader

Теперь чуть подробнее об API, опубликованном на github, который будем использовать для реализации нашей задачи.
Класс RRViewController отвечает за формирование строки, которая будет отрисовываться. У класса есть следующие публичные свойства и методы:

Запуск/приостановка чтения.

- (void)startReading;
- (void)pauseReading;

Изменение скорости чтения, путем передачи отрицательного или положительного значения. Скорость измеряется в словах в минуту.

- (void)changeSpeed:(int)speedModification;

Изменение размера шрифта, также задается отрицательным или положительным значением, относительно текущего размера. Заданы пороговые значения от 16 до 100.

- (void)changeFont:(int)fontModification;

А также два свойства хранящие ссылку на объекты, которые реализуют Delegate и DataSource протоколы класса RRViewController.

@property (nonatomic, weak) id <RRViewControllerDataSource> dataSource;
@property (nonatomic, weak) id <RRViewControllerDelegate> delegate;

Отрисовка мишени и позиционирование текста выполняется наследником UIView классом RRTargetView. У класса один публичный метод и одно свойство.
Устанавливает точку в диапазоне от 0.0 до 1.0 в которой отрисовывается вертикальная засечка у мишени, текст позиционируется относительно этой точки. Значение по умолчанию равно 1/3.

@property (nonatomic) CGFloat horizontalAccentPosition;

Метод принимает UILabel с текстом. AccentPoint — это точка которая будет совмещена с вертикальной засечкой.

- (void)positionLabel:(UILabel *)label withAccentPoint:(CGPoint)point;

Пример на изображении
Как написать «скорочиталку» для iOS за полчаса

Также имеется два протокола.
Протокол RRViewControllerDelegate

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

- (void)reportFontSize:(CGFloat)size;
- (void)reportReadingSpeed:(NSUInteger)speed;

Протокол RRViewControllerDataSource
Опциональные методы:

- (NSString *)longestWordWithFont:(UIFont *)font;

Метод возвращает RRViewController'у самое длинное слово в тексте, оно необходимо для работы системы автоматического подбора размера шрифта. Также для работы системы автоматического подбора текста, нужно в [NSUserDefaults standardUserDefaults] установить булево значение YES для ключа auto_text_size.

- (NSString *)previousWord;

Метод для получения предыдущего слова, на случай если в тексте встретилось незнакомое слово и вы хотите вручную вернуться к нему.

Два обязательных метода:

- (NSString *)nextWord;

RRViewController запрашивает у модели данных следующее слово для отображения.

- (NSString *)currentWord;

Запрашивается текущее слово, например, на случай изменения шрифта для того, чтобы произвести повторную отрисовку текста.


Ну а теперь напишем свою читалку.
Для начала создаем проект (Single View Application), даём ему благозвучное имя, например, Super Fast Reader.

Как написать «скорочиталку» для iOS за полчаса

Теперь экспортируем в проект API, который скачиваем с github, он будет заниматься отрисовкой текста.

Открываем Storyboard, где присутствует UIViewController, созданный по умолчанию. Добавляем в него контейнер, в качестве класс контроллера, помещенного в контейнер, указываем RRViewController. Также нам понадобятся элементы для управления и ввода текста. Добавим кнопки «Старт», «Пауза», поле для ввода текста UITextView, UIView, в который будут помещены все элементы управления для их группировки, а также UIScrollView в который мы поместим контейнер и UIView с элементами управления. Теперь связываем эти элементы с кодом приложения. Создаем IBOutlet для UItextView и UIScrollView и два IBAction для обработки нажатий кнопок «Старт» и «Пауза».

@property (weak, nonatomic) IBOutlet UITextView *textView;
@property (weak, nonatomic) IBOutlet UIScrollView *scrollView;

- (IBAction)startReading:(UIButton *)sender;
- (IBAction)pauseReading:(UIButton *)sender;

Представление в Storyboard
Как написать «скорочиталку» для iOS за полчаса

Иерархия видов
Как написать «скорочиталку» для iOS за полчаса

Настало время для работы с RRViewController.
Первое, что необходимо сделать, это получить на него ссылку. При создании контейнера был автоматически создан «Embed segue», назначим ему имя через InterfaceBuilder, например, RVCBecomesChild. В момент исполнения программы, при добавлении RRViewController в контейнер будет вызван метод prepareForSegue:sender:, чем мы и воспользуемся.

Сначала создаем свойство, в котором будет хранится ссылка на объект:

@property (weak, nonatomic) RRViewController *readingVC;

Теперь реализуем метод:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([segue.identifier isEqualToString:@"RVCBecomesChild"]) {
        self.readingVC = segue.destinationViewController;
    }
}

Ссылка на контроллер есть. Что дальше?
А дальше необходимо реализовать обязательные методы протокола RRViewControllerDataSource для класса RRViewController:

- (NSString *)nextWord;
- (NSString *)currentWord;

Первое, что нам необходимо, сообщить RRViewController'у о том, что мы являемся его источником данных.

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.readingVC.dataSource = self;
}

Не забываем сообщить компилятору о том, что мы поддерживаем протокол:

@interface SFRViewController () <RRViewControllerDataSource>

Это нужно для того, чтобы компилятор не выдавал предупреждения при присвоении self.readingVC.dataSource = self, а также для того, чтобы нам выдавалось предупреждение в случае, если не все методы помеченные, как @required были нами реализованы.

Текст для чтения будем брать из поля UITextView, дополнительно понадобится переменная, которая будет отображать текущую позицию в тексте.

@property (nonatomic) NSUInteger currentWord;

- (NSString *)nextWord
{
    self.textPosition++;
    NSArray *words = [self.textView.text componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
    if (self.textPosition >= [words count]) {
        self.textPosition = 0;
    }
    return [words objectAtIndex:self.textPosition];
}

- (NSString *)currentWord
{
    NSArray *words = [self.textView.text componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
    if (self.textPosition >= [words count]) {
        self.textPosition = 0;
    }
    return [words objectAtIndex:self.textPosition];
}

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

- (IBAction)startReading:(UIButton *)sender
{
    [self.readingVC startReading];
}

- (IBAction)pauseReading:(UIButton *)sender
{
    [self.readingVC pauseReading];
}

Готово. Можно запускать программу и наслаждаться результатом.

It's not a bug, it's a feature

Сразу можно заметить, что скорость достаточно медленная, а шрифт мелковат. Решается эта проблема очень легко. Создаем кнопки, которые будут вызывать методы RRViewController. В качестве аргумента методу передаются отрицательные, либо положительные целые числа, которые сообщают на сколько должны измениться эти параметры.

- (void)changeSpeed:(int)speedModification;
- (void)changeFont:(int)fontModification;

Реализацию этих методов оставлю на ваше усмотрение, а мы займемся решением другой проблемы.

В нашей программе при попытке добавления текста клавиатура перекрывает поле для ввода, также клавиатура не убирается при нажатии клавиши Enter, (что в общем то является правильным, ведь UITextView используется для ввода многострочного текста и нажатие клавиши «Enter» вставляет символ переноса строки в текст, но мы можем поменять это поведение).
Чтобы решить эту задачу, нам нужно получать отклик от текстового поля. Для этого сообщим ему, что наш класс ViewController является делеагатом класса UITextView и реализует его протокол.

@interface SFRViewController () <RRViewControllerDataSource, UITextViewDelegate>

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

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.readingVC.dataSource = self;
    self.textView.delegate = self;
    
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(keyboardWasShown:)
                                                 name:UIKeyboardWillShowNotification
                                               object:nil];
}

Далее реализуем два метода, которые нам необходимы.
Сначала метод, который будет вызываться при появлении клавиатуры:

- (void)keyboardWasShown:(NSNotification *)notification
{
    CGSize keyboardSize = [[[notification userInfo] objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue].size;
    [self.scrollView setContentInset:UIEdgeInsetsMake(-keyboardSize.height, 0, 0, 0)];
}

Теперь обрабатываем изменение текста. Если происходит вставка символа переноса строки "n", убираем клавиатуру и убираем вертикальное смещение для UIScrollView. Кстати в этом же методе вы можете реализовать парсинг строки для того, чтобы он выполнялся единожды при изменении текста.

- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
{
    if ([text isEqualToString:@"n"])
    {
        [textView resignFirstResponder];
    }
    return YES;
}

Запускаем еще раз. Работает! Можно наслаждаться результатом и заниматься реализацией собственных идей.
Естественно в данной статье был показан самый простой способ получения данных, в случае более грамотной реализации, стоит создать отдельный класс, который будет заниматься парсингом и предоставлением данных.

Послесловие

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

Приятного скорочтения!

Автор: GxocT

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js