1

I have a SwiftUI view that switches between two completely different container layouts:

  • a TabView layout (for compact width)
  • a sidebar + content layout using HStack (for regular width). I am using a custom sidebar since there is more layout and customization involved)

The container can change dynamically at runtime, e.g. by rotating the device or via a toggle (see. demo code below). The child pages contain their own @StateObject view models.

Important: The different layout are not SwiftUI Layout protocols but Views which not only handle the placement of the different views but also manage when which subview is shown, etc. (e.g. the TabView holds the subviews and show a view when the corresponding tab is selected).

Problem:

if useTabLayout {
    TabLayoutContainer(...)
} else {
    SidbarLayoutContainer(...)
}

When I switch between the two container layouts the pages are recreated, which means their @StateObject view models are re-initialized and the view state is lost.

What I want:

  • PageOne, PageTwo, and PageThree should keep their state
  • switching between Tab layout and Sidebar layout should not recreate the pages
  • only the outer container should change

Tried so far:

  • Using .id(...) on the pages (e.g. PageOne().id("one")). View Model is still re-created.
  • Creating page viewModel in Root-View and injecting it into the pages as @ObservedObject. While this works this requires the Root-View to know about the page views and theire content. This seems not to be a clean solution.
  • Creating pages only once: let pageOne = PageOne() and case .pageOne: return pageOne. This seem to work as well. But are there any downsides of keeping the page alive like this?
  • Considering AnyLayout (but it only works for real Layouts like VStackLayout, not complex containers like TabView)

Question

What is the correct SwiftUI approach for keeping child view state (@StateObject) alive while switching between two completely different container views (TabView vs Sidebar+Content)?

Demo Code

enum MyPage: Hashable {
    case pageOne, pageTwo, pageThree
}


struct MyRootView: View {
    @State var useTabLayout = true
    @State var selectedPage: MyPage = .pageOne

    var body: some View {
        VStack {
            Toggle("TabLayout", isOn: $useTabLayout)

            if useTabLayout {
                TabLayoutContainer(selectedPage: $selectedPage)
            } else {
                SidbarLayoutContainer(selectedPage: $selectedPage)
            }
        }
    }
}

struct TabLayoutContainer: View {
    @Binding var selectedPage: MyPage
    
    var body: some View {
        TabView(selection: $selectedPage) {
            PageFactory.view(for: .pageOne)
                .tag(MyPage.pageOne)

            PageFactory.view(for: .pageTwo)
                .tag(MyPage.pageTwo)
            
            PageFactory.view(for: .pageThree)
                .tag(MyPage.pageThree)
        }
    }
}

struct SidbarLayoutContainer: View {
    @Binding var selectedPage: MyPage
    
    var body: some View {
        HStack {
            // Sidebar
            VStack {
                Button("One") { selectedPage = .pageOne }
                Button("Two") { selectedPage = .pageTwo }
                Button("Three") { selectedPage = .pageThree }
            }
            
            // Content
            VStack {
                PageFactory.view(for: selectedPage)
            }
        }
    }
}

@ViewBuilder
static func view(for page: MyPage) -> some View {
    switch page {
        case .pageOne: PageOne()
        case .pageTwo: PageTwo()
        case .pageThree: PageThree()
    }
}

struct PageOne: View {
    @StateObject var viewModel: ViewModelOne = .init()
    
    var body: some View {
        Text("Page One")
    }
    
    class ViewModelOne: ObservableObject {
        init() {
            print("Initialized ViewModelOne")
        }
    }
}
7
  • 1
    Tab views can already be adapted into a sidebar, automatically. Why are you reinventing the wheel? Commented Nov 17 at 9:39
  • That aside, how about just using .opacity(0) to hide the view that is currently not active? Commented Nov 17 at 9:41
  • @Sweeper A custom sidebar is used since the real layout includes more customization than the TabView sidebar provides. Additionally this is iOS 18+ while I need 16+ support. How would using .opacticy(0) solve the described problem? This could only be used within the SidebarLayoutContainer and the views there would still be completly independed from the views within the TabBarLayoutContainer Commented Nov 17 at 9:53
  • TabBarLayoutContainer(…).opacity(useTabLayout ? 1 : 0). Whats wrong with that? Commented Nov 17 at 10:38
  • Maybe I am missing the point. Obviously this would work to control which layout is used / visible. However I do not understand how this would solve the described problem of keeping the views state intact while switching between layouts. PageOne and its viewModel is either visible within the TabBarLayoutContainer or in the SideBarLayoutContainer. When switching from one layout to the other, the page state (e.g. ScrollPosition, presented .sheet, etc.) should stay intact. How is this achived by changing the opacitiy of the TabBarLayoutContainer? Commented Nov 17 at 10:53

1 Answer 1

-1

You need to have the view models saved outside the view (eg in PageFactory) :

    struct PageFactory {
        // Declare static view models
        static let viewModelOne: PageOne.ViewModelOne = .init()
        static let viewModelTwo: PageTwo.ViewModelTwo = .init()
        static let viewModelThree: PageThree.ViewModelThree = .init()
        
        @ViewBuilder
        static func view(for page: MyPage) -> some View {
            switch page {
                // Initialize the view by passing it the view model
                case .pageOne: PageOne(viewModel: viewModelOne)
                case .pageTwo: PageTwo(viewModel: viewModelTwo)
                case .pageThree: PageThree(viewModel: viewModelThree)
            }
        }
    }

And then in view you initialise the view model in init : (I show only for Page Two but you need to do the same approach for other pages. I added a button to change a value in the model so you can see it persist between tab or layout changes

struct PageTwo: View {  
    @StateObject var viewModel: ViewModelTwo  
      
    // The view model of the view will use the one passed  
    init(viewModel: ViewModelTwo) {  
        self.\_viewModel = StateObject(wrappedValue: viewModel)  
    }  
      
    var body: some View {  
        VStack {  
            Text("Page Two \\(viewModel.value)")  
            Button {  
                viewModel.inc()  
            } label: {  
                Text("Increment")  
            }  
        }  
    }  
      
    class ViewModelTwo: ObservableObject {  
        @Published var value: Int = 0  
          
        init() {  
            print("Initialized ViewModelTwo")  
        }  
          
        func inc() {  
            value += 1  
        }  
    }  
}  

EDIT:

Second approach to eliminate view model knowledge from PageFactory :

Note that I show only for PageThree but the principle is the same for other pages

struct PageThree: View {
    @StateObject var viewModel: ViewModelThree
    
    init() {
        // Initialize the view using the shared viewModel
        self._viewModel = StateObject(wrappedValue: ViewModelThree.shared)
    }
    
    var body: some View {
        VStack {
            Text("Page Three \(viewModel.value)")
            Button {
                viewModel.update()
            } label: {
                Text("Update the value")
            }
        }
    }
    
    class ViewModelThree: ObservableObject {
        // Unique view model 
        static let shared = ViewModelThree()
        @Published var value: Int = 0
        init() {
            print("Initialized ViewModelThree")
        }
        
        func update() {
            value += 3
        }
    }
}

Then the PageFactory is simplified :

struct PageFactory {
        @ViewBuilder
        static func view(for page: MyPage) -> some View {
            switch page {
                case .pageOne: PageOne()
                case .pageTwo: PageTwo()
                case .pageThree: PageThree()
            }
        }
    }
Sign up to request clarification or add additional context in comments.

2 Comments

Thanks! This solution is what I described with "Creating page viewModel in Root-View and injecting it into the pages...". Instead of creating the ViewModels in the RootView you chose to do so in the PageFactory. However, the overall effect is the same. While this works in my tests, if wonder if this has side effects I am not aware of? An @StateObject ViewModel is create when the pages becomes visible for the first time. Using this approach all viewModel are created at the same time when launching the app. This could impact performance. Are there other downsides?
I added a second approach which eliminates the knowing of view model by PageFactory. View models will only be created the first time that the page is loaded by the system. (singleton are lazy loaded)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.