17 апреля 2021

🍏 Взаимодействие SwiftUI с вебом. Часть вторая: Web Navigation

Мобильный разработчик с 9 летним стажем.
В предыдущей статье мы создали WebView и подгрузили в него сайт proglib.io. Сегодня займемся пользовательским интерфейсом приложения, навигацией и получением информации с веб-страницы в Swift c помощью JavaScript.
🍏 Взаимодействие SwiftUI с вебом. Часть вторая: Web Navigation

Интерфейс приложения

Мы уже добавили в проект (код доступен на GitHub – прим. ред.) перечисление WebViewNavigationAction, которое описывает три действия: назад, вперед, перезагрузить. Создадим для них SwiftUI View, и назовем ее WebNavigationView, в который добавим кнопки действий. Поскольку WebView подгружает веб-страницу из интернета, а это действие не происходит мгновенно, добавим LoaderView чтобы показать пользователю прогресс загрузки.


На создании пользовательского интерфейса подробно останавливаться не будем: просто покажем его реализацию. Пишите в комментариях, какие темы вам хотелось бы увидеть в следующих статьях.
LoaderView
        import SwiftUI

struct LoaderView: View {
	@State var isSpinCircle = false
    var body: some View {
		ZStack {
			Circle()
				.frame(width: 60, height: 60, alignment: .center)
			VStack {
				Circle()
					.trim(from: 0.3, to: 1)
					.stroke(Color.white, lineWidth: 2)
					.frame(width:50, height: 50)
					.padding(.all, 8)
					.rotationEffect(.degrees(isSpinCircle ? 0 : -360), anchor: .center)
					.animation(Animation.linear(duration: 0.6).repeatForever(autoreverses: false))
					.onAppear {
						self.isSpinCircle = true
					}
			}
		}
	}
}

struct LoaderView_Previews: PreviewProvider {
    static var previews: some View {
        LoaderView()
    }
}
    
WebNavigationView
        import SwiftUI

struct WebNavigationView: View {
    var body: some View {
		VStack {
			Divider()
			HStack(spacing: 10) {
				Divider()
				Button(action: {}, label: {
					Image(systemName: "chevron.left")
						.font(.system(size: 30, weight: .regular))
						.imageScale(.medium)
				})
				Divider()
				Button(action: {}, label: {
					Image(systemName: "chevron.right")
						.font(.system(size: 30, weight: .regular))
						.imageScale(.medium)
				})
				Divider()
				Spacer()
				Divider()
				Button(action: {}, label: {
					Image(systemName: "arrow.clockwise")
						.font(.system(size: 30, weight: .regular))
						.imageScale(.medium)
				})
				Divider()
			}
			.frame(height: 50)
			Divider()
		}
    }
}

struct WebNavigationView_Previews: PreviewProvider {
    static var previews: some View {
        WebNavigationView()
    }
}
    

Пока действие нажатия на кнопку action оставим пустыми. Вернемся к ним позже.

Пара слов о ObservableObject
@ObservableObject – это обертка, которую мы можем разделить между несколькими View. View могут подписываться и наблюдать за изменениями этого объекта.

ViewModel

Мы добрались до самого интересного! Теперь подружим все элементы вместе. Для начала создадим ViewModel и подумаем, чего мы хотим от WebView.

  1. Получить заголовок веб-страницы – добавим свойство WebTitle;
  2. Определять действия навигации – добавим свойство webViewNavigationPublisher;
  3. Определять когда нужно показать LoaderView – добавим свойство isLoaderVisible.
ViewModel
        import Foundation
import Combine

class ViewModel: ObservableObject {
	var isLoaderVisible = PassthroughSubject<Bool, Never>();
	var webTitle = PassthroughSubject<String, Never>()
	var webViewNavigationPublisher = PassthroughSubject<WebViewNavigationAction, Never>()
}
    

Обновим ContentView, определим ViewModel и состояния isLoaderVisible:

        @ObservedObject var viewModel = ViewModel()
@State var isLoaderVisible = false
    

Пора добавить весь созданный интерфейс в ContentView

        var body: some View {
		ZStack {
			VStack(spacing: 0) {
				WebNavigationView()
				WebView(type: .public, url: "https://proglib.io", viewModel: viewModel)
				
			}
			if isLoaderVisible {
				LoaderView()
			}
		}
}
    

Теперь вернемся в WebView и добавим свойство @ObservedObject var viewModel: ViewModel, а также метод makeCoordinator, который будет возвращать Coordinator для взаимодействия с функциями делагата из WKNavigationDelegate.

        func makeCoordinator() -> Coordinator {
        Coordinator(self)
}
    

Напишем реализацию класса Coordinator.

        class Coordinator: NSObject, WKNavigationDelegate {
		var parent: WebView
		var webViewNavigationSubscriber: AnyCancellable? = nil
		
		init(_ webView: WebView) {
			self.parent = webView
		}
		
		deinit {
			webViewNavigationSubscriber?.cancel()
		}
	}
    

Прежде чем заняться навигацией в WebView, проверим что все работает. Изменим состояние isLoaderVisible на true и посмотрим результат в preview.

🍏 Взаимодействие SwiftUI с вебом. Часть вторая: Web Navigation

Погружение в WKNavigationDelegate

Давайте посмотрим, что нам предлагает реализовать протокол. Перейдите к его определению (Jump to Definition в Xcode; или зажмите ⌃⌘ и кликните). Как видите, все методы опциональные и не требуют реализации.

Далее приведем описание функций, чтобы вы их знали и могли использовать в своих целях.

***
didStartProvisionalNavigation
Метод вызывается при запуске навигации по главному фрейму.

func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!)

didFailProvisionalNavigation
Метод вызывается, когда происходит ошибка при запуске загрузки данных в главный фрейм.

func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error)

didReceiveServerRedirectForProvisionalNavigation
Метод вызывается, когда сервер получил перенаправление для главного фрейма.

func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!)

didCommit
Метод вызывается, когда начинает поступать контент для главного фрейма.

func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!)

didFail
Метод вызывается, когда возникает ошибка при комите (фиксации) в главном фрейме навигации.

func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error)

didFinish
Метод вызывается, когда навигация завершена.

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!)

didReceive
Метод вызывается, когда WebView необходимо ответить на запрос аутентификации.

func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)

webViewWebContentProcessDidTerminate
Метод вызывается, когда обработка контента в WebView прервана.

func webViewWebContentProcessDidTerminate(_ webView: WKWebView)

authenticationChallenge
Метод вызывается, когда WebView устанавливает соединение с использованием устаревшей версии TLS.

func webView(_ webView: WKWebView, authenticationChallenge challenge: URLAuthenticationChallenge, shouldAllowDeprecatedTLS decisionHandler: @escaping (Bool) -> Void)

decidePolicyFor
Метод принимает решение о разрешении или отклонении навигации на основе известного ответа. Здесь стоит отметить, что если не реализовать этот метод, то WebView загрузит запрос и при необходимости перенаправит в другое приложение.

Web Navigation

Добавим реализацию основных функций делегата в проект и пропишем везде вывод в консоль, для наглядности (пользователю нужно видеть, что происходит при загрузке).

В методе makeUIView, у WebView укажем координатор, где мы реализовали протокол WKNavigationDelegate

webView.navigationDelegate = context.coordinator

Поскольку мы добавили реализацию decidePolicyFor, нужно явно определить политику навигации. WKNavigationActionPolicy – это перечисление с двумя значениями allow и cancel. На данном этапе мы разрешим все.

decisionHandler(.allow, preferences)

Протестируйте проект и посмотрите, как работает приложение.

Займемся состоянием загрузки веб-страницы. Из описаний функций становится понятно, когда нужно скрыть или показать LoaderView – мы просто сообщим ViewModel необходимое состояние.

self.parent.viewModel.isLoaderVisible.send(true)

В ContentView обработаем это действие. Вызовем у VStack onReceive и установим состоянию isLoaderVisible значение из ViewModel

        VStack(spacing: 0) {
// views
}.onReceive(self.viewModel.isLoaderVisible.receive(on: RunLoop.main)) { value in self.isLoaderVisible = value }
    

Отлично, процесс загрузки веб-страницы работает как часы.

Вернемся в WebView и добавим логику для действий навигации в функции didStartProvisionalNavigation

        self.webViewNavigationSubscriber = self.parent.viewModel.webViewNavigationPublisher.receive(on: RunLoop.main).sink(receiveValue: { navigation in
				switch navigation {
					case .backward:
						if webView.canGoBack {
							webView.goBack()
						}
					case .forward:
						if webView.canGoForward {
							webView.goForward()
						}
					case .reload:
						webView.reload()
				}
			})

    

Осталось добавить обработчики действий в WebNavigationView, которые мы оставили пустыми. Необходимо передать ViewModel во View и отправить соответствующее действие для каждой кнопки. Например, для перезагрузки:

viewModel.webViewNavigationPublisher.send(.reload)

Прежде чем тестировать навигацию, поработаем над получением title веб-страницы.

При помощи метода evaluateJavaScript из webView мы можем вызвать любой код на JavaScript и получать результат в Swift, т.е. получить любую информацию с веб-страницы. В методе didFinish, когда навигация завершена, получим title и сообщим его ViewModel.

         webView.evaluateJavaScript("document.title") { (response, error) in
                if let error = error {
		print("Error  evaluateJavaScript")
                    print(error.localizedDescription)
                }
                
                guard let title = response as? String else {
                    return
                }
                
                self.parent.viewModel.showWebTitle.send(title)
}
    

Теперь покажем его в WebNavigationView.

Для этого добавим состояние @State var webTitle = "", значение которого будем показывать в Text (разместим его между Divider и Spacer).

        Text(webTitle).onReceive(self.viewModel.webTitle.receive(on: RunLoop.main)) { value in
					self.webTitle = value
				}
    

Готово! Давайте протестируем, как это работает.

🍏 Взаимодействие SwiftUI с вебом. Часть вторая: Web Navigation

Первая часть цикла доступна по ссылке. Продолжение следует…

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ