Pull to refresh

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

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



О принципе функционирования 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;

Пример на изображении


Также имеется два протокола.
Протокол 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.



Теперь экспортируем в проект 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


Иерархия видов


Настало время для работы с 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 находится на стадии разработки/доработки, поэтому с удовольствием приму любые замечания и предложения по его изменению, либо форкайте репозиторий и выполняйте изменения на свой лад.

Приятного скорочтения!
Tags:
Hubs:
+24
Comments32

Articles

Change theme settings