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!

  • 8 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 2022 / All rights reserved

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