SwiftUI 同步水平和垂直滚动视图与动态内容

问题描述 投票:0回答:1

我正在构建一个带有两个同步可滚动区域的 SwiftUI 视图:

  1. 显示部分列表的水平 ScrollView。
  2. 一个垂直的 ScrollView,显示与这些部分相对应的内容。

问题:

当每个部分具有统一数量的项目时,该实施才有效。但是,当部分包含不同数量的项目时,同步会中断,并且垂直 ScrollView 经常滚动到错误的部分。这是我的代码示例:

struct ContentView: View {
    // Sample data
    private let sections = (1...10).map { sectionIndex in
        SectionData(
            name: "Section \(sectionIndex)",
            items: (1...(Int.random(in: 80...150))).map { "Item \($0)" }
        )
    }
    
    @State private var selectedSection: String? = nil
    @State private var currentVisibleSection: String? = nil
    
    var body: some View {
        VStack(spacing: 0) {
            // Horizontal Selector
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 10) {
                    ForEach(sections) { section in
                        Button(action: {
                            selectedSection = section.name
                        }) {
                            Text(section.name)
                                .font(.headline)
                                .padding(.horizontal, 10)
                                .padding(.vertical, 5)
                                .background(
                                    RoundedRectangle(cornerRadius: 10)
                                        .fill(currentVisibleSection == section.name ? Color.blue : Color.gray.opacity(0.2))
                                )
                                .foregroundColor(currentVisibleSection == section.name ? .white : .primary)
                        }
                    }
                }
                .padding()
            }
            .background(Color(UIColor.systemGroupedBackground))
            
            // Vertical Scrollable Content
            ScrollViewReader { proxy in
                ScrollView(.vertical, showsIndicators: false) {
                    LazyVStack(spacing: 20) {
                        ForEach(sections) { section in
                            VStack(alignment: .leading, spacing: 10) {
                                // Section Header
                                SectionHeader(name: section.name)
                                    .id(section.name) // Each section has a unique ID
                                
                                // Section Content
                                LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 10) {
                                    ForEach(section.items, id: \.self) { item in
                                        Text(item)
                                            .frame(height: 100)
                                            .frame(maxWidth: .infinity)
                                            .background(Color.blue.opacity(0.2))
                                            .cornerRadius(8)
                                    }
                                }
                            }
                            .background(
                                GeometryReader { geo in
                                    Color.clear.preference(
                                        key: VisibleSectionPreferenceKey.self,
                                        value: [section.name: calculateVisibleHeight(geo)]
                                    )
                                }
                            )
                        }
                    }
                    .onPreferenceChange(VisibleSectionPreferenceKey.self) { visibleSections in
                        updateLargestVisibleSection(visibleSections)
                    }
                    .onChange(of: selectedSection) { sectionName in
                        guard let sectionName else { return }
                        withAnimation {
                            proxy.scrollTo(sectionName, anchor: .top)
                        }
                    }
                }
            }
        }
    }
    
    // Update the largest visible section
    private func updateLargestVisibleSection(_ visibleSections: [String: CGFloat]) {
        if let largestVisibleSection = visibleSections.max(by: { $0.value < $1.value })?.key {
            currentVisibleSection = largestVisibleSection
        }
    }
    
    // Calculate the visible height of a section
    private func calculateVisibleHeight(_ geometry: GeometryProxy) -> CGFloat {
        let frame = geometry.frame(in: .global)
        let screenHeight = UIScreen.main.bounds.height
        return max(0, min(frame.maxY, screenHeight) - max(frame.minY, 0))
    }
}

// PreferenceKey to track visible sections
private struct VisibleSectionPreferenceKey: PreferenceKey {
    static var defaultValue: [String: CGFloat] = [:]
    
    static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
        value.merge(nextValue(), uniquingKeysWith: max)
    }
}

// Supporting Views and Models
struct SectionHeader: View {
    let name: String
    
    var body: some View {
        Text(name)
            .font(.headline)
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.gray.opacity(0.2))
    }
}

struct SectionData: Identifiable {
    var id: String { name }
    let name: String
    let items: [String]
}

  1. LazyVStack:对于性能来说效果很好,但当部分包含不同数量的项目时,同步会中断。
  2. VStack:修复了同步问题,但由于所有内容都急切地加载到内存中,因此大型数据集的性能较差。
  • 此外,与 VStack 中的惰性子视图(如 LazyVGrid)交互会导致滚动跳转,从而破坏用户体验。
  1. onPreferenceChange:使用自定义 PreferenceKey 来跟踪可见部分,但这种方法对于延迟加载部分和动态项目计数变得不可靠。
ios swift swiftui scroll
1个回答
0
投票

在您的示例中,您有一个

ScrollView
,其中包含
LazyVStack
。在
LazyVStack
内部有许多嵌套的
LazyVGrid

惰性容器与

ScrollView
协调工作,但当存在嵌套惰性容器时,这种协调似乎效果不佳。我注意到滚动到目标部分后,
ScrollView
会突然跳跃。我怀疑发生这种情况是因为一些懒惰的容器正在丢弃其内容,而没有与
ScrollView
正确协调。

我建议有两种方法来解决,具体取决于部分中的项目数量是否有限:

  • 如果一个部分中的项目数量是有限的(如您的示例所示),则可以将数据展平以给出代表所有行的一个大集合。
  • 如果一个部分中的项目数量不是有限的,选项卡视图可能是更好的方法。

以下是平面数据方法如何适用于您的示例。

  1. 准备数据模型

我建议为您的部分数据使用数字 ID。作为可用于数据行 id 的基础,这更方便。为了方便起见,还可以添加项目数量的计算属性和用于访问特定项目的函数。

struct SectionData: Identifiable {
    var id: Int
    let name: String
    let items: [String]

    var nItems: Int {
        items.count
    }

    func item(at: Int) -> String {
        items[at]
    }
}
  1. 创建一种数据类型来封装一行数据。基本上只有两种类型的行:标题行和项目行。
struct RowData: Identifiable {
    let section: SectionData
    let nItems: Int
    let firstItemIndex: Int
    private let maxItemsPerSection = 1000

    // Initializer for a header row
    init(section: SectionData) {
        self.section = section
        self.nItems = 0
        self.firstItemIndex = -1
    }

    // Initializer for a row of items
    init(section: SectionData, nItems: Int, firstItemIndex: Int) {
        self.section = section
        self.nItems = nItems
        self.firstItemIndex = firstItemIndex
    }

    var sectionId: Int {
        section.id
    }

    var id: Int {

        // Header rows inherit the id of the section
        nItems == 0 ? sectionId : (sectionId * maxItemsPerSection) + firstItemIndex
    }
}

每个行实例都有一个提供数据的部分的句柄,但在此阶段并未从该部分中提取实际数据。

尽管

SectionData
struct
,但我的理解是,Swift 编译器很有可能会优化数据传递的方式,并可能选择通过引用传递。但是,如果您想确定,可以将
SectionData
更改为
class

  1. init
  2. 中创建平面集合
private let nItemsPerRow = 3
private let flatData: [RowData]
init() {
    var flatData = [RowData]()
    for section in sections {

        // Add a row entry for the section header
        flatData.append(RowData(section: section))

        // Add rows for showing the items
        let nRows = Int((Double(section.nItems) / Double(nItemsPerRow)).rounded(.up))
        for rowNum in 0..<nRows {
            let firstItemIndex = rowNum * nItemsPerRow
            let nItems = min(nItemsPerRow, section.nItems - firstItemIndex)
            flatData.append(RowData(section: section, nItems: nItems, firstItemIndex: firstItemIndex))
        }
    }
    self.flatData = flatData
}
  1. 我建议使用
    .scrollTargetLayout
    ,而不是
    ScrollViewReader
@State private var selectedRow: Int?
ScrollView(.vertical, showsIndicators: false) {
    LazyVStack(spacing: 10) {
        //...
    }
    .scrollTargetLayout()
}
.scrollPosition(id: $selectedRow, anchor: .top)
.scrollTargetBehavior(.viewAligned)
  1. 创建一个
    ViewModifier
    来记录最顶行的部分

视图修改器可以应用于每一行。其目的是记录最上面的可见行代表哪个部分。此信息用于突出显示相应的部分按钮。

每一行都可以通过检查其在

ScrollView
坐标空间中的位置来检测自己是否是
ScrollView
中的最顶层行。允许 +/- 9 点的公差。

struct SectionRecorder: ViewModifier {
    let sectionId: Int
    @Binding var topSection: Int?

    func body(content: Content) -> some View {
        content
            .onGeometryChange(for: CGFloat.self) { proxy in
                abs(proxy.frame(in: .scrollView).minY)
            } action: { newValue in
                if newValue < 10 && topSection != sectionId {
                    topSection = sectionId
                }
            }
    }
}
  1. 显示行
@State private var topSection: Int?
ForEach(flatData) { rowData in
    if rowData.nItems == 0 {

        // Section Header
        SectionHeader(name: rowData.section.name)
            .modifier(SectionRecorder(sectionId: rowData.sectionId, topSection: $topSection))
    } else {

        // Row Content
        HStack {
            ForEach(0..<rowData.nItems, id: \.self) { i in
                Text(rowData.section.item(at: rowData.firstItemIndex + i))
                    .frame(height: 100)
                    .frame(maxWidth: .infinity)
                    .background(Color.blue.opacity(0.2))
                    .cornerRadius(8)
            }
            let nEmptyPositions = nItemsPerRow - rowData.nItems
            ForEach(0..<nEmptyPositions, id: \.self) { _ in
                Color.clear.frame(height: 1)
            }
        }
        .modifier(SectionRecorder(sectionId: rowData.sectionId, topSection: $topSection))
    }
}

您会注意到每行数据都使用了

HStack

  • 由于单元格都使用

    maxWidth: .infinity
    ,因此列一定会对齐。

  • 如果列的宽度需要是动态的,您可以考虑使用这个答案中显示的技术来确定动态宽度。

  • 或者,您可以考虑使用自定义

    Layout
    ,它使用百分比或权重作为列宽。这种布局的示例可以在这个答案中找到。

  1. 更新按钮以设置滚动目标

由于标题行与数据行的高度不同,滚动到不同的部分并不总是到达正确的位置。它有助于在动画完成之前重新应用选择。按钮操作可以包含

Task
来执行此操作。

Button {
    withAnimation(.easeInOut(duration: 0.3)) {
        selectedRow = section.id
    }
    Task { @MainActor in

        // Re-apply the selection when the animation is nearing completion
        try? await Task.sleep(for: .seconds(0.27))
        selectedRow = nil
        try? await Task.sleep(for: .milliseconds(20))
        selectedRow = section.id
    }
} label: {
    let isSelected = topSection == section.id
    Text(section.name)
        .font(.headline)
        .padding(.horizontal, 10)
        .padding(.vertical, 5)
        .background(
            RoundedRectangle(cornerRadius: 10)
                .fill(isSelected ? .blue : .gray.opacity(0.2))
        )
        .foregroundStyle(isSelected ? .white : .primary)
}

将它们放在一起,这是完全更新的示例:

struct ContentView: View {

    // Sample data
    private let sections = (1...10).map { sectionIndex in
        SectionData(
            id: sectionIndex,
            name: "Section \(sectionIndex)",
            items: (1...(Int.random(in: 80...150))).map { "Item \($0)" }
        )
    }
    private let nItemsPerRow = 3
    private let flatData: [RowData]

    @State private var selectedRow: Int?
    @State private var topSection: Int?

    init() {
        var flatData = [RowData]()
        for section in sections {

            // Add a row entry for the section header
            flatData.append(RowData(section: section))

            // Add rows for showing the items
            let nRows = Int((Double(section.nItems) / Double(nItemsPerRow)).rounded(.up))
            for rowNum in 0..<nRows {
                let firstItemIndex = rowNum * nItemsPerRow
                let nItems = min(nItemsPerRow, section.nItems - firstItemIndex)
                flatData.append(RowData(section: section, nItems: nItems, firstItemIndex: firstItemIndex))
            }
        }
        self.flatData = flatData
    }

    var body: some View {
        VStack(spacing: 0) {

            // Horizontal Selector
            sectionSelector

            // Vertical Scrollable Content
            ScrollView(.vertical, showsIndicators: false) {
                LazyVStack(spacing: 10) {
                    ForEach(flatData) { rowData in
                        if rowData.nItems == 0 {

                            // Section Header
                            SectionHeader(name: rowData.section.name)
                                .modifier(SectionRecorder(sectionId: rowData.sectionId, topSection: $topSection))
                        } else {

                            // Row Content
                            HStack {
                                ForEach(0..<rowData.nItems, id: \.self) { i in
                                    Text(rowData.section.item(at: rowData.firstItemIndex + i))
                                        .frame(height: 100)
                                        .frame(maxWidth: .infinity)
                                        .background(Color.blue.opacity(0.2))
                                        .cornerRadius(8)
                                }
                                let nEmptyPositions = nItemsPerRow - rowData.nItems
                                ForEach(0..<nEmptyPositions, id: \.self) { _ in
                                    Color.clear.frame(height: 1)
                                }
                            }
                            .modifier(SectionRecorder(sectionId: rowData.sectionId, topSection: $topSection))
                        }
                    }
                }
                .scrollTargetLayout()
            }
            .scrollPosition(id: $selectedRow, anchor: .top)
            .scrollTargetBehavior(.viewAligned)
        }
    }

    private var sectionSelector: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 10) {
                ForEach(sections) { section in
                    Button {
                        withAnimation(.easeInOut(duration: 0.3)) {
                            selectedRow = section.id
                        }
                        Task { @MainActor in

                            // Re-apply the selection when the animation is nearing completion
                            try? await Task.sleep(for: .seconds(0.27))
                            selectedRow = nil
                            try? await Task.sleep(for: .milliseconds(20))
                            selectedRow = section.id
                        }
                    } label: {
                        let isSelected = topSection == section.id
                        Text(section.name)
                            .font(.headline)
                            .padding(.horizontal, 10)
                            .padding(.vertical, 5)
                            .background(
                                RoundedRectangle(cornerRadius: 10)
                                    .fill(isSelected ? .blue : .gray.opacity(0.2))
                            )
                            .foregroundStyle(isSelected ? .white : .primary)
                    }
                }
            }
            .padding()
        }
        .background(Color(.systemGroupedBackground))
    }

    struct SectionRecorder: ViewModifier {
        let sectionId: Int
        @Binding var topSection: Int?

        func body(content: Content) -> some View {
            content
                .onGeometryChange(for: CGFloat.self) { proxy in
                    abs(proxy.frame(in: .scrollView).minY)
                } action: { newValue in
                    if newValue < 10 && topSection != sectionId {
                        topSection = sectionId
                    }
                }
        }
    }
}

// Supporting Views and Models
struct SectionHeader: View {
    let name: String

    var body: some View {
        Text(name)
            .font(.headline)
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.gray.opacity(0.2))
    }
}

struct SectionData: Identifiable {
    var id: Int
    let name: String
    let items: [String]

    var nItems: Int {
        items.count
    }

    func item(at: Int) -> String {
        items[at]
    }
}

struct RowData: Identifiable {
    let section: SectionData
    let nItems: Int
    let firstItemIndex: Int
    private let maxItemsPerSection = 1000

    // Initializer for a header row
    init(section: SectionData) {
        self.section = section
        self.nItems = 0
        self.firstItemIndex = -1
    }

    // Initializer for a row of items
    init(section: SectionData, nItems: Int, firstItemIndex: Int) {
        self.section = section
        self.nItems = nItems
        self.firstItemIndex = firstItemIndex
    }

    var sectionId: Int {
        section.id
    }

    var id: Int {

        // Header rows inherit the id of the section
        nItems == 0 ? sectionId : (sectionId * maxItemsPerSection) + firstItemIndex
    }
}

Animation

© www.soinside.com 2019 - 2024. All rights reserved.