You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

将LazyVstack嵌套在NavigationStack中时出现上滑回弹循环问题

SwiftUI中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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 17:47:02