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

SwiftUI:内层ScrollView触顶时如何实现外层滚动联动

页面结构示意图

页面结构示意图

页面组成

我需要实现如下页面,整体包含四个部分:

  • Sticky header:页面上固定位置、宽高的导航栏,包含返回图标。
  • 信息视图(Info View):用于展示特定信息和图片的视图。
  • Tabs:包含两个标签的标签栏,用于切换卡片滚动列表。
  • ScrollView中的帖子卡片:可滚动的卡片列表视图,采用瀑布流形式展示卡片(瀑布流已实现)。

问题一

第一个困扰我的问题是,当页面处于初始状态时,底部的列表会直接滚动,而非推动父级ScrollView协同滚动。
问题一示意图

问题二

问题二示意图

演示代码

我使用水平ScrollView和scrollTargetBehavior API实现标签切换,每个标签对应一个包含卡片列表VStack的垂直内层ScrollView。

import SwiftUI

struct ContentView: View {
    @State private var currentTab: String? = "Posted"
    @State private var tabProgress: CGFloat = 0
    
    var body: some View {
        let screenWidth: CGFloat = UIScreen.main.bounds.width
        let bw: CGFloat = UIScreen.main.bounds.width / 375
        let screenHeight: CGFloat = UIScreen.main.bounds.height
        
        GeometryReader { geometry in
            ZStack {
                // Sticky top bar, on the top of any other views
                VStack {
                    VStack {
                        NavRow()
                            .padding(.top, geometry.safeAreaInsets.top)
                    }
                    .frame(alignment: .top)
                    .background(.green)
                    Spacer()
                }
                .zIndex(999)
                
                // Outer Scroll View
                ScrollView (.vertical, showsIndicators: false) {
                    VStack (spacing: 0) {
                        // Same height as the top nav row
                        Spacer().frame(height: 64 * bw + geometry.safeAreaInsets.top)
                        // Info View
                        InfoView()
                        // Tab Row
                        TabRow(currentTab: $currentTab)
                    }
                    // Scroll Views
                    // Use horizontal paging scroll to implement tab switching
                    ScrollView(.horizontal, showsIndicators: false) {
                        LazyHStack(spacing: 0) {
                            ScrollView (.vertical, showsIndicators: false) {
                                PostedList()
                            }
                            .frame(width: screenWidth, height: screenHeight - 112 * bw - geometry.safeAreaInsets.top)
                            .id("Posted")
                            ScrollView (.vertical, showsIndicators: false) {
                                LikedList()
                            }
                            .frame(width: screenWidth, height: screenHeight - 112 * bw - geometry.safeAreaInsets.top)
                            .id("Liked")
                        }
                        .scrollTargetLayout()
                        .offsetX { value in
                            /// Converting Offset into Progress
                            let progress = -value / (screenWidth * CGFloat(1))
                            /// Capping Progress BTW 0-1
                            tabProgress = max(min(progress, 1), 0)
                        }
                    }
                    .scrollPosition(id: $currentTab)
                    .scrollTargetBehavior(.paging)
                    .scrollClipDisabled()
                }
                .ignoresSafeArea()
                .frame(width: screenWidth, height: screenHeight)
            }
            .ignoresSafeArea()
            .frame(width: screenWidth, height: screenHeight)
        }

    }
}

// Top nav row
struct NavRow: View {
    var body: some View {
        let screenWidth: CGFloat = UIScreen.main.bounds.width
        let bw: CGFloat = UIScreen.main.bounds.width / 375
        
        Rectangle().fill(.clear)
            .frame(width: screenWidth, height: 64 * bw)
            .overlay(alignment: .center, content: {
                Text("Nav Row")
                    .font(.system(size: 18, weight: .semibold))
                    .foregroundColor(.white)
            })
    }
}

struct InfoView: View {
    var body: some View {
        let bw: CGFloat = UIScreen.main.bounds.width / 375
        
        Rectangle().fill(.red.opacity(0.6))
            .frame(width: 343 * bw, height: 212 * bw)
            .cornerRadius(12 * bw)
            .overlay {
                Text("Info View")
                    .foregroundColor(.white)
                    .font(.system(size: 24, weight: .semibold))
            }
    }
}

struct TabRow: View {
    @Binding var currentTab: String?
    
    @ViewBuilder
    func SingleTab(tab: String) -> some View {
        let isCurrent = currentTab == tab
        Text(tab)
            .font(.system(size: isCurrent ? 16 : 15, weight:  isCurrent ? .semibold : .regular))
            .onTapGesture {
                if (!isCurrent) {
                    withAnimation(.snappy) {
                        currentTab = tab
                    }
                }
            }
    }
    
    var body: some View {
        let screenWidth: CGFloat = UIScreen.main.bounds.width
        let bw: CGFloat = UIScreen.main.bounds.width / 375
        
        Rectangle().fill(.yellow.opacity(0.5))
            .frame(width: screenWidth, height: 64 * bw)
            .overlay(alignment: .leading, content: {
                HStack {
                    SingleTab(tab: "Posted")
                    Spacer().frame(width: 24 * bw)
                    SingleTab(tab: "Liked")
                }
                .padding(.leading, 16 * bw)
            })
    }
}

@ViewBuilder
func sampleCard(cardText: String, cardColor: Color) -> some View {
    let bw: CGFloat = UIScreen.main.bounds.width / 375
    
    Rectangle().fill(cardColor)
        .frame(width: 343 * bw, height: 166 * bw)
        .overlay {
            Text(cardText)
                .font(.system(size: 18, weight: .semibold))
        }
        .padding(.bottom, 32 * bw)
}

struct PostedList: View {
    // 11 colors
    private let colorList: [Color] = [.gray, .red, .purple, .orange, .blue, .yellow, .pink, .brown, .cyan, .indigo, .teal]
    
    var body: some View {
        let screenWidth: CGFloat = UIScreen.main.bounds.width
        
        VStack (spacing: 0) {
            ForEach(0...colorList.count - 1, id: \.self) { i in
                sampleCard(cardText: "Post \(i)", cardColor: colorList[i])
            }
        }
        .frame(width: screenWidth)
    }
}


struct LikedList: View {
    var body: some View {
        let screenWidth: CGFloat = UIScreen.main.bounds.width
        
        VStack (spacing: 0) {
            ForEach(0...19, id: \.self) { i in
                sampleCard(cardText: "Liked \(i)", cardColor: .gray.opacity(0.2))
            }
        }
        .frame(width: screenWidth)
    }
}

/// Offset Key
struct OffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = .zero
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

extension View {
    @ViewBuilder
    func offsetX(completion: @escaping (CGFloat) -> ()) -> some View {
        self
            .overlay {
                GeometryReader {
                    let minX = $0.frame(in: .scrollView(axis: .horizontal)).minX
                    Color.clear
                        .preference(key: OffsetKey.self, value: minX)
                        .onPreferenceChange(OffsetKey.self, perform: completion)
                }
            }
    }
}

我已被该问题困扰数日,恳请各位提供解决方案,万分感谢。

内容的提问来源于stack exchange,提问作者潘星宇

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.26 14:23:04