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

如何在ScrollView内实现可滚动自定义TabView(iOS 17)

iOS 17自定义横向TabView嵌套ScrollView失效的解决方案

问题根源

你的自定义TabView单独使用正常,但嵌套到外层垂直ScrollView的Section中失效,核心原因有三个:

  • 外层垂直ScrollView和内层横向ScrollView滚动手势优先级冲突,横向滚动被外层拦截
  • 嵌套在LazyVStack的Section里时,GeometryReader获取的尺寸不是实际可用宽度,导致滚动进度计算错误
  • 固定的Section Header改变了内层ScrollView的布局上下文,使得scrollTargetLayout和scrollPosition的绑定失效

具体修复步骤

1. 解决滚动手势冲突

给内层横向ScrollView添加高优先级横向手势,避免被外层垂直ScrollView拦截:

ScrollView(.horizontal) {
    // ... 原有内容
}
.gesture(DragGesture().onChanged { _ in }, including: .all)
.highPriorityGesture(DragGesture(minimumDistance: 0, coordinateSpace: .local), including: .horizontal)

2. 修正尺寸计算逻辑

移除嵌套ScrollView内的GeometryReader,改用屏幕宽度直接计算滚动进度(或通过环境变量获取容器宽度):

func rcontent() -> some View {
    ScrollView(.horizontal) {
        LazyHStack(spacing: 0) {
            // ... 原有SampleView内容
        }
        .scrollTargetLayout()
        .offsetX { value in
            let screenWidth = UIScreen.main.bounds.width
            let progress = -value / (screenWidth * CGFloat(TabProfile.allCases.count - 1))
            tabProgress = max(min(progress, 1), 0)
        }
    }
    // 加上手势修复代码
    .gesture(DragGesture().onChanged { _ in }, including: .all)
    .highPriorityGesture(DragGesture(minimumDistance: 0, coordinateSpace: .local), including: .horizontal)
    .scrollPosition(id: $selectedTab)
    .scrollIndicators(.hidden)
    .scrollTargetBehavior(.viewAligned)
    .scrollClipDisabled()
}

3. 调整Tab栏布局方式

把Tab按钮栏从Section Header中移出,改用.sticky()修饰符固定在ScrollView顶部,避免Section的固定逻辑干扰内层滚动:

ScrollView {
    LazyVStack(alignment: .leading, spacing: 0) {
        Color.blue.frame(height: 100)
            .overlay {
                Text("Some content").foregroundStyle(.white).bold()
            }
        
        // 用sticky固定Tab栏在顶部
        buttonTab()
            .sticky()
        
        rcontent()
    }
}

4. 优化Tab栏背景避免内容穿透

给Tab栏添加背景色,防止滚动时下层内容穿透显示:

func buttonTab() -> some View {
    HStack(spacing: 0) {
        // ... 原有Tab按钮内容
    }
    // ... 原有修饰符
    .background(Color(.systemBackground))
}

完整修改后的代码

import SwiftUI

#Preview(body: {
    SwiftUIView()
})

struct SwiftUIView: View {
    @State private var selectedTab: TabProfile?
    @State private var tabProgress: CGFloat = 0
    @Environment(\.colorScheme) var colorScheme
    
    var body: some View {
        VStack(alignment: .leading){
            Color.red.frame(height: 100)
                .overlay {
                    Text("Header").foregroundStyle(.white)
                        .offset(y: 20).bold()
                }
            
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 0) {
                    Color.blue.frame(height: 100)
                        .overlay {
                            Text("Some content").foregroundStyle(.white).bold()
                        }
                    
                    buttonTab()
                        .sticky()
                    
                    rcontent()
                }
            }
            .scrollIndicators(.hidden)
        }
        .ignoresSafeArea()
    }
    
    func rcontent() -> some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                SampleView(.purple)
                    .id(TabProfile.hustles)
                    .containerRelativeFrame(.horizontal)
                
                SampleView(.red)
                    .id(TabProfile.jobs)
                    .containerRelativeFrame(.horizontal)
                
                SampleView(.blue)
                    .id(TabProfile.likes)
                    .containerRelativeFrame(.horizontal)
                
                SampleView(.green)
                    .id(TabProfile.sale)
                    .containerRelativeFrame(.horizontal)
                
                SampleView(.orange)
                    .id(TabProfile.question)
                    .containerRelativeFrame(.horizontal)
            }
            .scrollTargetLayout()
            .offsetX { value in
                let screenWidth = UIScreen.main.bounds.width
                let progress = -value / (screenWidth * CGFloat(TabProfile.allCases.count - 1))
                tabProgress = max(min(progress, 1), 0)
            }
        }
        .scrollPosition(id: $selectedTab)
        .scrollIndicators(.hidden)
        .scrollTargetBehavior(.viewAligned)
        .scrollClipDisabled()
        .gesture(DragGesture().onChanged { _ in }, including: .all)
        .highPriorityGesture(DragGesture(minimumDistance: 0, coordinateSpace: .local), including: .horizontal)
    }
    
    func buttonTab() -> some View {
        HStack(spacing: 0) {
            ForEach(TabProfile.allCases, id: \.rawValue) { tab in
                HStack(spacing: 10) {
                    Text(tab.rawValue)
                        .font(.callout)
                }
                .frame(maxWidth: .infinity)
                .padding(.vertical, 10)
                .contentShape(.capsule)
                .onTapGesture {
                    UIImpactFeedbackGenerator(style: .light).impactOccurred()
                    withAnimation(.snappy) {
                        selectedTab = tab
                    }
                }
            }
        }
        .tabMask(tabProgress, tabCount: TabProfile.allCases.count)
        .padding(.horizontal, 8)
        .background {
            GeometryReader {
                let size = $0.size
                let capusleWidth = size.width / CGFloat(TabProfile.allCases.count)
                ZStack(alignment: .leading){
                    RoundedRectangle(cornerRadius: 0)
                        .fill(.gray)
                        .frame(height: 1)
                        .offset(y: 40)
                    RoundedRectangle(cornerRadius: 5)
                        .fill(.blue)
                        .frame(width: capusleWidth, height: 2)
                        .offset(x: tabProgress * (size.width - capusleWidth), y: 40)
                }
            }
        }
        .background(Color(.systemBackground))
    }
    
    @ViewBuilder
    func SampleView(_ color: Color) -> some View {
        ScrollView(.vertical) {
            LazyVGrid(columns: Array(repeating: GridItem(), count: 2), content: {
                ForEach(1...10, id: \.self) { _ in
                    RoundedRectangle(cornerRadius: 15)
                        .fill(color.gradient)
                        .frame(height: 150)
                }
            })
            .padding(15)
        }
        .scrollIndicators(.hidden)
        .scrollClipDisabled()
    }
}

enum TabProfile: String, CaseIterable {
    case hustles = "Hustles"
    case jobs = "Jobs"
    case likes = "Likes"
    case sale = "4Sale"
    case question = "???"
}

// 补充tabMask自定义修饰符示例实现
extension View {
    func tabMask(_ progress: CGFloat, tabCount: Int) -> some View {
        self.overlay(
            Rectangle()
                .fill(.blue.opacity(0.1))
                .frame(width: UIScreen.main.bounds.width / CGFloat(tabCount))
                .offset(x: progress * (UIScreen.main.bounds.width - UIScreen.main.bounds.width / CGFloat(tabCount)))
                .allowsHitTesting(false)
        )
    }
}

额外说明

  • 如果你的offsetX是自定义修饰符,需确保它能正确读取横向滚动偏移量,嵌套ScrollView下可能需要调整坐标空间
  • 尽量避免在嵌套ScrollView中过度使用GeometryReader,优先用containerRelativeFrame或环境变量获取尺寸
  • 固定视图时优先使用.sticky(),比Section的pinnedViews更灵活且不易干扰内层滚动

内容的提问来源于stack exchange,提问作者Ahmed Zaidan

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.25 16:55:02