如何在 SwiftUI TabView 中保持滚动位置

How to maintain scroll position in a SwiftUI TabView

TabView 中使用 ScrollView 我注意到当我在选项卡之间来回移动时,ScrollView 不会保持其滚动位置。我如何更改以下示例以使 ScrollView 保持其滚动位置?

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)
        }
    }
}

不幸的是,鉴于 SwiftUI 的当前限制,这对于内置组件来说根本不可能 (iOS 13.x/Xcode 11.x)。

两个原因:

  1. 当您离开选项卡时,SwiftUI 会完全处理您的视图。这就是您的滚动位置丢失的原因。它不像 UIKit,你有一堆屏幕外的 UIViewControllers。
  2. 没有 ScrollView 等同于 UIScrollView.contentOffset。这意味着您无法在某处保存滚动状态并在用户 returns 到选项卡时恢复它。

最简单的方法可能是使用 UITabBarController 填充 UIHostingController。这样,当用户在每个选项卡之间移动时,您不会丢失每个选项卡的状态。

否则,您可以创建一个自定义选项卡容器,然后自己修改每个选项卡的不透明度(而不是有条件地将它们包含在层次结构中,这就是导致您出现问题的原因目前)。

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)
  }
}

当我想避免 WKWebView 每次用户短暂离开时完全重新加载时,我就使用了这种技术。

我会推荐第一种技术。特别是如果这是您应用的主导航。

此答案作为解决方案的补充发布@oivvio linked 在@arsenius 的答案中,关于如何使用充满 UIHostingControllerUITabController

link 中的答案有一个问题:如果子视图具有外部 SwiftUI 依赖项,则不会更新这些子视图。对于大多数子视图只有内部状态的情况,这很好。但是,如果你和我一样,是一个享受全球 Redux 系统的 React 开发者,那你就麻烦了。

为了解决这个问题,关键是在每次调用updateUIViewController时为每个UIHostingController更新rootView。我的代码还避免了创建不必要的 UIViewUIViewControllers:如果不将它们添加到视图层次结构中,创建它们的成本并不高,但浪费越少越好。

警告:代码不支持动态选项卡视图列表。为了正确地支持它,我们想要识别每个子选项卡视图并执行数组差异以正确地添加、排序或删除它们。原则上可以做到,但超出了我的需要。

我们首先需要一个TabItem。控制器以这种方式获取所有信息,而无需创建任何 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())
    }
}

然后我们有控制器:

struct XNTabView: UIViewControllerRepresentable {
    let tabItems: [XNTabItem]

    func makeUIViewController(context: UIViewControllerRepresentableContext<XNTabView>) -> UITabBarController {
        let rootController = UITabBarController()
        rootController.viewControllers = tabItems.map {
            let host = UIHostingController(rootView: [=11=].body)
            host.tabBarItem = UITabBarItem(title: [=11=].title, image: [=11=].image, selectedImage: [=11=].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
            }
        }
    }
}

子控制器在 makeUIViewController 中初始化。每当调用 updateUIViewController 时,我们都会更新每个子控制器的根视图。我 而不是 rootView 进行了比较,因为根据 Apple 关于如何更新视图的描述,我觉得会在框架级别进行相同的检查。我可能是错的。

使用起来非常简单。以下是我从我目前正在做的模拟项目中抓取的部分代码:


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

    public func flipFavorite(for id: Int) {
        if let index = (allHouseInfo.firstIndex { [=12=].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: [=12=])
                }
            })
        } 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 { [=12=].isFavorite } }
    }

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

状态被提升到根级别 Model 并且它带有变异助手。在 CardContent 中,您可以通过 EnvironmentObject 访问状态和助手。更新将在 Model 对象中完成,传播到 ContentView,通知我们的 XNTabView 并更新其每个 UIHostController

编辑:

  • 原来.environmentObject可以放在顶层

2020 年 11 月更新

TabView 现在在选项卡之间移动时将保持选项卡中的滚动位置,因此不再需要我原来问题的答案中的方法。