Share This
Связаться со мной
Крути в низ
Categories
//🍏 Взаимодействие SwiftUI с вебом. Часть 3: JavaScript – инструкция для новичков

🍏 Взаимодействие SwiftUI с вебом. Часть 3: JavaScript – инструкция для новичков

В предыдущей статье мы разложили по полочкам навигацию в WebView. Самое время разобраться, как подружить SwiftUI с JavaScript, поскольку без этого языка программирования не обходится ни один современный сайт или мобильное приложение.

vzaimodejstvie swiftui s vebom chast 3 javascript instrukcija dlja novichkov 9c7bb4e - 🍏 Взаимодействие SwiftUI с вебом. Часть 3: JavaScript – инструкция для новичков

Мы уже добавили в проект (код доступен на 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.

Например, при помощи свойств:

  • textContent
  • innerText

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 передается результат исполнения скрипта или ошибка.

В документации сказано, что вызов этого метода равноценен вызову метода, где значение framenil представляет главный фрейм, а значение contentWorldWKContentWorld.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!

  • 1 views
  • 0 Comment

Leave a Reply

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.

Свежие комментарии

    Рубрики

    About Author 01.

    blank
    Roman Spiridonov

    Моя специальность - Back-end Developer, Software Engineer Python. Мне 39 лет, я работаю в области информационных технологий более 5 лет. Опыт программирования на Python более 3 лет. На Django более 2 лет.

    Categories 05.

    © Speccy 2020 / All rights reserved

    Связаться со мной
    Close