我正在构建一个带有两个同步可滚动区域的 SwiftUI 视图:
问题:
当每个部分具有统一数量的项目时,该实施才有效。但是,当部分包含不同数量的项目时,同步会中断,并且垂直 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]
}
在您的示例中,您有一个
ScrollView
,其中包含 LazyVStack
。在 LazyVStack
内部有许多嵌套的 LazyVGrid
。
惰性容器与
ScrollView
协调工作,但当存在嵌套惰性容器时,这种协调似乎效果不佳。我注意到滚动到目标部分后,ScrollView
会突然跳跃。我怀疑发生这种情况是因为一些懒惰的容器正在丢弃其内容,而没有与 ScrollView
正确协调。
我建议有两种方法来解决,具体取决于部分中的项目数量是否有限:
以下是平面数据方法如何适用于您的示例。
我建议为您的部分数据使用数字 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]
}
}
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
。
init
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
}
.scrollTargetLayout
,而不是 ScrollViewReader
@State private var selectedRow: Int?
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 10) {
//...
}
.scrollTargetLayout()
}
.scrollPosition(id: $selectedRow, anchor: .top)
.scrollTargetBehavior(.viewAligned)
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
}
}
}
}
@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
,它使用百分比或权重作为列宽。这种布局的示例可以在这个答案中找到。
由于标题行与数据行的高度不同,滚动到不同的部分并不总是到达正确的位置。它有助于在动画完成之前重新应用选择。按钮操作可以包含
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
}
}