How to maintain scroll position in a SwiftUI TabView
Asked Answered
C

3

16

Using a ScrollView inside a TabView I've noticed that the ScrollView does not maintain it's scroll position when I move back an forth between tabs. How do I change the below example to get the ScrollView to maintain it's scroll position?

import SwiftUI

struct HomeView: View {
    var body: some View {
        ScrollView {
            VStack {
                Text("Line 1")
                Text("Line 2")
                Text("Line 3")
                Text("Line 4")
                Text("Line 5")
                Text("Line 6")
                Text("Line 7")
                Text("Line 8")
                Text("Line 9")
                Text("Line 10")
            }
        }.font(.system(size: 80, weight: .bold))
    }
}

struct ContentView: View {
    @State private var selection = 0

    var body: some View {
        TabView(selection: $selection) {
            HomeView()
                .tabItem {
                    Image(systemName: "house")
                }.tag(0)

            Text("Out")
                .tabItem {
                    Image(systemName: "cloud")
                }.tag(1)
        }
    }
}
Chorister answered 11/12, 2019 at 23:33 Comment(1)
You might find helpful my customisation of ScrollView in How to make a SwiftUI List scroll automatically?Daric
C
4

November 2020 update

TabView will now maintain scroll position in tabs when moving in between tabs, so the approaches in the answers to my original question are no longer necessary.

Chorister answered 20/11, 2020 at 19:39 Comment(1)
TabView certainly keeps the scroll position when programatically changing the selection binding but when using the swipe gesture to change between tabs, if there are 4 or more tabs the scroll position is reset eventually. I reported the bug and also created this sample test project to reproduce the issue.Varien
A
10

Unfortunately this is simply not possible with built-in components given the current limitations of SwiftUI (iOS 13.x/Xcode 11.x).

Two reasons:

  1. SwiftUI completely disposes of your View when you switch away from the tab. This is why your scroll position is lost. It's not like UIKit where you have a bunch of offscreen UIViewControllers.
  2. There is no ScrollView equivalent of UIScrollView.contentOffset. That means you can't save your scroll state somewhere and restore it when the user returns to the tab.

Your easiest route is probably going to be using a UITabBarController filled with UIHostingControllers. That way you won't lose the state of each tab as users move between them.

Otherwise, you could create a custom tab container, and modify the opacity of each tab yourself (as opposed to conditionally including them in the hierarchy, which is what causes your problem currently).

var body: some View {
  ZStack {
    self.tab1Content.opacity(self.currentTab == .tab1 ? 1.0 : 0.0)
    self.tab2Content.opacity(self.currentTab == .tab2 ? 1.0 : 0.0)
    self.tab3Content.opacity(self.currentTab == .tab3 ? 1.0 : 0.0)
  }
}

I've used this technique when I wanted to keep a WKWebView from completely reloading every time a user briefly tabbed away.

I would recommend the first technique. Especially if this is your app's main navigation.

Antitrust answered 12/12, 2019 at 2:59 Comment(1)
Thanks! I ended up using your first option, which is more fleshed out in this answer to a similar question: https://mcmap.net/q/377596/-tabview-resets-navigation-stack-when-switching-tabs Given your answer I was able to locate itChorister
C
4

November 2020 update

TabView will now maintain scroll position in tabs when moving in between tabs, so the approaches in the answers to my original question are no longer necessary.

Chorister answered 20/11, 2020 at 19:39 Comment(1)
TabView certainly keeps the scroll position when programatically changing the selection binding but when using the swipe gesture to change between tabs, if there are 4 or more tabs the scroll position is reset eventually. I reported the bug and also created this sample test project to reproduce the issue.Varien
T
1

This answer is posted as a complement to the solution @oivvio linked in the answer from @arsenius, about how to use a UITabController filled with UIHostingControllers.

The answer in the link has one issue: if the children views have external SwiftUI dependencies, those children will not be updated. This is fine for most cases where children views only have internal states. However, if you are like me, a React developer who enjoys a global Redux system, you will be in trouble.

In order to resolve the issue, the key is to update rootView for each UIHostingController every time when updateUIViewController is called. My code also avoids creating unnecessary UIView or UIViewControllers: they are not that expensive to create if you do not add them to the view hierarchy, but still, the less waste the better.

Warning: the code does not support a dynamic tab view list. To support that correctly, we would like to identify each child tab view and do an array diff to add, order, or remove them correctly. That can be done in principle but goes beyond my need.

We first need a TabItem. It is made this way for the controller to grab all the information, without creating any UITabBarItem:

struct XNTabItem: View {
    let title: String
    let image: UIImage?
    let body: AnyView

    public init<Content: View>(title: String, image: UIImage?, @ViewBuilder content: () -> Content) {
        self.title = title
        self.image = image
        self.body = AnyView(content())
    }
}

We then have the controller:

struct XNTabView: UIViewControllerRepresentable {
    let tabItems: [XNTabItem]

    func makeUIViewController(context: UIViewControllerRepresentableContext<XNTabView>) -> UITabBarController {
        let rootController = UITabBarController()
        rootController.viewControllers = tabItems.map {
            let host = UIHostingController(rootView: $0.body)
            host.tabBarItem = UITabBarItem(title: $0.title, image: $0.image, selectedImage: $0.image)
            return host
        }
        return rootController
    }

    func updateUIViewController(_ rootController: UITabBarController, context: UIViewControllerRepresentableContext<XNTabView>) {
        let children = rootController.viewControllers as! [UIHostingController<AnyView>]
        for (newTab, host) in zip(self.tabItems, children) {
            host.rootView = newTab.body
            if host.tabBarItem.title != host.tabBarItem.title {
                host.tabBarItem.title = host.tabBarItem.title
            }
            if host.tabBarItem.image != host.tabBarItem.image {
                host.tabBarItem.image = host.tabBarItem.image
            }
        }
    }
}

Children controllers are initialized in makeUIViewController. Whenever updateUIViewController is called, we update each children controller's root view. I did not do a comparison for rootView because I feel the same check would be done at the framework level, according to Apple's description about how views are updated. I might be wrong.

To use it is really simple. The below is partial code I grabbed from a mock project I am currently doing:


class Model: ObservableObject {
    @Published var allHouseInfo = HouseInfo.samples

    public func flipFavorite(for id: Int) {
        if let index = (allHouseInfo.firstIndex { $0.id == id }) {
            allHouseInfo[index].isFavorite.toggle()
        }
    }
}

struct FavoritesView: View {
    let favorites: [HouseInfo]

    var body: some View {
        if favorites.count > 0 {
            return AnyView(ScrollView {
                ForEach(favorites) {
                    CardContent(info: $0)
                }
            })
        } else {
            return AnyView(Text("No Favorites"))
        }
    }
}

struct ContentView: View {
    static let housingTabImage = UIImage(systemName: "house.fill")
    static let favoritesTabImage = UIImage(systemName: "heart.fill")

    @ObservedObject var model = Model()

    var favorites: [HouseInfo] {
        get { model.allHouseInfo.filter { $0.isFavorite } }
    }

    var body: some View {
        XNTabView(tabItems: [
            XNTabItem(title: "Housing", image: Self.housingTabImage) {
                NavigationView {
                    ScrollView {
                        ForEach(model.allHouseInfo) {
                            CardView(info: $0)
                                .padding(.vertical, 8)
                                .padding(.horizontal, 16)
                        }
                    }.navigationBarTitle("Housing")
                }
            },
            XNTabItem(title: "Favorites", image: Self.favoritesTabImage) {
                NavigationView {
                    FavoritesView(favorites: favorites).navigationBarTitle("Favorites")
                }
            }
        ]).environmentObject(model)
    }
}

The state is lifted to a root level as Model and it carries mutation helpers. In the CardContent you could access the state and the helpers via an EnvironmentObject. The update would be done in the Model object, propagated to the ContentView, with our XNTabView notified and each of its UIHostController updated.

EDITs:

  • It turns out that .environmentObject can be put at the top level.
Theatricalize answered 19/2, 2020 at 3:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.