728x90
안녕하세요.
이번에는 TCA를 활용하여 WebView를 만들어볼게요.
(TCA 0.54.0 기준으로 작성했으며, 전체 코드는 여기를 봐주세요!)
Reducer, Scope, TaskResult 등 지금까지 TCA에 대해서 간단하게 알아봤는데요.
지금까지 공부한 것을 활용하면 자식 WebView를 만들 수 있습니다!!ㅎㅎ
[참고] TCA로 WebView 만들면 뭐가 좋아?
UIKit 기반의 WebKit의 이벤트를 SwiftUI로 던져주거나, 반대로 SwiftUI 이벤트를 WebKit으로 던져주기 쉽습니다.
이전 글에서 다 다뤄봤던 내용이기 때문에 자세하게 설명은 생략하고 WebView, WebViewReducer 코드 먼저 보여드릴게요.
(좀 길어요..;;)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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로 등록하는 것으로, 웹뷰로 이벤트를 전달하거나 웹뷰의 이벤트를 전달받을 수 있습니다.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
} | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} | |
} |


더 스마트한 방법 알고 계신 분 계시면 공유해 주세요!!!
이번 글은 여기서 마무리.
반응형
'SwiftUI' 카테고리의 다른 글
TCA(8) : Binding (1) | 2023.08.09 |
---|---|
TCA(7) : 0.54.0 -> 1.0.0 업데이트 (0) | 2023.08.07 |
TCA(5) : 부모-자식 간 이벤트 전달 (0) | 2023.07.20 |
TCA(4) : Scope (1) | 2023.07.12 |
navigationBar가 숨김 처리된 상태에서 제스처로 화면 뒤로가기 (0) | 2023.06.28 |