Мы уже добавили в проект (код доступен на GitHub – прим. ред.) перечисление WebViewNavigationAction , которое описывает три действия: назад, вперед, перезагрузить, а затем разобрались с пользовательским интерфейсом приложения, навигацией и получением информации с веб-страницы в Swift. Сегодня более подробно рассмотрим работу с JavaScript.
Создаем local.html
Подготовим простой файл HTML и отобразим его в WebView .
Создадим папку www в проекте. Внутрь положим файл local.html со следующим содержимым:
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?
Важный момент! Если readAccessURL ссылается на один файл, то Webkit сможет загрузить только его; если это директория, то и файлы внутри каталога могут быть загружены WebKit . В данный момент мы загружаем только один файл и все описываем внутри него (стили, скрипты).
Протестируем что у нас получилось!
А зачем оно все?
На практике встречаются такие задачи, когда нам нужно подгрузить веб-страницу с определенным контентом. Иногда мы хотим показать только часть содержимого и скрыть, например, <header> веб-страницы, который добавляет избыточную навигацию в приложение. В других случаях нам нужно понимать, что пользователь нажал на кнопку OK (когда это событие произошло).
Отладка JavaScript Далее мы рассмотрим как работать с JavaScript из Swift , и наоборот. Если вы еще не знакомы с Web технологиями, такими как JavaScript , HTML , CSS , то ничего страшного. Я расскажу теоретический минимум, которые необходимо знать. Прежде чем добавлять сценарии JavaScript в приложение, я рекомендую проверить их в браузере. Откройте инструменты разработчика и протестируйте код в консоли.
Когда вы будете запускать код в WKWebView , global scope (глобальная область видимости) может стать проблемой, которая приводит к краху приложения из-за ошибок повторного объявления переменных и т.п.
Пара слов о global scope
В JavaScript используют const для объявления констант и let – для обычных переменных . Вы также можете воспользоваться var , но здесь тоже есть некоторые особенности, которые могут привести в замешательство.
Поэкспериментируйте с объявлением переменных. Например, получите заголовок всей веб-страницы 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();
Если вы хотите разобраться, как работают переменные и области видимости (scope), прочтите этот пост.
Селекторы
Чтобы произвести какие-либо действия с элементами веб-страницы, нужно получить на них ссылку. 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');
Поскольку в нашем примере заголовок в документе самый первый, то мы получили нужное. А если в <head> определили два заголовка? Например, мог быть заголовок в шапке сайта, который описывает бренд, а за ним следует заголовок, который задает название контенту содержимого. В таком случае нам бы пришлось создать более точный запрос.
Классы более общие, а идентификаторы всегда уникальные во всем документе, и у каждого элемента в 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 .
Стоить отметить, что в JavaScript есть стрелочные функции, однако если вы незнакомы с особенностями языка, это может вызвать замешательство. Чтобы избежать путаницы с тем, куда указывает this , я использовал обычную функцию, где this в теле функции указывает на саму кнопку.
Как получить контент элемента или атрибута?
Существует несколько способов в DOM API получить контент элемента, но у каждого свои особенности и назначения. Допустим мы хотим получить контент нашего названия title .
Например, при помощи свойств:
title.innerText
и title.textContent
в данном случае возвращают одни и те же результаты. Однако они весьма отличаются. textContent может вернуть нам даже текстовое содержимое блока <style> .
Поскольку чаще всего нам требуется получить читабельный контент, то innerText является лучшим выбором и используется в большинстве случаев, но его результат зависит от стилей. Если элемент скрыт, то мы ничего не получим.
Чтобы разница была явно видна, получите элемент HTML и посмотрите, что будет в этих свойствах.
Для более подробного изучения смотрите документацию, а также обратите внимание на свойства outerText , innerHtml и outerHtml .
Порой нам необходимо вытащить у элемента <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)
В этот метод мы передаем строку, которая представляет из себя код на JavaScript и получаем результат. Если мы ничего не возвращаем явно, то JavaScript runtime вернет нам undefined . Для отслеживания, каких либо блоков кода на JavaScript удобно использовать в качестве возвращаемого результата true или false . В completionHandler передается результат исполнения скрипта или ошибка.
В документации сказано, что вызов этого метода равноценен вызову метода, где значение 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) .
Рассмотрим его более подробно. Это ключевой параметр функции, который дает нам большую гибкость и обеспечивает предотвращение многих конфликтов. Мы можем его использовать для предотвращения конфликтов между скриптами на веб-странице. Выполнение сценарии JavaScript в собственном WKContentWorld дает нам отдельную копию переменных среды для изменения.
Получим заголовок из шапки и покажем его в нашем NavigationView . Для этого создадим файл JSUserScripts.swift , где напишем сценарии JavaScript , которые будут модифицировать страницу и забирать из нее данные.
Напишем две функции для получения и скрытия заголовка на веб-странице:
JSUserScripts.swift
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” в приложении.
Когда мы разобрались с исполнением сценариев JavaScript , инициируя вызов из Swift , рассмотрим и обратный процесс – как выполнить код Swift при помощи JavaScript .
Такую коммуникацию нам помогает реализовать протокол 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!