Мы уже добавили в проект (код доступен на GitHub – прим. ред.) перечисление WebViewNavigationAction, которое описывает три действия: назад, вперед, перезагрузить, а затем разобрались с пользовательским интерфейсом приложения, навигацией и получением информации с веб-страницы в Swift. Сегодня более подробно рассмотрим работу с JavaScript.
Создаем local.html
Создадим папку www в проекте. Внутрь положим файл local.html со следующим содержимым:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<title>Local HTML</title>
<style>
body {
background: -webkit-linear-gradient(bottom left, #2b2f44 0%,#312442 100%);
text-align: center;
font-family: monospace;
font-weight: bold;
font-size: large;
}
.title {
color: #FFFFFF;
}
.container {
display: grid;
justify-content: center;
align-content: center;
grid-auto-flow: row;
gap: 12px;
}
.item {
max-width: 600px;
padding: 12px;
background: #000000;
border-radius: 5px;
color: #FFFFFF;
}
#ok {
background-color: #000000;
color: #FFFFFF;
font-size: medium;
padding: 12px;
}
</style>
</head>
<body>
<header>
<h1 class="title">Interaction with JavaScript</h1>
</header>
<div class="container">
<div class="item">
<h1>Swift</h1>
</div>
<div class="item">
<h1>JavaScript</h1>
</div>
<button id="ok">OK</button>
</div>
</body>
</html>
При добавлении не забудьте указать create folder reference.
Чтобы показать локальный файл HTML, нам необходимо доработать WebView, но для начала обновим конфигурацию WebView в ContentView:
WebView(type: .local, url: "local", viewModel: viewModel)
Вернемся в WebView и обновим логику в updateUIView:
func updateUIView(_ webView: WKWebView, context: Context) {
if let urlValue = url {
if type == .local {
if let localUrl = Bundle.main.url(forResource: urlValue, withExtension: "html", subdirectory: "www") {
webView.loadFileURL(localUrl, allowingReadAccessTo: localUrl.deletingLastPathComponent())
}
} else if type == .public {
if let requestUrl = URL(string: urlValue) {
webView.load(URLRequest(url: requestUrl))
}
}
}
}
Обратите внимание, для загрузки локального файла мы используем следующий метод:
func loadFileURL(_ URL: URL, allowingReadAccessTo readAccessURL: URL) -> WKNavigation?
Протестируем что у нас получилось!
А зачем оно все?
На практике встречаются такие задачи, когда нам нужно подгрузить веб-страницу с определенным контентом. Иногда мы хотим показать только часть содержимого и скрыть, например, <header> веб-страницы, который добавляет избыточную навигацию в приложение. В других случаях нам нужно понимать, что пользователь нажал на кнопку OK (когда это событие произошло).
Когда вы будете запускать код в WKWebView, global scope (глобальная область видимости) может стать проблемой, которая приводит к краху приложения из-за ошибок повторного объявления переменных и т.п.
Пара слов о global scope
Поэкспериментируйте с объявлением переменных. Например, получите заголовок всей веб-страницы document.title, как мы это уже делали в прошлой статье. Объявите переменные с const, let.
Если вы выполните в консоли несколько раз const title = document.title, то получите ошибку. Это можно исправить, если заменить const на let или ввести локальную область видимости (local scope), заключив код в фигурные скобки:
{ const title = document.title; }
Чтобы проблем, стоит оборачивать код в функции. Поскольку они дают нам дополнительный контекст, вы можете быть уверены, что не получите подобных ошибок.
function getDocTittle() {
const title = document.title;
console.log(title);
}
getDocTitle();
Селекторы
Чтобы произвести какие-либо действия с элементами веб-страницы, нужно получить на них ссылку. Selectors API предоставляет нам простой и эффективный способ получить элемент из DOM (Document-Object-Model), сопоставляя элементы заданному в параметре множеству селекторов. Спецификация добавляет два метода, которые мы можем применять к документу (Document), элементу (Element), и фрагменту документа (DocumentFragment):
- querySelector(selectors) вернет первое совпадение. Если совпадений не найдено, то null.
- querySelectorAll(selectors) вернет все совпадения (NodeList), или пустой NodeList, если их нет.
Когда сайты имеют хорошую разметку, чаще всего в качестве параметра (selectors) мы передаем CSS-класс, ID элемента, имя тега элемента. Для для формирования более точных запросов следует передавать множество CSS-селекторов.
Рассмотрим все вышеизложенное на примерах.
Например, в <body> на веб-странице у нас есть:
- <header> внутри которого заголовок.
- <h1> c классом title.
- <div> – блок с классом container, в котором содержатся:
- два блока <div> с классом item, в котором содержатся:
- заголовки <h1>;
- кнопка <button> с идентификатором ok.
Получим наш заголовок из тега <head>:
const h1 = document.querySelector('h1');
Классы более общие, а идентификаторы всегда уникальные во всем документе, и у каждого элемента в HTML есть глобальные атрибуты.
Атрибут class – один из них. Он позволяет CSS и JavaScript выбрать и получить элемент при помощи селекторов CSS или функций JavaScript.
Если хотим получить именно название (title), то лучше воспользоваться более точным запросом. В передаваемом селекторе, точка означает, что мы имеем дело с именем класса.
const title = document.querySelector('.title');
Оба запроса вернули один и тот же результат:
<h1 class="title">Interaction with JavaScript</h1>
Во втором случае мы тоже получили нужное, поскольку название класса title говорит само за себя.
Теперь получим кнопку с идентификатором ok:
const button = document.querySelector('#ok');
В передаваемом селекторе решетка означает, что это имя идентификатора.
Результат:
<button id="ok">OK</button>
Воспользуемся следующим запросом, чтобы исследовать элементы тега div:
let divs = document.querySelectorAll("div")
В результате получим состоящий из трех элементов NodeList:
[div.container, div.item, div.item]
Создадим более точные запросы, чтобы получить контент заголовков <h1> внутри элементов div.item:
let itemsContent = document.querySelectorAll("div.item h1");
C помощью метода forEach мы можем перебирать элементы NodeList. Выведем результат в консоль:
itemsContent.forEach(function(title) { console.log(title.innerText)) }
Скрытие элементов
Например, чтобы скрыть title, воспользуемся свойством display:
title.style.display = 'none';
Разумеется, при обновлении WebView элемент снова появится.
Существует ли элемент?
Часто встречающаяся задача – проверка присутствия элемента. Если вы не совсем уверены в получаемом результате, оберните его в условный оператор if. Попробуем получить первый заголовок второго уровня <h2>, которого не существует в примере, и скрыть его:
const h2 = document.querySelector('h2');
if(h2) { h2.style.display = 'none'; }
Клики и события
JavaScript предоставляет нам метод addEventListener, в котором два параметра:
- название события, например, ‘click’.
- функция, которая выполняет какой-либо код, когда происходит событие.
button.addEventListener('click', function(e) {
console.log(e);
console.log('OK Clicked');
});
Мы добавили событие click, и по нажатию на кнопку OK должны увидеть в консоли: ”OK Clicked”.
Параметр е, который передается в функцию, описывает само событие click. К самому элементу можно обратиться через e.target.
Как получить контент элемента или атрибута?
Существует несколько способов в DOM API получить контент элемента, но у каждого свои особенности и назначения. Допустим мы хотим получить контент нашего названия title.
Например, при помощи свойств:
- textContent
- innerText
title.innerText и title.textContent в данном случае возвращают одни и те же результаты. Однако они весьма отличаются. textContent может вернуть нам даже текстовое содержимое блока <style>.
Чтобы разница была явно видна, получите элемент HTML и посмотрите, что будет в этих свойствах.
Порой нам необходимо вытащить у элемента <img> ссылку на изображение, которая находится в атрибуте src или значение элемента <input>. Сделать это довольно просто:
let imgSrc = img.src;
const input = document.querySelector('#mylInput');
let inputValue = input.value
Run JS! Run!
В предыдущей статье мы использовали метод evaluateJavaScript для получения заголовка документа. Остановимся на нем поподробней.
open func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil)
В документации сказано, что вызов этого метода равноценен вызову метода, где значение frame – nil представляет главный фрейм, а значение contentWorld – WKContentWorld.pageWorld:
public func evaluateJavaScript(_ javaScript: String, in frame: WKFrameInfo? = nil, in contentWorld: WKContentWorld, completionHandler: ((Result<Any, Error>) -> Void)? = nil)
Данное улучшение доступно начиная с iOS 14.
- WKFrameInfo содержит информацию о фрейме на странице.
- WKContentWorld – этот объект определяет область видимости выполнения кода JavaScript, т.е. мы должны использовать его как пространство имен (namespace).
Получим заголовок из шапки и покажем его в нашем NavigationView. Для этого создадим файл JSUserScripts.swift, где напишем сценарии JavaScript, которые будут модифицировать страницу и забирать из нее данные.
Напишем две функции для получения и скрытия заголовка на веб-странице:
let getHeaderTitle = """
function getHeaderTitle() {
const headerTitle = document.querySelector('h1.title');
return headerTitle.innerText;
}
getHeaderTitle();
"""
let hideHeaderTitle = """
function hideHeaderTitle() {
const headerTitle = document.querySelector('h1.title');
headerTitle.style.display = 'none';
}
hideHeaderTitle();
"""
Теперь заменим старый метод evaluateJavaScript улучшенной версией:
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("didFinish")
self.parent.viewModel.isLoaderVisible.send(false)
webView.evaluateJavaScript(getHeaderTitle, in: nil, in: .defaultClient) { result in
switch result {
case .success(let value):
if let title = value as? String {
self.parent.viewModel.webTitle.send(title)
}
case .failure(let error):
print(error.localizedDescription)
}
}
webView.evaluateJavaScript(hideHeaderTitle, in: nil, in: .defaultClient)
}
Свойство .defaultClient предоставляет пространство имен (namespace) по умолчанию для приложения.
Если нам нужно запустить скрипт внутри пространства имен текущей веб-страницы, мы можем использовать .page
webView.evaluateJavaScript(hideHeaderTitle, in: nil, in: .page)
Также мы можем создавать собственные пространства имен:
webView.evaluateJavaScript(hideHeaderTitle, in: nil, in: .world(name: "magic"))
В нашем примере это излишество, но если бы мы создавали веб-браузер с расширениями на JavaScript, пришлось бы создавать уникальное пространство имен для каждого расширения.
Еще один интересный метод, который пришел вместе с iOS 14:
public func callAsyncJavaScript(_ functionBody: String, arguments: [String : Any] = [:], in frame: WKFrameInfo? = nil, in contentWorld: WKContentWorld, completionHandler: ((Result<Any, Error>) -> Void)? = nil)
В параметр functionBody, в качестве строки мы передаем само тело функции, а в качестве arguments мы можем передавать переменные, которые будут использованы в функции, что очень удобно. Такая гибкость позволяет нам переиспользовать блоки кода JavaScript.
Как видно из названия, Async означает, что строка будет запущена как асинхронная функция. В этом случае можно использовать await и работать с JavaScript-обещаниями (Promise). Такая техника необходима, когда мы не знаем, когда именно будет завершена работа функции или когда необходимо что-то запустить через определенное время (например setTimeout).
Рассмотрим несколько примеров:
let hideAnyElement = """
const element = document.querySelector(selector);
element.style.display = 'none';
"""
let getElementInnerText = """
const element = document.querySelector(selector);
return element.innerText;
"""
Исполним JavaScript:
webView.callAsyncJavaScript(hideAnyElement, arguments: ["selector":"#ok"], in: nil, in: .defaultClient)
Код стал элегантней. Теперь мы можем передавать селекторы в качестве аргументов и скрывать любые элементы веб-страницы.
Напишем простое обещание (promise):
let setTimeoutFor = """
const myPromise = new Promise((resolve, reject) => {
window.setTimeout(function (){
resolve('foo');
}, timeout);
});
await myPromise;
return myPromise;
"""
Исполним JavaScript:
let timeout = 3000;
webView.callAsyncJavaScript(setTimeoutFor, arguments: [ "timeout":"\(timeout)"], in: nil, in: .defaultClient) { (result) in
switch result {
case .success(let response):
print("Done...");
print(response);
case .failure(let error):
print("Error...");
print(error)
}
}
После трех секунд ожидания мы получим результат “foo” в приложении.
Такую коммуникацию нам помогает реализовать протокол WKScriptMessageHandler и объект WKUserContentController, который предоставляет возможность отправки сообщений JavaScript в WKWebView. Протокол WKScriptMessageHandler определяет всего одну функцию, в которой мы можем получить сообщение из запущенных на веб-странице сценариев.
Напишем расширение для Coordinator, в которое и добавим реализацию этой функции:
extension WebView.Coordinator: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "messageAppHandler" {
if let body = message.body as? String {
print("Message body: \(body)")
}
}
}
}
В методе makeUIView (здесь мы создаем WebView и задаем его конфигурацию) укажем наш класс, который реализует обработчик сообщений и его имя:
webView.configuration.userContentController.add(context.coordinator, contentWorld: .page, name: "messageAppHandler")
- Аргумент name определяет название нашего обработчика сообщений WKScriptMessageHandler. Обратите внимание, что namе (имя обработчика сообщений) используется для определения, какой именно WKUserContentController отправляет сообщение.
- Свойство body содержит наше сообщение (строку или объект JSON).
- На веб-странице, чтобы отправить сообщение нашему обработчику, мы должны вызвать у него метод postMessage.
Добавим скрипт на веб-страницу, и отправим сообщение:
<script type="text/javascript">
sendValueToApp();
function sendValueToApp() {
window.webkit.messageHandlers.messageAppHandler.postMessage('Hello from web page');
}
</script>
Запустим и проверим, что получаем наше сообщение с веб-страницы в консоли.
На этом пока все. Код проекта доступен на Github.
P.S. Happy Code!
Комментарии