SwiftUI

TCA(6) : WebView

Phililip 2023. 7. 28.
728x90

안녕하세요.

 

이번에는 TCA를 활용하여 WebView를 만들어볼게요.

 

(TCA 0.54.0 기준으로 작성했으며, 전체 코드는 여기를 봐주세요!)

 


Reducer, Scope, TaskResult 등 지금까지 TCA에 대해서 간단하게 알아봤는데요.

 

지금까지 공부한 것을 활용하면 자식 WebView를 만들 수 있습니다!!ㅎㅎ

[참고] TCA로 WebView 만들면 뭐가 좋아?

UIKit 기반의 WebKit의 이벤트를 SwiftUI로 던져주거나, 반대로 SwiftUI 이벤트를 WebKit으로 던져주기 쉽습니다.

 

 

이전 글에서 다 다뤄봤던 내용이기 때문에 자세하게 설명은 생략하고 WebView, WebViewReducer 코드 먼저 보여드릴게요.

(좀 길어요..;;)

 

struct WebView: UIViewControllerRepresentable {
private let url: URL
/*
단순히, 웹뷰만 띄우고 싶을 경우엔 Store를 넘길 필요가 없기 때문에 optional로 설정.
*/
private let store: StoreOf<WebViewReducer>?
init(url: URL, store: StoreOf<WebViewReducer>? = nil) {
self.url = url
self.store = store
}
func makeUIViewController(context: Context) -> some UIViewController {
let webViewController = WebViewController(url: url, store: store)
return webViewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { }
}
view raw WebView.swift hosted with ❤ by GitHub
struct WebViewReducer: ReducerProtocol {
struct State: Equatable {
fileprivate weak var webViewController: WebViewController?
}
enum Action: Equatable {
case setWebViewController(WebViewController)
case refresh
case canGoBack(Bool)
case canGoForward(Bool)
case goBack
case goForward
case titleChanged(String)
case urlChanged(URL)
case decidePolicyForNavigationAction
case decidePolicyForNavigationResponse
case didFinishNavigation
case didFailNaviation
case didFailProvisionalNavigation
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case let .setWebViewController(webViewController):
state.webViewController = webViewController
return .none
case .refresh:
state.webViewController?.refresh()
return .none
case .goBack:
state.webViewController?.goBack()
return .none
case .goForward:
state.webViewController?.goForward()
return .none
default:
return .none
}
}
}
class WebViewController: UIViewController {
private let url: URL
private let viewStore: ViewStoreOf<WebViewReducer>?
private let webView: WKWebView
init(url: URL, store: StoreOf<WebViewReducer>?) {
self.url = url
self.webView = WKWebView(frame: .zero)
if let store {
self.viewStore = ViewStore(store)
} else {
self.viewStore = nil
}
super.init(nibName: nil, bundle: nil)
viewStore?.send(.setWebViewController(self))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
self.webView.navigationDelegate = self
self.view.addSubview(self.webView)
self.webView.frame = self.view.frame
self.webView.load(URLRequest(url: url))
self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.url), options: .new, context: nil)
self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.title), options: .new, context: nil)
self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack), options: .new, context: nil)
self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.canGoForward), options: .new, context: nil)
}
deinit {
self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.url))
self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.title))
self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack))
self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoForward))
}
func refresh() {
guard let targetURL = self.webView.url else {
return
}
self.webView.evaluateJavaScript("document.body.remove()")
self.webView.load(URLRequest(url: targetURL))
}
func goBack() {
self.webView.goBack()
}
func goForward() {
self.webView.goForward()
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == #keyPath(WKWebView.url) {
guard let url = self.webView.url else {
return
}
self.viewStore?.send(.urlChanged(url))
} else if keyPath == #keyPath(WKWebView.title) {
guard let title = self.webView.title else {
return
}
self.viewStore?.send(.titleChanged(title))
} else if keyPath == #keyPath(WKWebView.canGoBack) {
self.viewStore?.send(.canGoBack(self.webView.canGoBack))
} else if keyPath == #keyPath(WKWebView.canGoForward) {
self.viewStore?.send(.canGoForward(self.webView.canGoForward))
}
}
}
// MARK: - WKNavigationDelegate
extension WebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
viewStore?.send(.decidePolicyForNavigationAction)
return .allow
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy {
viewStore?.send(.decidePolicyForNavigationResponse)
return .allow
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
viewStore?.send(.didFinishNavigation)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
viewStore?.send(.didFailNaviation)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
viewStore?.send(.didFailProvisionalNavigation)
}
}

 

아직까진 SwiftUI 용 WebView가 따로 없기 때문에, UIViewControllerRepresentable를 사용해서 WebViewController를 감싼 WebView라는 것을 만들었어요.

 

WebView 생성 시 WebViewReducer를 전달받아서 WebViewController로 전달해 주고요.

 

WebViewController에서는 observer 또는 delegate로 이벤트를 받으면, 그 이벤트를 WebViewReducer로 전달시키는 방식입니다.

 

 

WebView를 사용하는 곳(=부모)에선 WebViewReducer를 자식 Reducer로 등록하는 것으로, 웹뷰로 이벤트를 전달하거나 웹뷰의 이벤트를 전달받을 수 있습니다.

 

struct MyWebPageView: View {
let store: StoreOf<MyWebPage>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
NavigationStack {
ZStack {
WebView(
url: URL(string: "https://phillip5094.tistory.com/")!,
store: store.scope(state: \.webView, action: MyWebPage.Action.webView)
)
if viewStore.isLoading {
ProgressView()
}
}
.navigationTitle(viewStore.navigationBarTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button {
viewStore.send(.goBackButtonTapped)
} label: {
Image(systemName: "chevron.backward")
}
.disabled(viewStore.canGoBackButtonDisabled)
Button {
viewStore.send(.goForwardButtonTapped)
} label: {
Image(systemName: "chevron.forward")
}
.disabled(viewStore.canGoForwardButtonDisabled)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewStore.send(.refreshButtonTapped)
} label: {
Image(systemName: "arrow.clockwise")
}
}
}
}
}
}
}
struct MyWebPage: ReducerProtocol {
struct State: Equatable {
var isLoading = true
var navigationBarTitle = ""
var canGoBackButtonDisabled = true
var canGoForwardButtonDisabled = true
var webView = WebViewReducer.State()
}
enum Action: Equatable {
// NavigationBar Action
case refreshButtonTapped
case goBackButtonTapped
case goForwardButtonTapped
// WebView
case webView(WebViewReducer.Action)
}
var body: some ReducerProtocol<State, Action> {
Scope(state: \.webView, action: /Action.webView) {
WebViewReducer()
}
Reduce { state, action in
switch action {
case .refreshButtonTapped:
state.isLoading = true
return .run { send in await send(.webView(.refresh)) }
case .goBackButtonTapped:
return .run { send in await send(.webView(.goBack)) }
case .goForwardButtonTapped:
return .run { send in await send(.webView(.goForward)) }
case .webView(.titleChanged(let title)):
state.navigationBarTitle = title
return .none
case .webView(.canGoBack(let canGoBack)):
state.canGoBackButtonDisabled = !canGoBack
return .none
case .webView(.canGoForward(let canGoForward)):
state.canGoForwardButtonDisabled = !canGoForward
return .none
case .webView(.didFinishNavigation):
if state.isLoading {
state.isLoading = false
}
return .none
default:
return .none
}
}
}
}
view raw MyWebPage.swift hosted with ❤ by GitHub

 

 

더 스마트한 방법 알고 계신 분 계시면 공유해 주세요!!!

 


이번 글은 여기서 마무리.

 

 

 

반응형