🍏 Взаимодействие SwiftUI с вебом. Часть 3: JavaScript – инструкция для новичков
В предыдущей статье мы разложили по полочкам навигацию в WebView. Самое время разобраться, как подружить SwiftUI с JavaScript, поскольку без этого языка программирования не обходится ни один современный сайт или мобильное приложение. Мы уже добавили в проект (код доступен на GitHub – прим. ред.) перечисление WebViewNavigationAction, которое описывает три действия: назад, вперед, перезагрузить, а затем разобрались с пользовательским интерфейсом приложения, навигацией и получением информации с веб-страницы в Swift. Сегодня более подробно рассмотрим работу с JavaScript. Подготовим простой файл HTML и отобразим его в WebView. Создадим папку www в проекте. Внутрь положим файл local.html со следующим содержимым: local.html При добавлении не забудьте указать create folder reference. Чтобы показать локальный файл HTML, нам необходимо доработать WebView, но для начала обновим конфигурацию WebView в ContentView: Вернемся в WebView и обновим логику в updateUIView: Обратите внимание, для загрузки локального файла мы используем следующий метод: Важный момент! Если readAccessURL ссылается на один файл, то Webkit сможет загрузить только его; если это директория, то и файлы внутри каталога могут быть загружены WebKit. В данный момент мы загружаем только один файл и все описываем внутри него (стили, скрипты). Протестируем что у нас получилось! На практике встречаются такие задачи, когда нам нужно подгрузить веб-страницу с определенным контентом. Иногда мы хотим показать только часть содержимого и скрыть, например, <header> веб-страницы, который добавляет избыточную навигацию в приложение. В других случаях нам нужно понимать, что пользователь нажал на кнопку OK (когда это событие произошло). Отладка JavaScript Далее мы рассмотрим как работать с JavaScript из Swift, и наоборот. Если вы еще не знакомы с Web технологиями, такими как JavaScript, HTML, CSS, то ничего страшного. Я расскажу теоретический минимум, которые необходимо знать. Прежде чем добавлять сценарии JavaScript в приложение, я рекомендую проверить их в браузере. Откройте инструменты разработчика и протестируйте код в консоли. Когда вы будете запускать код в WKWebView, global scope (глобальная область видимости) может стать проблемой, которая приводит к краху приложения из-за ошибок повторного объявления переменных и т.п. В JavaScript используют const для объявления констант и let – для обычных переменных. Вы также можете воспользоваться var, но здесь тоже есть некоторые особенности, которые могут привести в замешательство. Поэкспериментируйте с объявлением переменных. Например, получите заголовок всей веб-страницы document.title, как мы это уже делали в прошлой статье. Объявите переменные с const, let. Если вы выполните в консоли несколько раз Чтобы проблем, стоит оборачивать код в функции. Поскольку они дают нам дополнительный контекст, вы можете быть уверены, что не получите подобных ошибок. Если вы хотите разобраться, как работают переменные и области видимости (scope), прочтите этот пост. Чтобы произвести какие-либо действия с элементами веб-страницы, нужно получить на них ссылку. Selectors API предоставляет нам простой и эффективный способ получить элемент из DOM (Document-Object-Model), сопоставляя элементы заданному в параметре множеству селекторов. Спецификация добавляет два метода, которые мы можем применять к документу (Document), элементу (Element), и фрагменту документа (DocumentFragment): Когда сайты имеют хорошую разметку, чаще всего в качестве параметра (selectors) мы передаем CSS-класс, ID элемента, имя тега элемента. Для для формирования более точных запросов следует передавать множество CSS-селекторов. Рассмотрим все вышеизложенное на примерах. Например, в <body> на веб-странице у нас есть: Получим наш заголовок из тега <head>: Поскольку в нашем примере заголовок в документе самый первый, то мы получили нужное. А если в <head> определили два заголовка? Например, мог быть заголовок в шапке сайта, который описывает бренд, а за ним следует заголовок, который задает название контенту содержимого. В таком случае нам бы пришлось создать более точный запрос. Классы более общие, а идентификаторы всегда уникальные во всем документе, и у каждого элемента в HTML есть глобальные атрибуты. Атрибут class – один из них. Он позволяет CSS и JavaScript выбрать и получить элемент при помощи селекторов CSS или функций JavaScript. Если хотим получить именно название (title), то лучше воспользоваться более точным запросом. В передаваемом селекторе, точка означает, что мы имеем дело с именем класса. Оба запроса вернули один и тот же результат: Во втором случае мы тоже получили нужное, поскольку название класса title говорит само за себя. Теперь получим кнопку с идентификатором ok: В передаваемом селекторе решетка означает, что это имя идентификатора. Результат: Воспользуемся следующим запросом, чтобы исследовать элементы тега div: В результате получим состоящий из трех элементов NodeList: Создадим более точные запросы, чтобы получить контент заголовков <h1> внутри элементов div.item: C помощью метода forEach мы можем перебирать элементы NodeList. Выведем результат в консоль: Управление элементами Как получить элементы из документа, мы уже разобрались. Давайте научимся ими манипулировать. Стоить отметить, что существует несколько способов как и получения элементов, так и несколько способов выполнить ту или иную задачу с некоторыми тонкостями и особенностями. Например, такие задачи как, скрытия элементов, проверки на существование элемента в документе и т.д. Поэтому если вы уже подкованы в этой области, поделитесь своим опытом в комментариях. Далее мы не будем останавливаться на всевозможных способах и деталях. Просто посмотрим на часто распространенные примеры. Например, чтобы скрыть title, воспользуемся свойством display: Разумеется, при обновлении WebView элемент снова появится. Часто встречающаяся задача – проверка присутствия элемента. Если вы не совсем уверены в получаемом результате, оберните его в условный оператор if. Попробуем получить первый заголовок второго уровня <h2>, которого не существует в примере, и скрыть его: Давайте разберемся как получить уведомление, когда пользователь нажал на кнопку. JavaScript предоставляет нам метод addEventListener, в котором два параметра: Мы добавили событие click, и по нажатию на кнопку OK должны увидеть в консоли: ”OK Clicked”. Параметр е, который передается в функцию, описывает само событие click. К самому элементу можно обратиться через e.target. Стоить отметить, что в JavaScript есть стрелочные функции, однако если вы незнакомы с особенностями языка, это может вызвать замешательство. Чтобы избежать путаницы с тем, куда указывает this, я использовал обычную функцию, где this в теле функции указывает на саму кнопку. Существует несколько способов в DOM API получить контент элемента, но у каждого свои особенности и назначения. Допустим мы хотим получить контент нашего названия title. Например, при помощи свойств: Поскольку чаще всего нам требуется получить читабельный контент, то innerText является лучшим выбором и используется в большинстве случаев, но его результат зависит от стилей. Если элемент скрыт, то мы ничего не получим. Чтобы разница была явно видна, получите элемент HTML и посмотрите, что будет в этих свойствах. Для более подробного изучения смотрите документацию, а также обратите внимание на свойства outerText, innerHtml и outerHtml. Порой нам необходимо вытащить у элемента <img> ссылку на изображение, которая находится в атрибуте src или значение элемента <input>. Сделать это довольно просто: В предыдущей статье мы использовали метод evaluateJavaScript для получения заголовка документа. Остановимся на нем поподробней. В этот метод мы передаем строку, которая представляет из себя код на JavaScript и получаем результат. Если мы ничего не возвращаем явно, то JavaScript runtime вернет нам undefined. Для отслеживания, каких либо блоков кода на JavaScript удобно использовать в качестве возвращаемого результата true или false. В completionHandler передается результат исполнения скрипта или ошибка. В документации сказано, что вызов этого метода равноценен вызову метода, где значение frame – nil представляет главный фрейм, а значение contentWorld – WKContentWorld.pageWorld: Данное улучшение доступно начиная с iOS 14. Рассмотрим его более подробно. Это ключевой параметр функции, который дает нам большую гибкость и обеспечивает предотвращение многих конфликтов. Мы можем его использовать для предотвращения конфликтов между скриптами на веб-странице. Выполнение сценарии JavaScript в собственном WKContentWorld дает нам отдельную копию переменных среды для изменения. Получим заголовок из шапки и покажем его в нашем NavigationView. Для этого создадим файл JSUserScripts.swift, где напишем сценарии JavaScript, которые будут модифицировать страницу и забирать из нее данные. Напишем две функции для получения и скрытия заголовка на веб-странице: JSUserScripts.swift Теперь заменим старый метод evaluateJavaScript улучшенной версией: Свойство Если нам нужно запустить скрипт внутри пространства имен текущей веб-страницы, мы можем использовать Также мы можем создавать собственные пространства имен: В нашем примере это излишество, но если бы мы создавали веб-браузер с расширениями на JavaScript, пришлось бы создавать уникальное пространство имен для каждого расширения. Еще один интересный метод, который пришел вместе с iOS 14: В параметр functionBody, в качестве строки мы передаем само тело функции, а в качестве arguments мы можем передавать переменные, которые будут использованы в функции, что очень удобно. Такая гибкость позволяет нам переиспользовать блоки кода JavaScript. Как видно из названия, Async означает, что строка будет запущена как асинхронная функция. В этом случае можно использовать await и работать с JavaScript-обещаниями (Promise). Такая техника необходима, когда мы не знаем, когда именно будет завершена работа функции или когда необходимо что-то запустить через определенное время (например setTimeout). Рассмотрим несколько примеров: Исполним JavaScript: Код стал элегантней. Теперь мы можем передавать селекторы в качестве аргументов и скрывать любые элементы веб-страницы. Напишем простое обещание (promise): Исполним JavaScript: После трех секунд ожидания мы получим результат “foo” в приложении. Когда мы разобрались с исполнением сценариев JavaScript, инициируя вызов из Swift, рассмотрим и обратный процесс – как выполнить код Swift при помощи JavaScript. Такую коммуникацию нам помогает реализовать протокол WKScriptMessageHandler и объект WKUserContentController, который предоставляет возможность отправки сообщений JavaScript в WKWebView. Протокол WKScriptMessageHandler определяет всего одну функцию, в которой мы можем получить сообщение из запущенных на веб-странице сценариев. Напишем расширение для Coordinator, в которое и добавим реализацию этой функции: В методе makeUIView (здесь мы создаем WebView и задаем его конфигурацию) укажем наш класс, который реализует обработчик сообщений и его имя: Добавим скрипт на веб-страницу, и отправим сообщение: Запустим и проверим, что получаем наше сообщение с веб-страницы в консоли. На этом пока все. Код проекта доступен на Github. P.S. Happy Code! Создаем 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>
WebView(type: .local, url: "local", viewModel: viewModel)
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?
А зачем оно все?
Пара слов о global scope
const title = document.title
, то получите ошибку. Это можно исправить, если заменить const на let или ввести локальную область видимости (local scope), заключив код в фигурные скобки:
{ const title = document.title; }
function getDocTittle() { const title = document.title; console.log(title); } getDocTitle();
Селекторы
const h1 = document.querySelector('h1');
const title = document.querySelector('.title');
<h1 class="title">Interaction with JavaScript</h1>
const button = document.querySelector('#ok');
<button id="ok">OK</button>
let divs = document.querySelectorAll("div")
[div.container, div.item, div.item]
let itemsContent = document.querySelectorAll("div.item h1");
itemsContent.forEach(function(title) { console.log(title.innerText)) }
Скрытие элементов
title.style.display = 'none';
Существует ли элемент?
const h2 = document.querySelector('h2'); if(h2) { h2.style.display = 'none'; }
Клики и события
button.addEventListener('click', function(e) { console.log(e); console.log('OK Clicked'); });
Как получить контент элемента или атрибута?
title.innerText
и title.textContent
в данном случае возвращают одни и те же результаты. Однако они весьма отличаются. textContent может вернуть нам даже текстовое содержимое блока <style>.
let imgSrc = img.src;
const input = document.querySelector('#mylInput'); let inputValue = input.value
Run JS! Run!
open func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil)
public func evaluateJavaScript(_ javaScript: String, in frame: WKFrameInfo? = nil, in contentWorld: WKContentWorld, completionHandler: ((Result<Any, Error>) -> Void)? = nil)
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(); """
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"))
public func callAsyncJavaScript(_ functionBody: String, arguments: [String : Any] = [:], in frame: WKFrameInfo? = nil, in contentWorld: WKContentWorld, completionHandler: ((Result<Any, Error>) -> Void)? = nil)
let hideAnyElement = """ const element = document.querySelector(selector); element.style.display = 'none'; """ let getElementInnerText = """ const element = document.querySelector(selector); return element.innerText; """
webView.callAsyncJavaScript(hideAnyElement, arguments: ["selector":"#ok"], in: nil, in: .defaultClient)
let setTimeoutFor = """ const myPromise = new Promise((resolve, reject) => { window.setTimeout(function (){ resolve('foo'); }, timeout); }); await myPromise; return myPromise; """
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) } }
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)") } } } }
webView.configuration.userContentController.add(context.coordinator, contentWorld: .page, name: "messageAppHandler")
<script type="text/javascript"> sendValueToApp(); function sendValueToApp() { window.webkit.messageHandlers.messageAppHandler.postMessage('Hello from web page'); } </script>
- 8 views
- 0 Comment
Свежие комментарии