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

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

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

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

Погружение в 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
				}

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

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

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

BestITSolutions
11 февраля 2025

Лучшие конструкторы мобильных приложений

Специализированные конструкторы мобильных приложений позволяют запустить со...
natkaida
20 февраля 2025

📱🧮 Думаешь, каждый может сделать крутой калькулятор? Как Android обошел iOS в создании идеального калькулятора

Когда в Google решили написать по-настоящему точный калькулятор для Android...
BestITSolutions
20 ноября 2024

ТОП-25 курсов Flutter, включая бесплатное онлайн-обучение разработке

Мечтаете создавать мобильные приложения, но не знаете с чего начать? Курсы ...