iosswiftswiftuiwebviewtabview

How can I use TabView to load new data while maintaining the same View


I am working on a PWA wrapper, in which I want to move certain parts of the PWA to the native APP. For example: login/authentication (fingerprint/Face ID) logic, navigation logic (tabbar), notifications.

For iOS 18/26 I have found that a TabView with Tab items would be a great base to start with. Together with a WKWebView (iOS 18) and WebView (iOS 26) it looked like a match made in heaven.

I first started for iOS 26, when that's working I will create the fallbacks for iOS 18. But I quickly ran into trouble with the new WebView/WebPage components.

My first setup was:

struct ContentView: View {
    @State private var selectedTab: Tabs = .home

    var body: some View {
        TabView(selection: $selectedTab) {
            ForEach(Tabs.allCases, id: \.self) { tab in
                Tab(tab.title, systemImage: tab.icon, value: tab) {
                    WebView(url: tab.url)
                }
            }
        }
    }
}

That works, but not the way I wanted. The above code creates a new WebView instance for each Tab, that causes all sorts of issues (increased memory usage, trouble with cookies/sessions) and it's just plain ugly.

I then tried some versions with a WebView wrapper in which I tried the WebView and WebPage as static variables. The idea was good, but I quickly ran into exceptions when I tried to navigate to a different URL.

With some help of a few AI services I discovered this had to do with a WebPage that should not be re-used in multiple WebView instances (even though it was static), and probably some other issues caused by my lack of Swift(UI). I stopped working on iOS apps when Objective-C still was the main language.

Eventually (again with the help of AI) I managed to get a somewhat working situation:

struct ContentView: View {
    @State private var selectedTab: Tabs = .home

    var body: some View {
        BrowserContainer()
        
        TabView(selection: $selectedTab) {
            ForEach(Tabs.allCases, id: \.self) { tab in
                Tab(tab.title, systemImage: tab.icon, value: tab) {
                    Color.red // I made this red to see why the WebView only was 50% of the display height
                        .onAppear() {
                            WebViewManager.shared.load(tab.url)
                        }
                }
            }
        }
    }
}

@MainActor
final class WebViewManager: ObservableObject {
    static let shared = WebViewManager()

    let page: WebPage
    let webView: WebView

    private init() {
        self.page = WebPage()
        self.webView = WebView(page)
    }

    func load(_ url: URL) {
        page.load(URLRequest(url: url))
    }
}

struct BrowserContainer: View {
    @ObservedObject private var manager = WebViewManager.shared

    var body: some View {
        manager.webView
            .ignoresSafeArea()
    }
}

But being a C# developer for a living, the resulting code made me sick. It was ugly and hacky, and I was sure that it's not the way to go. So that's what made me decide to create this post and hopefully get some knowledge of experienced developers that made something similar to this.

The ultimate goal is one global WebView (and eventually WKWebView for iOS 18) instance, which I can then "navigate" with code by changing the URL. This would be main navigation of different pages using the TabBar, but also special URLs from Notifications.


Solution

  • As of today, I am unaware of any SwiftUI-native solution for maintaining a single View while using the TabBar logic to shuffle between Data.

    # One possible solution using UIKit...
    I created a protocol, TabBarOption. This protocol was created with enum's in-mind, and is used to tell CustomTabBar what's coming and what's available. Usage is simple:

    enum TabOption: Int , TabBarOption {  
      case home
      var title: String {…}
      var image: String {…}
    }
    
    struct MyTabView: View { 
      @State var selection: TabOption 
      var body: some View {
        MyDataView ( selection: selection )
        CustomTabBar ( selection: $selection )
      }
    }
    

    ‘CustomTabBar’ only produces a TabBar, not the associated View.

    $selection will update the local @State case of the enum.

    Only The webKit logic is available in iOS 26 and later. The TabBar should cover as far back as iOS 14? Maybe earlier.

    It's worth noting that this approach looses the "more" feature, where TabViews with more that 4 tabs are automatically placed inside of a "more" tab.

    Tested and working on iPhone 15 , iOS 26.

    import SwiftUI
    import WebKit
    import UIKit
    
    @available( iOS 26.0 , * )
    struct DemoView: View {
      @State var manager: WebManager = WebManager ( .fox )
      var body: some View {
        VStack {
          WebView ( manager.page )
          CustomTabBar ( selection: $manager.webSite )
        }
        .task ( id: manager.webSite ) { manager.load ( manager.webSite ) }
        .ignoresSafeArea ( .all , edges: .bottom )
        .padding ( .top , 1 )
      }
    }
    
    
    @available( iOS 26.0 , * )
    @Observable
    class WebManager {
      var webSite: WebSite
      var page: WebPage = .init()
      init ( _ webSite: WebSite ) {
        self.webSite = webSite
        self.page.load ( webSite.url )
      }
      func load ( _ newWebsite: WebSite ) {
        self.webSite = newWebsite
        self.page.load ( newWebsite.url )
      }
    }
    
    
    enum WebSite: Int , TabBarOption {
    
      case home , cnn , fox , msnbc
      
      var url: URL? {
        switch self {
          case .home  : URL ( string: "https://www.stackoverflow.com" )
          case .cnn   : URL ( string: "https://www.cnn.com"           )
          case .fox   : URL ( string: "https://www.foxnews.com"       )
          case .msnbc : URL ( string: "https://www.msnbc.com"         )
        }
      }
      var title: String {
        switch self {
          case .home  : "Stack Overflow"
          case .cnn   : "CNN"
          case .fox   : "Fox"
          case .msnbc : "MSNBC"
        }
      }
      var image: String {
        switch self {
          case .home  : "paperclip"
          case .cnn   : "backpack"
          case .fox   : "pencil"
          case .msnbc : "newspaper"
        }
      }
    }
    
    
    public protocol TabBarOption: CaseIterable , Hashable , RawRepresentable where Self.RawValue == Int {
      static var home: Self { get }
      var title: String { get }
      var image: String { get }
    }
    
    
    public struct CustomTabBar < Case: TabBarOption >: UIViewRepresentable {
      @Binding var selection: Case
      
      let items: [ UITabBarItem ]
      
      public init ( selection: Binding < Case > ) {
        self._selection = selection
        self.items = Case.allCases.map { UITabBarItem ( title: $0.title , image: UIImage ( systemName: $0.image ) , tag: $0.rawValue ) }
      }
    
      public func makeUIView ( context: Context ) -> UITabBar {
        let tabBar = UITabBar()
        tabBar.items = items
        tabBar.selectedItem = items [ selection.rawValue ]
        tabBar.delegate = context.coordinator
        return tabBar
      }
    
      public func updateUIView ( _ uiView: UITabBar , context: Context ) { uiView.selectedItem = items [ selection.rawValue ] }
    
      public func makeCoordinator() -> Coordinator { Coordinator ( self ) }
    
      public class Coordinator: NSObject, UITabBarDelegate {
        var parent: CustomTabBar
        init ( _ parent: CustomTabBar ) { self.parent = parent }
    
        public func tabBar ( _ tabBar: UITabBar , didSelect item: UITabBarItem ) {
          parent.selection = Case ( rawValue: item.tag ) ?? .home
        }
      }
    }