SwiftUI WKWebView content height issue

It's been a week I'm stuck with this issue where I have my custom WKWebView with UIViewRepresentable

struct Webview : UIViewRepresentable {
    var webview: WKWebView?

    init() {
        self.webview = WKWebView()
    }

    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: Webview

        init(_ parent: Webview) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print("Loading finished -- Delegate")
            webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (height, error) in
                print(height)
                webView.bounds.size.height = height as! CGFloat
            })
        }

    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> WKWebView  {
        return webview!
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.navigationDelegate = context.coordinator
        let htmlStart = "<HTML><HEAD><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, shrink-to-fit=no\"></HEAD><BODY>"
        let htmlEnd = "</BODY></HTML>"
        let dummy_html = """
                        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ut venenatis risus. Fusce eget orci quis odio lobortis hendrerit. Vivamus in sollicitudin arcu. Integer nisi eros, hendrerit eget mollis et, fringilla et libero. Duis tempor interdum velit. Curabitur</p>
                        <p>ullamcorper, nulla nec elementum sagittis, diam odio tempus erat, at egestas nibh dui nec purus. Suspendisse at risus nibh. Mauris lacinia rutrum sapien non faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec interdum enim et augue suscipit, vitae mollis enim maximus.</p>
                        <p>Fusce et convallis ligula. Ut rutrum ipsum laoreet turpis sodales, nec gravida nisi molestie. Ut convallis aliquet metus, sit amet vestibulum risus dictum mattis. Sed nec leo vel mauris pharetra ornare quis non lorem. Aliquam sed justo</p>
                        """
        let htmlString = "\(htmlStart)\(dummy_html)\(htmlEnd)"
        uiView.loadHTMLString(htmlString, baseURL:  nil)
    }
}

and it's presented like this

enter image description here

and the issue here is the webview is missing height. It's not appearing in my view unless I add a hard coded frame value where my content gets cut-off.

Webview()
   .frame(height:300)

I almost came across similar questions but it didn't help :/


Solution 1:

It is confusing of ScrollView in SwiftUI, which expects known content size in advance, and UIWebView internal UIScrollView, which tries to get size from parent view... cycling.

So here is possible approach.. to pass determined size from web view into SwiftUI world, so no hardcoding is used and ScrollView behaves like having flat content.

At first demo of result, as I understood and simulated ...

enter image description here

Here is complete module code of demo. Tested & worked on Xcode 11.2 / iOS 13.2.

import SwiftUI
import WebKit

struct Webview : UIViewRepresentable {
    @Binding var dynamicHeight: CGFloat
    var webview: WKWebView = WKWebView()

    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: Webview

        init(_ parent: Webview) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (height, error) in
                DispatchQueue.main.async {
                    self.parent.dynamicHeight = height as! CGFloat
                }
            })
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> WKWebView  {
        webview.scrollView.bounces = false
        webview.navigationDelegate = context.coordinator
        let htmlStart = "<HTML><HEAD><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, shrink-to-fit=no\"></HEAD><BODY>"
        let htmlEnd = "</BODY></HTML>"
        let dummy_html = """
                        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ut venenatis risus. Fusce eget orci quis odio lobortis hendrerit. Vivamus in sollicitudin arcu. Integer nisi eros, hendrerit eget mollis et, fringilla et libero. Duis tempor interdum velit. Curabitur</p>
                        <p>ullamcorper, nulla nec elementum sagittis, diam odio tempus erat, at egestas nibh dui nec purus. Suspendisse at risus nibh. Mauris lacinia rutrum sapien non faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec interdum enim et augue suscipit, vitae mollis enim maximus.</p>
                        <p>Fusce et convallis ligula. Ut rutrum ipsum laoreet turpis sodales, nec gravida nisi molestie. Ut convallis aliquet metus, sit amet vestibulum risus dictum mattis. Sed nec leo vel mauris pharetra ornare quis non lorem. Aliquam sed justo</p>
                        """
        let htmlString = "\(htmlStart)\(dummy_html)\(htmlEnd)"
        webview.loadHTMLString(htmlString, baseURL:  nil)
        return webview
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
    }
}


struct TestWebViewInScrollView: View {
    @State private var webViewHeight: CGFloat = .zero
    var body: some View {
        ScrollView {
            VStack {
                Image(systemName: "doc")
                    .resizable()
                    .scaledToFit()
                    .frame(height: 300)
                Divider()
                Webview(dynamicHeight: $webViewHeight)
                    .padding(.horizontal)
                    .frame(height: webViewHeight)
            }
        }
    }
}

struct TestWebViewInScrollView_Previews: PreviewProvider {
    static var previews: some View {
        TestWebViewInScrollView()
    }
}

Solution 2:

If I simply place your Webview in a VStack it sizes as expected. Theres' no actual problem there.

Although you haven't stated so, the most likely reason for your Webview not taking up any space is that you have placed these items in a ScrollView. Since WKWebView is basically (though not a subclass of) a UIScrollView the system does not know how to size it when contained within another scrolling view.

For example:

struct ContentView: View {
    var body: some View {
        NavigationView {
            ScrollView { // THIS LINE means that...
                VStack(spacing: 0.0) {
                    Color.red.aspectRatio(1.0, contentMode: .fill)
                    Text("Lorem ipsum dolor sit amet").fontWeight(.bold)
                    Webview()
                        .frame(height: 300) // ...THIS is necessary 
                }
            }
        .navigationBarItems(leading: Text("Item"))
            .navigationBarTitle("", displayMode: .inline)
        }
    }
}

Nested scrollviews is not what you want for the layout you've shown anyway. You no doubt want that image and text at the top to scroll out of the way when the WKWebView scrolls.

The technique you need to use with either UIKit or SwiftUI is going to be similar. I don't really recommend doing this with SwiftUI at this point.

  1. Place the WKWebView in a container (most likely UIViewController.view)
  2. Place your header content (image plus text, which could be in a UIHostingContainer) in that view as a sibling above the webview.
  3. Set webView.scrollView.contentInset.top to the size of your content from #2
  4. Implement UIScrollViewDelegate.scrollViewDidScroll()
  5. In scrollViewDidScroll modify the position of your content to match the webview's contentOffset.

Here is one possible implementation:

WebView:

import SwiftUI
import WebKit

struct WebView : UIViewRepresentable {

    @Binding var offset: CGPoint
    var contentInset: UIEdgeInsets = .zero

    class Coordinator: NSObject, UIScrollViewDelegate {
        var parent: WebView

        init(_ parent: WebView) {
            self.parent = parent
        }

        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            var offset = scrollView.contentOffset
            offset.y += self.parent.contentInset.top
            self.parent.offset = offset
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> WKWebView {
        let webview = WKWebView()
        webview.scrollView.delegate = context.coordinator
        // continue setting up webview content
        return webview
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        if uiView.scrollView.contentInset != self.contentInset {
            uiView.scrollView.contentInset = self.contentInset
        }
    }

}

ContentView. Note: I've used 50 as a constant instead of calculating the size. It is possible to get the actual size using GeometryReader though.

struct ContentView: View {
    @State var offset: CGPoint = .zero
    var body: some View {
        NavigationView {
            WebView(offset: self.$offset, contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 0, right: 0))
            .overlay(
                Text("Hello World!")
                    .frame(height: 50)
                    .offset(y: -self.offset.y)
                , alignment: .topLeading)
                .edgesIgnoringSafeArea(.all)
        }
        .navigationBarItems(leading: Text("Item"))
            .navigationBarTitle("", displayMode: .inline)
    }
}