如何在 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)。
两个原因:
- 当您离开选项卡时,SwiftUI 会完全处理您的视图。这就是您的滚动位置丢失的原因。它不像 UIKit,你有一堆屏幕外的 UIViewControllers。
- 没有
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 的答案中,关于如何使用充满 UIHostingController
的 UITabController
。
link 中的答案有一个问题:如果子视图具有外部 SwiftUI 依赖项,则不会更新这些子视图。对于大多数子视图只有内部状态的情况,这很好。但是,如果你和我一样,是一个享受全球 Redux 系统的 React 开发者,那你就麻烦了。
为了解决这个问题,关键是在每次调用updateUIViewController
时为每个UIHostingController
更新rootView
。我的代码还避免了创建不必要的 UIView
或 UIViewControllers
:如果不将它们添加到视图层次结构中,创建它们的成本并不高,但浪费越少越好。
警告:代码不支持动态选项卡视图列表。为了正确地支持它,我们想要识别每个子选项卡视图并执行数组差异以正确地添加、排序或删除它们。原则上可以做到,但超出了我的需要。
我们首先需要一个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
现在在选项卡之间移动时将保持选项卡中的滚动位置,因此不再需要我原来问题的答案中的方法。
在 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)。
两个原因:
- 当您离开选项卡时,SwiftUI 会完全处理您的视图。这就是您的滚动位置丢失的原因。它不像 UIKit,你有一堆屏幕外的 UIViewControllers。
- 没有
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 的答案中,关于如何使用充满 UIHostingController
的 UITabController
。
link 中的答案有一个问题:如果子视图具有外部 SwiftUI 依赖项,则不会更新这些子视图。对于大多数子视图只有内部状态的情况,这很好。但是,如果你和我一样,是一个享受全球 Redux 系统的 React 开发者,那你就麻烦了。
为了解决这个问题,关键是在每次调用updateUIViewController
时为每个UIHostingController
更新rootView
。我的代码还避免了创建不必要的 UIView
或 UIViewControllers
:如果不将它们添加到视图层次结构中,创建它们的成本并不高,但浪费越少越好。
警告:代码不支持动态选项卡视图列表。为了正确地支持它,我们想要识别每个子选项卡视图并执行数组差异以正确地添加、排序或删除它们。原则上可以做到,但超出了我的需要。
我们首先需要一个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
现在在选项卡之间移动时将保持选项卡中的滚动位置,因此不再需要我原来问题的答案中的方法。