시리즈: SwiftUI Representable
- SwiftUI: Representable을 이용해서 UIViewController 띄우기
- SwiftUI: 하드웨어 키보드 입력 받기 (Representable 사용)
- SwiftUI: 웹 뷰(WKWebView) 추가하기 및 자바스크립트 실행 (Representable 사용)
소개
SwiftUI 내부에 웹 뷰(WKWebView
)를 추가하는 방법입니다. 원래 웹 페이지를 표시하는 WKWebView
는 UIKit
과 호환되는 요소인데, 찾아본 결과 SwiftUI에는 웹을 표시할 수 있는 뷰가 없는 것처럼 보여서 역시 Representable을 이용해서 간접적으로 추가하는 방법을 설명하겠습니다.
방법
두 가지 방법을 알아보겠습니다.
- 단순히 SwiftUI에 웹 뷰를 추가하는 방법입니다.
- 위 예제의 웹 뷰에서
evaluateJavaScript(_:)
를 원하는 시점에 실행할 수 있도록 합니다.
웹 뷰를 추가하는 방법
1) UIViewRepresentable을 준수(conform)하고 웹 뷰를 감싸는 WebView를 추가합니다.
struct WebView: UIViewRepresentable { typealias UIViewType = WKWebView var url: URL? func makeUIView(context: Context) -> UIViewType { let preferences = WKPreferences() preferences.javaScriptCanOpenWindowsAutomatically = false // JavaScript가 사용자 상호 작용없이 창을 열 수 있는지 여부 let configuration = WKWebViewConfiguration() configuration.preferences = preferences let webView = WKWebView(frame: CGRect.zero, configuration: configuration) webView.allowsBackForwardNavigationGestures = true // 가로로 스와이프 동작이 페이지 탐색을 앞뒤로 트리거하는지 여부 webView.scrollView.isScrollEnabled = true // 웹보기와 관련된 스크롤보기에서 스크롤 가능 여부 if let url = url { webView.load(URLRequest(url: url)) // 지정된 URL 요청 개체에서 참조하는 웹 콘텐츠를 로드하고 탐색 } return webView } func updateUIView(_ uiView: UIViewType, context: Context) { // ... 잠시 후 작성 ... // } }
UIViewRepresentable
에 관한 자세한 내용은 SwiftUI: Representable을 이용해서 UIViewController 띄우기를 참고해주세요. (뷰 컨트롤러에 관한 글이지만 뷰(UIView
)도 내용이 거의 같습니다.)makeUIView
함수에서WKWebView
인스턴스를 리턴합니다. 필요한 경우 함수 내부에서 각종 설정 등을 미리 지정합니다.- var url
- 파라미터로
URL
을 받습니다.
- 파라미터로
2) SwiftUI의 뷰(ContentView 등) 내부에 위의 Representable을 추가합니다.
var body: some View { // ... // WebView(url: URL(string: "https://www.website.con")) // ... // }
url
파라미터에 URL
을 입력해서 유효한 주소인 경우 웹 페이지가 표시됩니다. 아래 스크린샷은 SwiftUI의 뷰 내부에 WebView
를 삽입한 예제입니다.
웹 뷰에서 evaluateJavaScript(_:)를 원하는 시점에 실행
SwiftUI 환경에서 ContentView
내부에 버튼이 있는데 이 버튼을 누르면 웹 페이지에서 특정 자바스크립트 코드를 실행하고 싶다면 어떻게 할까요?
일반 UIKit이었다면 단순히 버튼 이벤트 내부에 webView.evaluateJavasScript("스크립트")
를 넣었으면 되었지만 SwiftUI 환경에서는 굉장히 복잡합니다.
1) WebViewData 클래스를 추가
import Combine // ... // class WebViewData: ObservableObject { var functionCaller = PassthroughSubject<String, Never>() var shouldUpdateView = true }
- functionCaller
PassthroughSubject<Output, Failure>
타입입니다.String
값을 내보냅니다.
- shouldUpdateView
updateUIView
를 실행해야 하는지 여부에 대한Bool
값입니다.true
로 지정합니다.
- [심화] PassthroughSubject
- Downstream 구독자(subscribers)들에게 값을 전파하는 subject(send 메서드를 호출해서 stream에 값을 주입하기 위해 사용하는 Publisher)입니다.
CurrentValueSubject
와 달리 value값 접근 불가, 최신값을 저장하지 않는다는 차이점이 있습니다.- 기존의 명령형(imperative) 코드를
Combine
모델로 적용할 때 편리한 방법을 제공하는 Subject Class입니다. - 구독자가 없거나 demand 값이 0인 경우 값을 버립니다.
- 자세한 내용: https://0urtrees.tistory.com/324
2) WebView 내에 WebViewData에 대한 상태 변수 추가
struct WebView: UIViewRepresentable { // ... // @StateObject var data: WebViewData // ... // }
3) WebView 내에 코디네이터를 추가
import Combine // ... // func makeCoordinator() -> Coordinator { return Coordinator(self) } class Coordinator: NSObject, WKNavigationDelegate { /// WebView Representable var parentWebView: WebView var webView: WKWebView? = nil private var cancellable: AnyCancellable? init(_ parentWebView: WebView) { self.parentWebView = parentWebView super.init() } func tieFunctionCaller(data: WebViewData) { print("Passthrough:", #function) cancellable = data.functionCaller.sink(receiveValue: { js in self.webView?.evaluateJavaScript(js) }) } }
- 코디네이터에 관한 자세한 내용은 SwiftUI: Representable을 이용해서 UIViewController 띄우기를 참고해주세요.
- parentWebView
- Representable View의 인스턴스를 담습니다.
- webView
- Representable View의
makeUIView(...)
를 통해 생성된WKWebView
인스턴스를 담습니다. - 잠시 후 자세히 설명합니다.
- Representable View의
- init(_ parentWebView: WebView)
- 파라미터로
WebView
를 지정합니다. - 이것을
makeCoordinator()
에서 리턴시킵니다.
- 파라미터로
- tieFunctionCaller(…)
WebViewData
의functionCalller
를 소환하는 역할을 합니다.functionCalller
는PassthroughSubject
이므로sink
를 호출할 수 있습니다.- 어느 특정 조건이 되면(예: 버튼을 누른 경우)
String
값이 배출됩니다. sink
를 통해 배출된 js값(String
타입)을webView?.evaluateJavaScript(js)
로 실행합니다.
4) WebView 내에 updateUIView를 작성합니다.
func updateUIView(_ uiView: UIViewType, context: Context) { guard data.shouldUpdateView else { data.shouldUpdateView = false return } context.coordinator.tieFunctionCaller(data: data) context.coordinator.webView = uiView }
updateUIView
는 웹뷰가 실행된 시점에 바로 실행되며,makeUIView
다음에 실행됩니다.uiView
는 현재 실행되고 있는WebView
(=>UIViewType
)입니다.context
는UIViewRepresentable
(=>WebView
또는UIViewType
)에 대한 컨텍스트 변수입니다.- context.coordinator
- 현재 컨텍스트에 있는 코디네이터입니다.
teiFunctionCaller
함수를 실행합니다.- 코디네이터의
webView
를uiView
와 연결합니다.
5) SwiftUI의 뷰(ContentView 등) 내부에 WebViewData를 추가합니다.
struct ContentView: View { // ... // @StateObject var webViewData = WebViewData() // ... // }
6) SwiftUI의 뷰(ContentView 등) 내부에 WebView를 추가합니다.
var body: some View { WebView(url: URL(string: "https://example.con"), data: webViewData) }
앞 섹션과의 차이점은 WebView
의 파라미터로 data
가 추가된 점입니다. 여기서 webViewData
상태 변수를 추가합니다.
7) 버튼을 누르면 특정 자바스크립트가 실행되도록 하기
webViewData
를 이용합니다.
Button { webViewData.functionCaller.send( """ document.querySelector("button[id^='playbut']").click() """ ) } label: { Image(systemName: "play.fill") }
evaluateJavaScript
가 실행되기 까지의 과정을 간략하게 설명하면 다음과 같습니다.
- updateUIView(_:context:)
<-tieFunctionCaller
실행,webView
:WKWebView
등록 - 커스텀 JS 데이터 전달
@StateObject webViewData.functionCaller.send("CUSTOM_JS")
- tieFunctionCaller(data:)에서 data.functionCalller.sink…
<-webView?.evaluateJS
실행
전체 코드
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
import SwiftUI | |
import WebKit | |
import Combine | |
/* | |
https://stackoverflow.com/questions/66581811/call-evaluatejavascript-from-a-swiftui-button | |
*/ | |
class WebViewData: ObservableObject { | |
var functionCaller = PassthroughSubject<String, Never>() | |
var shouldUpdateView = true | |
} | |
struct WebView: UIViewRepresentable { | |
typealias UIViewType = WKWebView | |
var url: URL? | |
@StateObject var data: WebViewData | |
func makeUIView(context: Context) -> UIViewType { | |
let preferences = WKPreferences() | |
preferences.javaScriptCanOpenWindowsAutomatically = false // JavaScript가 사용자 상호 작용없이 창을 열 수 있는지 여부 | |
let configuration = WKWebViewConfiguration() | |
configuration.preferences = preferences | |
let webView = WKWebView(frame: CGRect.zero, configuration: configuration) | |
webView.allowsBackForwardNavigationGestures = true // 가로로 스와이프 동작이 페이지 탐색을 앞뒤로 트리거하는지 여부 | |
webView.scrollView.isScrollEnabled = true // 웹보기와 관련된 스크롤보기에서 스크롤 가능 여부 | |
if let url = url { | |
webView.load(URLRequest(url: url)) // 지정된 URL 요청 개체에서 참조하는 웹 콘텐츠를 로드하고 탐색 | |
} | |
return webView | |
} | |
func updateUIView(_ uiView: UIViewType, context: Context) { | |
guard data.shouldUpdateView else { | |
data.shouldUpdateView = false | |
return | |
} | |
context.coordinator.tieFunctionCaller(data: data) | |
context.coordinator.webView = uiView | |
} | |
func makeCoordinator() -> Coordinator { | |
return Coordinator(self) | |
} | |
class Coordinator: NSObject, WKNavigationDelegate { | |
/// WebView Representable | |
var parentWebView: WebView | |
var webView: WKWebView? = nil | |
private var cancellable: AnyCancellable? | |
init(_ parentWebView: WebView) { | |
self.parentWebView = parentWebView | |
super.init() | |
} | |
func tieFunctionCaller(data: WebViewData) { | |
cancellable = data.functionCaller.sink(receiveValue: { js in | |
self.webView?.evaluateJavaScript(js) | |
}) | |
} | |
} | |
} | |
struct WebView_Previews: PreviewProvider { | |
static var previews: some View { | |
let url = URL(string: "https://google.com") | |
let webViewData = WebViewData() | |
WebView(url: url, data: webViewData) | |
} | |
} |
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
// | |
// BodyView.swift | |
// Story One | |
// | |
// Created by 윤범태 on 2023/03/25. | |
// | |
import SwiftUI | |
struct BodyView: View { | |
@Binding var isTodoStateChanged: Bool | |
@State var chordTodo: ChordTodo | |
@State var showRemoveAlert = false | |
@State var showUpdateForm = false | |
@StateObject var webViewData = WebViewData() | |
@Environment(\.dismiss) var dismiss | |
var body: some View { | |
VStack { | |
Text(chordTodo.title) | |
.font(.largeTitle) | |
Divider() | |
Text("코드") | |
.font(.largeTitle) | |
HStack { | |
Text(chordTodo.chord) | |
.font(.title2) | |
Button { | |
webViewData.functionCaller.send( | |
""" | |
// document.querySelector("h1").textContent = "JS Evaluated" | |
document.querySelector("button[id^='playbut']").click() | |
""" | |
) | |
} label: { | |
Image(systemName: "play.fill") | |
} | |
} | |
// 웹뷰 버그: https://developer.apple.com/forums/thread/714467?answerId=734799022#734799022 | |
WebView(url: URL(string: "https://www.scales-chords.com/chord/piano/\(chordTodo.chord.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? chordTodo.chord)"), data: webViewData) | |
Divider() | |
Text(chordTodo.comment.isEmpty ? "No Comment…" : chordTodo.comment) | |
Divider() | |
HStack { | |
Button { | |
if var list = try? UserDefaults.standard.getObject(forKey: .cfgTodoList, castTo: [ChordTodo].self) { | |
list.removeAll { $0.id == self.chordTodo.id } | |
print("deleted list", self.chordTodo.id, list) | |
try? UserDefaults.standard.setObject(list, forKey: .cfgTodoList) | |
isTodoStateChanged = true | |
dismiss() | |
} | |
} label: { | |
Text("삭제") | |
.foregroundColor(.red) | |
} | |
Spacer() | |
Button("업데이트") { | |
showUpdateForm = true | |
}.sheet(isPresented: $showUpdateForm, onDismiss: { | |
if isTodoStateChanged { | |
print("Todo on bodyView: updated") | |
// TODO: – 업데이트 완료하면 BodyView에 내용 반영되게 하기 | |
dismiss() | |
} else { | |
print("Todo on bodyView: not updated") | |
} | |
}) { | |
WriteView(isWriteSuccess: $isTodoStateChanged, mode: .update, todoTitle: chordTodo.title, chordText: chordTodo.chord, comment: chordTodo.comment, id: chordTodo.id) | |
} | |
Spacer() | |
Button("닫기") { | |
dismiss() | |
} | |
}.padding(sides: [.left, .right], value: 20) | |
} | |
} | |
} | |
struct BodyView_Previews: PreviewProvider { | |
static var previews: some View { | |
StatefulPreviewWrapper(false) { | |
BodyView(isTodoStateChanged: $0, chordTodo: ChordTodo(title: "불안하다", chord: "Cdim7", comment: "comment….")) | |
} | |
} | |
} |
0개의 댓글