如何在嵌套的ScrollView中(垂直方向包含水平方向的ScrollView)通过编程滚动到指定的单元格?

移动开发 2026-07-12

我有一个SwiftUI视图,里面是 嵌套的滚动视图 —— 一个垂直的 ScrollView,其中包含一个水平的 ScrollView,它展示一个格子网格(列A-K,行1-100)。我想在点击按钮时,编程地滚动到某个特定的单元格(例如 K:60)。

部署目标:iOS 16(iOS 17+的解决方案可接受,但优先与iOS 16兼容)

约束: 我无法计算目标单元格的确切坐标/偏移量——这是一个简化的生产代码演示,在这种场景下不现实进行手动偏移计算。

import SwiftUI


struct ContentView: View {
    var body: some View {
        VStack {
            NestedScrollingView()
        }
        .padding()
    }
}

struct NestedScrollingView: View {
    @StateObject var viewModel = NestedScrollingViewModel()
    let myMap = Dictionary(uniqueKeysWithValues: ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K"].map { ($0, Array(1...100)) })

    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            RoundedRectangle(cornerRadius: 8)
                .foregroundStyle(Color.blue)
                .frame(height: 100)
                .padding()
            Button("Target Scroll View \(viewModel.scrollTokey.description)") {
                viewModel.onClickScrollTo()
            }
            .buttonStyle(.borderedProminent)
            .padding()

            gridView
        }
    }

    var gridView: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(alignment: .top, spacing: 4) {
                ForEach(myMap.keys.sorted(), id: \.self) { key in
                    VStack(spacing: 4) {
                        Text(key)
                            .font(.caption)

                        ForEach(myMap[key] ?? [], id: \.self) { value in
                            Text("\(key):\(value)")
                                .font(.caption)
                                .frame(width: 50, height: 50)
                                .border(Color.gray, width: 1)
                                .padding(4)
                        }
                    }
                }
            }
            .padding(8)
        }
    }
}

class NestedScrollingViewModel: ObservableObject {
    let scrollTokey: [String: Int] = ["K": 60]

    func onClickScrollTo() {
        // Need to scroll to K:60
    }
}

What I've Tried

  1. iOS 17+ scrollPosition(id:) 修饰符 —— 给每个单元格添加了 .id("\(key)-\(value)"),并在水平ScrollView上对其使用 @State private var scrollPosition: String? 搭配 .scrollPosition(id: $scrollPosition, anchor: .center)。这只对ScrollView内容的直接子项起作用,对VStack/HStack内部的嵌套项不起作用。
  2. ScrollViewReader 在水平ScrollView上 —— 将水平滚动视图包裹在 ScrollViewReader 之中,并使用 proxy.scrollTo(id, anchor: .center)。它可以水平滚动到列K,但不能垂直滚动到第60行
  3. ScrollViewReader 在垂直ScrollView上 —— 垂直代理无法找到位于嵌套的水平滚动视图内的ID。
  4. 两个 ScrollViewReader(一个用于每个坐标轴) —— 为垂直和水平滚动分别使用了代理。水平代理工作正常,但垂直代理仍然无法访问嵌套的水平滚动视图中的ID。

单一的 ScrollView([.horizontal, .vertical]) —— 将两个坐标轴合并到一个ScrollView,使一个 ScrollViewReader 能滚动到任意单元格。这会改变UX(不再有独立坐标轴滚动)。

要求:

  • iOS 16+兼容(若无iOS 16兼容实现,iOS 17+的解决方案可作为回退)
  • 不能手动计算像素偏移量
  • 必须保持嵌套ScrollView的结构(垂直包含水平)

解决方案

先简要回顾一些关键约束:

  • 顶层的 ScrollView 同时包含一些头部内容和网格单元格。当水平滚动时,这些内容应当一起滚动。
  • 嵌套的网格也应能水平滚动。整张网格应作为一个视图整体滚动,网格的行不应独立滚动。重要的是水平滚动不应影响头部区域,头部应保持在同一位置。

在你提到的第3 点中,你描述了当外层的 ScrollView 被包裹在 ScrollViewReader 时的问题

  1. ScrollViewReader 在垂直 ScrollView—— 垂直代理无法找到嵌套的水平ScrollView内的ID。

这个问题的一种变通做法,是在网格背景中使用占位符。类似这样的:

gridView
    .background {
        VStack(spacing: 0) {
            ForEach(myMap["A"] ?? [], id: \.self) { row in
                Color.clear
                    .id(row)
            }
        }
    }

这将滚动目标提升到一个位于嵌套的(水平)ScrollView 之外的视图。

使用简单的 Color.clear 作为占位符只有在网格的所有行高度都相同的情况下才有效。如果并非如此,你可能需要使用虚拟内容作为占位,然后再将其隐藏,例如:

ForEach(myMap["A"] ?? [], id: \.self) { row in
    CellContent(col: "A", row: row)
        .disabled(true)
        .hidden()
        .accessibilityHidden(true)
        .id(row)
}

要以与iOS 16兼容的方式实现真正的编程滚动,你可以考虑把 ScrollViewProxy 传递给嵌套视图。或者,你可以使用一个状态变量来保存要滚动到的ID。然后,使用 .onChange.task 在已经有代理作用域的视图中检测到状态变量的变化。这本质上就是一个自制的 .scrollPosition

如果使用状态变量,只有变量真的发生变化时才起效。因此,设想这样的场景:先执行一次编程滚动,然后再执行一次手动滚动,然后再次对同一目标进行编程滚动。在这种情况下,目标并没有改变,因此不会执行滚动。为了解决这个问题,最好先重置之前的目标,然后异步地把状态变量更新为新的目标。


下面是将示例更新为上述技术的方式。为了确保在这种情况下也能工作,一些容器也改为懒加载容器,以防万一。

struct CellKey {
    let col: String
    let row: Int
}

struct NestedScrollingView: View {
    @StateObject var viewModel = NestedScrollingViewModel()
    let myMap = Dictionary(uniqueKeysWithValues: ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K"].map { ($0, Array(1...100)) })
    @State private var targetKey: CellKey?

    private func scrollTo(key: CellKey) {
        if targetKey == nil {
            targetKey = key
        } else {
            targetKey = nil
            Task { @MainActor in
                targetKey = key
            }
        }
    }

    var body: some View {
        ScrollViewReader { rowProxy in
            ScrollView(.vertical, showsIndicators: false) {
                LazyVStack {
                    RoundedRectangle(cornerRadius: 8)
                        .foregroundStyle(Color.blue)
                        .frame(height: 100)
                        .padding()
                    Button("Target Scroll View \(viewModel.scrollTokey.description)") {
                        // viewModel.onClickScrollTo()
                        scrollTo(key: CellKey(col: "K", row: 60))
                    }
                    .buttonStyle(.borderedProminent)
                    .padding()

                    gridView
                        .background {
                            VStack(spacing: 0) {
                                ForEach(myMap["A"] ?? [], id: \.self) { row in
                                    Color.clear
                                        .id(row)
                                }
                            }
                        }
                }
                .task(id: targetKey?.row) {
                    if let row = targetKey?.row {
                        withAnimation {
                            rowProxy.scrollTo(row)
                        }
                    }
                }
            }
        }
    }

    var gridView: some View {
        ScrollViewReader { colProxy in
            ScrollView(.horizontal, showsIndicators: false) {
                LazyHStack(alignment: .top, spacing: 4) {
                    ForEach(myMap.keys.sorted(), id: \.self) { key in
                        VStack(spacing: 4) {
                            Text(key)
                                .font(.caption)

                            ForEach(myMap[key] ?? [], id: \.self) { value in
                                let isSelected = targetKey?.col == key && targetKey?.row == value
                                Text("\(key):\(value)")
                                    .font(.caption)
                                    .frame(width: 50, height: 50)
                                    .border(Color.gray, width: 1)
                                    .background(isSelected ? .yellow : Color(.systemBackground))
                                    .onTapGesture { scrollTo(key: CellKey(col: key, row: value)) }
                                    .padding(4)
                            }
                        }
                        .id(key)
                    }
                }
                .task(id: targetKey?.col) {
                    if let col = targetKey?.col {
                        withAnimation {
                            colProxy.scrollTo(col)
                        }
                    }
                }
                .padding(8)
            }
        }
    }
}

Animation

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章