Search code examples
javascriptiosswiftswiftuimobile-development

SwiftUI web view doesn't show up the first time


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>

Solution

  • 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.