I'd like to use webview with html and javascript to render something. Below is the complete code to reproduce the problem. I expect TestViewPreview to first render text "hello testview", and after 5 seconds it renders "hello2 testview". However, only the 2nd one is rendered. For the first one it gives some error not sure if related:
evaluateJavaScript: render('hello testview'); TestView.updateUIView error: Error Domain=WKErrorDomain Code=4 "A JavaScript exception occurred" UserInfo={WKJavaScriptExceptionLineNumber=0, WKJavaScriptExceptionMessage=TypeError: undefined is not a function, WKJavaScriptExceptionColumnNumber=0, NSLocalizedDescription=A JavaScript exception occurred}
Could you please take a look at the code below?
struct MyState {
var text: String = "abc"
}
struct TestView: UIViewRepresentable {
let htmlPath: String
@Binding var state: MyState
func makeUIView(context: Context) -> WKWebView {
if let htmlPath = Bundle.main.path(forResource: htmlPath, ofType: "html") {
do {
let htmlString = try String(contentsOfFile: htmlPath)
WebViewHelper.webView.loadHTMLString(htmlString, baseURL: Bundle.main.bundleURL)
WebViewHelper.webView.scrollView.showsHorizontalScrollIndicator = true
print("TestView.makeUIView: Finished inital loading.")
} catch {
print("TestView.makeUIView: Error loading HTML file: \(error)")
}
}
return WebViewHelper.webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
let script = "render('\(state.text)');"
print("evaluateJavaScript: \(script)")
webView.evaluateJavaScript(script) { _, error in
if let error = error {
print("TestView.updateUIView error: \(error)")
}
}
}
}
struct TestViewPreview: View {
@State private var state = MyState(text: "hello testview")
var body: some View {
TestView(htmlPath: "test_html", state: self.$state)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
state.text = "hello2 testview"
}
}
}
}
#Preview {
TestViewPreview()
}
test_html.html
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="output"></div>
<script>
function render(text) {
document.getElementById("output").innerHTML = text
}
</script>
</body>
</html>
You should wait for the web view to finish loading the HTML first, before calling the render
function in JS.
You should use the didFinish
delegate method in WKNavigationDelegate
to detect that the web view has finished loading. Then you can invoke a callback that is passed to the TestView
.
struct TestView: UIViewRepresentable {
let htmlPath: String
@Binding var state: MyState
let didLoad: () -> Void
class Coordinator: NSObject, WKNavigationDelegate {
var didLoad: () -> Void
init(didLoad: @escaping () -> Void) {
self.didLoad = didLoad
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// if the web view will load multiple pages,
// add a flag and check it here so that didLoad only gets called once
didLoad()
}
}
func makeCoordinator() -> Coordinator {
.init(didLoad: didLoad)
}
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
if let htmlPath = Bundle.main.path(forResource: htmlPath, ofType: "html") {
do {
let htmlString = try String(contentsOfFile: htmlPath)
webView.loadHTMLString(htmlString, baseURL: Bundle.main.bundleURL)
webView.scrollView.showsHorizontalScrollIndicator = true
print("TestView.makeUIView: Finished inital loading.")
} catch {
print("TestView.makeUIView: Error loading HTML file: \(error)")
}
}
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.didLoad = didLoad
let script = "render('\(state.text)');"
webView.evaluateJavaScript(script) { _, error in
if let error = error {
print("TestView.updateUIView error: \(error)")
}
}
}
}
Instead of onAppear
, you should set the @State
in the didLoad
closure:
@State private var state = MyState(text: "") // the initial value here is just a "dummy"
var body: some View {
TestView(htmlPath: "foo", state: self.$state) {
// set it to the initial value you want here
state.text = "hello testview"
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
state.text = "hello2 testview"
}
}
}
Note that this will still print the error you saw, because the web view will not have finished loading when updateUIView
is called for the first time, but it doesn't matter. This is just failing to run render('')
(the initial dummy empty string). updateUIView
will be called again to run render('hello testview')
, after the web view finishes loading the HTML.
P.S. I hope this render
function is just a dummy function you wrote for the minimal reproducible example, and that your real code is not like this. You are not sanitising the parameter of render
at all, and this is a huge code injection vulnerability.