swiftuiwebkit

Make TabView content background transparent so that a "constant" UIViewRepresentable WKWebView can be displayed behind it


I am creating an app where the primary UI is a WKWebView and SwiftUI is only used for odds and ends of the app. I am running into a snag using the SwiftUI system UI TabView. If you run this sample app there is some behavior I cannot explain

enum Page: String, Hashable, CaseIterable, Identifiable {
    case settings = "gear"
    case radio = "radio"
    case connect = "dot.radiowaves.right"
    
    var id: String { rawValue }
}

struct RandomColorHostedWebView: UIViewRepresentable {
    let webView: WKWebView
    
    init() {
        // Clear cache to be extra sure https://stackoverflow.com/a/34376943/3833632
        WKWebsiteDataStore.default().removeData(ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache], modifiedSince: Date(timeIntervalSince1970: 0), completionHandler:{ })
        let webConfiguration = WKWebViewConfiguration()
        self.webView = WKWebView(frame: .zero, configuration: webConfiguration)
        let urlRequest = URLRequest(url: URL(string: "https://randomcolour.com")!)
        self.webView.load(urlRequest)
    }
    
    func makeUIView(context: Context) -> UIView {
        return webView
    }

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



struct ContentView: View {
    let pages = Page.allCases
    
    let randomColor: RandomColorHostedWebView
    init() {
        randomColor = RandomColorHostedWebView()
    }
    
    var body: some View {
        TabView {
            ForEach(pages) { page in
                randomColor
                    .tag(page)
                    .tabItem {
                        Label(page.rawValue, systemImage: page.rawValue)
                    }
            }
        }
    }
}

If you run this you will notice that each tab you click causes a refresh animation but it does not load a new color. In addition when you go back to a tab you have already been to it will be blank.

This is a behavior I cannot explain. (I would love to understand why this happens)

That being said I am assuming the cause is that SwiftUI has no idea that each of the tabItem parent views are the same view as they have different structural identities.

So I am left looking for a workaround to either make the background of the TabView clear so I can use a ZStack, limit the TabView height to only the tab content so I can use a VStack, or something else.

This existing solution attempts to remove the background of the TabView does not seem to work. I agree with a commenter that says that workaround appears to have been broken in Later OS versions.

   var body: some View {
        ZStack {
            randomColor
            TabView {
                ForEach(pages) { page in
                    Color.clear
                        // https://stackoverflow.com/questions/63178381/make-tabview-background-transparent
                        .background(BackgroundHelper())
                        .tag(page)
                        .tabItem {
                            Label(page.rawValue, systemImage: page.rawValue)
                        }
                }
            }
        }
    }

Solution

  • Your UIViewRepresentable implementation is incorrect. You should always return a new instance of the view in makeUIView. With the current implementation, you only ever have one instance of WKWebView.

    As you switch between tabs, SwiftUI adds that WKWebView as subviews of the different tabs. Obviously, a UIView can only have one parent view, so as you move to another tab, the WKWebView gets removed from the previous tab. When you move back to a visited tab, the WKWebView is not added back, because SwiftUI doesn't expect it to be removed in the first place.

    Here is a more correct implementation:

    struct RandomColorHostedWebView: UIViewRepresentable {
        
        func makeUIView(context: Context) -> UIView {
            WKWebsiteDataStore.default().removeData(ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache], modifiedSince: Date(timeIntervalSince1970: 0), completionHandler:{ })
            let webConfiguration = WKWebViewConfiguration()
            let webView = WKWebView(frame: .zero, configuration: webConfiguration)
            let urlRequest = URLRequest(url: URL(string: "https://randomcolour.com")!)
            webView.load(urlRequest)
            return webView
        }
        
        func updateUIView(_ uiView: UIView, context: Context) {
        }
    }
    

    The BackgroundHelper from the linked post can be slightly modified to use a UIViewControllerRepresentable - going up the UIViewController hierarchy instead of the UIView hierarchy. Of course, there is no guarantee that this is not going to break in the future.

    struct BackgroundHelper: UIViewControllerRepresentable {
        class HelperVC: UIViewController {
            override func didMove(toParent parent: UIViewController?) {
                super.didMove(toParent: parent)
                var currParent = parent
                while true {
                    guard let parent = currParent else {
                        break
                    }
                    if parent.tabBarController?.viewControllers?.contains(parent) == true {
                        currParent?.view.backgroundColor = .clear
                        parent.tabBarController?.view.backgroundColor = .clear
                        break
                    }
                    currParent = currParent?.parent
                }
            }
        }
        
        func makeUIViewController(context: Context) -> HelperVC {
            HelperVC()
        }
        
        func updateUIViewController(_ uiViewController: HelperVC, context: Context) {
            
        }
    }
    

    Then you can write

    ZStack {
        RandomColorHostedWebView()
        TabView {
            ForEach(pages) { page in
                Text("Foo")
                    .background { BackgroundHelper() }
                    .tag(page)
                    .tabItem {
                        Label(page.rawValue, systemImage: page.rawValue)
                    }
            }
            .toolbarBackgroundVisibility(.visible, for: .tabBar)
        }
    }
    .ignoresSafeArea()
    

    I used ignoresSafeArea and toolbarBackgroundVisibility to make it look nicer. Depending on what web content you are actually displaying, you might not want to ignore the bottom safe area (i.e. you want to place the web view just above the tab bar), in which case you can do:

    @Namespace var ns
    
    // ...
    
    ZStack {
        RandomColorHostedWebView()
            .matchedGeometryEffect(id: "tab", in: ns, isSource: false)
        TabView {
            ForEach(pages) { page in
                // we only want the source of the matched geometry effect to be on the initial tab only, hence this check
                if page == .settings {
                    ZStack {
                        Color.clear
                            .matchedGeometryEffect(id: "tab", in: ns, isSource: true)
                        Text("Foo")
                    }
                    .ignoresSafeArea(edges: [.horizontal, .top])
                    .background { BackgroundHelper() }
                    .tag(page)
                    .tabItem {
                        Label(page.rawValue, systemImage: page.rawValue)
                    }
                } else {
                    Text("Foo")
                        .background { BackgroundHelper() }
                        .tag(page)
                        .tabItem {
                            Label(page.rawValue, systemImage: page.rawValue)
                        }
                }
            }
            .toolbarBackgroundVisibility(.visible, for: .tabBar)
        }
    }
    .ignoresSafeArea(edges: [.horizontal, .top])