С любовью к дизайнерам: внедряем веб-формы в мобильное приложение

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

f250f99d7639199b1e57b34643b4a4d0 7dab77dbec160e08130c700c3cc41733

Итак, у нас имеется внешняя веб страница проекта-партнера, которая содержит веб-форму. Страница отлично работает во встроенном в приложение браузере, но ее внешний вид не совпадает с представлениями о прекрасном нашего отдела дизайна и выглядит внутри неорганично. Дизайнеры рисуют новую красивую форму и дают команду: «Должно выглядеть так!». У всех свои задачи, но наша общая цель – качественное приложение.

Наша задача ясна. Приступаем к реализации. Внедрить форму в приложение в новом дизайне — ничего сложного. Но как быть с веб-формой?

Навскидку, можно реализовать программно логику работы страницы с формой. Потом сформировать HTTP-запрос, эмулирующий нажатие кнопки «Отправить», и передать его вUIWebView.

Однако, при всей простоте у такого подхода есть подводные камни. Форма запросто может содержать в себе CSRF-токен (тогда нам придется загружать страницу и парсить токен, чтобы передать его в итоговом запросе), список выбора значений, которые могут часто меняться на стороне сервера (тоже загружать и парсить), да и вообще манипулировать состоянием одного или нескольких скрытых полей формы (привет, JavaScript!) в зависимости от данных, введенных пользователем. Все это достаточно усложняет задачу, не находите?

Есть другой путь! И на сцене под овации зрителей появляется маэстро Костыль. Что мы делаем?

Все очень просто. Берем скрытый от глаз пользователя UIWebView, загружаем туда нашу веб-страницу и манипулируем с ее объектами DOM при помощи JavaScript.

Рассмотрим данную технику на простом примере. В качестве подопытного кролика возьмем форму поиска в правом верхнем углу главной страницы Хабра, которая имеет следующее HTML-представление:

<div class="search">
  <form id="search_form" name="search" method="get" action="//habrahabr.ru/search/">
    <input type="submit" value="">
    <input type="text" name="q" x-webkit-speech="" speech="" tabindex="1" autocomplete="off">
  </form>
</div>

Форма проста и содержит в себе только одно текстовое поле ввода и кнопку, поэтому является идеальным объектом для эксперимента.
Первым делом создаем контроллер, который будет управлять веб-формой.

@interface MRWebViewController () <UIWebViewDelegate>
@property (nonatomic, weak, readonly) UIWebView *webView;
@property (nonatomic, strong, readonly) NSURLRequest *request;
@property (nonatomic, assign) BOOL hasForm;

// ...

@end

@implementation MRWebViewController {
}

// ...
- (instancetype)initWithURLString:(NSString *)urlString {
    self = [super init];
    if (self) {
        _request = [NSURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    }
    return self;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    [self createWebView];
    self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    self.view.alpha = 0.0;
}

- (void)createWebView {
    UIWebView *webView = [[UIWebView alloc] initWithFrame:self.view.bounds];
    webView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    webView.backgroundColor = UIColor.whiteColor;
    webView.scalesPageToFit = YES;
    webView.delegate = self;
    [self.view addSubview:webView];
    _webView = webView;
}

// ...

- (void)reload {
    self.hasForm = NO;
    self.view.alpha = 0.0;
    [self.webView stopLoading];
    [self.webView loadRequest:self.request];
}

// ...

@end

Наш контроллер содержит UIWebView, в который мы будем загружать страницу с формой, и объект NSURLRequest, который мы будем использовать для хранения запроса для загрузки страницы. Указание свойства autoresizingMask для объекта view позволит в дальнейшем без проблем использовать данный контроллер в качестве child view controller, а свойством alpha будем управлять его видимостью.

Создадим где-то в недрах нашего проекта объект контроллера и загрузим в него страницу с формой.

static NSString *kMRHabraURLString = @"http://habrahabr.ru";

MRWebViewController *controller = [[MRWebViewController alloc] initWithURLString:kMRHabraURLString];

[controller reload];

При этом результат загрузки страницы перехватим соответствующей функцией делегата в нашем контроллере. Манипулировать объектами DOM удобно при помощи jQuery. Поэтому убедимся, что в загруженной странице он точно будет присутствовать.

- (void)webViewDidFinishLoad:(UIWebView *)webView {
    if (!self.hasForm) {
        NSLog(@"Installing jQuery at %@", webView.request.URL.absoluteString);
        [self.webView stringByEvaluatingJavaScriptFromString:[MRScriptsFactory jqueryScript]];
        self.hasForm = YES;
    } // ...
}

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

После того, как пользователь заполнил нативную форму и нажал в ней на кнопку «Искать», наш контроллер получает сообщение searchWithString:.

- (BOOL)searchWithString:(NSString *)searchString {
    BOOL result = NO;
    if (self.hasForm) {
        // ...
        NSString *actualString = [searchString stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"];
        NSString *script = [NSString stringWithFormat:[MRScriptsFactory fillFormScript], actualString];
        NSString *scriptResult = [self.webView stringByEvaluatingJavaScriptFromString:script];
        __autoreleasing NSError *error = nil;
        id object = [NSJSONSerialization JSONObjectWithData:[scriptResult dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&error];
        result = (!error && [object isKindOfClass:[NSDictionary class]] && [object[@"success"] boolValue]);

        // ...
    }
    return result;
}

В нашем случае скрипт, получаемый через [MRScriptsFactory fillFormScript], имеет вид:


(function ($, searchString) {
    var components = {
        $text : $("form#search_form input[type='text']"),
        $submit : $("form#search_form input[type='submit']")
    };
    components.$text.val(searchString);
    components.$submit.click();
    return JSON.stringify({
        "success" : true
    });
})(jQuery, '%@');

Как видно из исходного кода скрипта, он производит заполнение текстового поля формы строкой поиска и программно эмулирует нажатие на кнопку формы.

Так как никакой последующей обработки данных, получаемых в результате исполнения запроса в UIWebView, нами изначально не предусматривалось, то в нашем примере мы просто «проявляем» его пользователю.

- (void)webViewDidFinishLoad:(UIWebView *)webView {
    if (!self.hasForm) {
       // ...
    } else if (self.isScriptExecuting) {
        [UIView animateWithDuration:0.3 animations:^{
            self.view.alpha = 1.0;
        }];
        self.scriptExecuting = NO;
        // ...
    }
}

Данный подход успешно применяется нами длительное время и хорошо себя зарекомендовал. Полный исходный код примера располагается здесь