将LazyVstack嵌套在NavigationStack中时出现上滑回弹循环问题
问题描述
在SwiftUI开发中,将LazyVStack嵌套在NavigationStack内,意图通过pinnedViews: .sectionHeaders实现表头固定,同时用ScrollView渲染内容,但出现上滑后回弹的循环问题。
问题演示:
相关实现代码如下:
视图代码
struct SProjectsScreen: View { @Bindable var store: StoreOf<ProjectsFeature> @State private var searchText: String = "" @Binding var isBotTabBarHidden: Bool @Environment(\.safeArea) private var safeArea: EdgeInsets @Environment(\.screenSize) private var screenSize: CGSize @Environment(\.dismiss) var dismiss private var isLoading : Bool { store.projects.isEmpty } var body: some View { NavigationStack(path:$store.navigationPath.sending(\.setNavigationPath)) { ScrollView(.vertical){ LazyVStack(spacing:16,pinnedViews: .sectionHeaders) { Section { VStack(spacing:16) { if isLoading { ForEach(0..<5,id:\.self) { _ in ProjectItemSkeleton() } } else{ ForEach(store.projects,id:\_id) { projectItem in NavigationLink(value: projectItem) { SProjectItem(project: projectItem) .foregroundStyle(Color.theme.foreground) } .simultaneousGesture(TapGesture().onEnded({ _ in store.send(.setCurrentProjectSelected(projectItem.name)) })) } } } } header: { VStack(spacing:16) { HStack { Text("Your") Text("Projects") .fontWeight(.bold) Text("Are Here!") } .font(.title) .frame(maxWidth: .infinity,alignment: .leading) .padding(.horizontal,12) .padding(.vertical,0) HStack { SSearchField(searchValue: $searchText) Button { } label: { Image(systemName: "slider.horizontal.3") .foregroundStyle(.white) .fontWeight(.medium) .font(.system(size: 24)) .frame(width:50.66,height: 50.66) .background { Circle().fill(Color.theme.primary) } } } } .padding(.top,8) .padding(.bottom,16) .background(content: { Color.white }) } } } .scrollIndicators(.hidden) .navigationDestination(for: Project.self) { project in SFoldersScreen(project:project,isBotTabBarHidden: $isBotTabBarHidden) .toolbar(.hidden) } .padding(.horizontal,SScreenSize.hPadding) .onAppear { Task { if isLoading{ do { let projectsData = try await ProjectService.Shared.getProjects() store.send(.setProjects(projectsData)) } catch{ print("error found: ",error.localizedDescription) } } } } .refreshable { do { let projectsData = try await ProjectService.Shared.getProjects() store.send(.setProjects(projectsData)) } catch{ print("error found: ",error.localizedDescription) } } }.onChange(of: store.navigationPath, { a, b in print("Navigation path changed:", b) }) } }
TCA状态管理Reducer代码
import ComposableArchitecture @Reducer struct ProjectsFeature{ @ObservableState struct State: Equatable{ var navigationPath : [Project] = [] var projects : [Project] = [ ] var currentProjectSelected : String? } enum Action{ case setNavigationPath([Project]) case setProjects([Project]) case setCurrentProjectSelected(String?) case popNavigation } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .setNavigationPath(let navigationPath): state.navigationPath = navigationPath return .none case .setProjects(let projects): state.projects = projects return .none case .setCurrentProjectSelected(let projectName): state.currentProjectSelected = projectName return .none case .popNavigation: if !state.navigationPath.isEmpty { state.navigationPath.removeLast() } state.currentProjectSelected = nil return .none } } } }
问题原因
这种循环回弹问题的核心是双重滚动容器的行为冲突:
- LazyVStack在设置
pinnedViews时会自动启用自身的滚动能力,外层再嵌套ScrollView会导致两个滚动容器的手势识别逻辑混乱,触发重复的滚动调整。 - 自定义section header的背景布局可能与NavigationStack的导航栏产生交互异常,加剧滚动行为的冲突。
解决方案
方案1:移除外层ScrollView(推荐)
LazyVStack配合pinnedViews本身就是完整的滚动容器,不需要额外嵌套ScrollView,直接将其作为NavigationStack的根视图即可解决问题:
// 修改后的核心body代码 var body: some View { NavigationStack(path:$store.navigationPath.sending(\.setNavigationPath)) { // 移除外层ScrollView,直接使用LazyVStack作为根视图 LazyVStack(spacing:16,pinnedViews: .sectionHeaders) { // 原Section内容完全保留 Section { VStack(spacing:16) { if isLoading { ForEach(0..<5,id:\.self) { _ in ProjectItemSkeleton() } } else{ ForEach(store.projects,id:\_id) { projectItem in NavigationLink(value: projectItem) { SProjectItem(project: projectItem) .foregroundStyle(Color.theme.foreground) } .simultaneousGesture(TapGesture().onEnded({ _ in store.send(.setCurrentProjectSelected(projectItem.name)) })) } } } } header: { // 原header内容完全保留 VStack(spacing:16) { HStack { Text("Your") Text("Projects") .fontWeight(.bold) Text("Are Here!") } .font(.title) .frame(maxWidth: .infinity,alignment: .leading) .padding(.horizontal,12) .padding(.vertical,0) HStack { SSearchField(searchValue: $searchText) Button { } label: { Image(systemName: "slider.horizontal.3") .foregroundStyle(.white) .fontWeight(.medium) .font(.system(size: 24)) .frame(width:50.66,height: 50.66) .background { Circle().fill(Color.theme.primary) } } } } .padding(.top,8) .padding(.bottom,16) .background(content: { Color.white }) } } .scrollIndicators(.hidden) .navigationDestination(for: Project.self) { project in SFoldersScreen(project:project,isBotTabBarHidden: $isBotTabBarHidden) .toolbar(.hidden) } .padding(.horizontal,SScreenSize.hPadding) .onAppear { Task { if isLoading{ do { let projectsData = try await ProjectService.Shared.getProjects() store.send(.setProjects(projectsData)) } catch{ print("error found: ",error.localizedDescription) } } } } .refreshable { do { let projectsData = try await ProjectService.Shared.getProjects() store.send(.setProjects(projectsData)) } catch{ print("error found: ",error.localizedDescription) } } }.onChange(of: store.navigationPath, { a, b in print("Navigation path changed:", b) }) }
方案2:保留ScrollView但禁用LazyVStack内部滚动(仅适用于小数据量场景)
如果必须保留外层ScrollView,可以通过强制LazyVStack适应ScrollView高度来禁用其内部滚动,但会失去懒加载优势:
ScrollView(.vertical){ LazyVStack(spacing:16,pinnedViews: .sectionHeaders) { // 原内容不变 } .frame(maxHeight: .infinity) // 强制LazyVStack填充ScrollView高度,不启用自身滚动 }
额外优化建议
- 确保自定义header的背景色与NavigationStack导航栏样式协调,避免布局偏移引发的滚动调整。
- 检查
SScreenSize.hPadding等自定义布局常量,避免内容超出安全区域导致异常滚动。
内容的提问来源于stack exchange,提问作者MiguelG
相关产品推荐
相关产品推荐

