当使用按钮时,带有.viewAligned的 scrollTargetBehavior的 SwiftUI ScrollView无法滚动到正确的索引

移动开发 2026-07-12

我有一个水平滚动视图,里面的单元格是 (1-8),上方还有一排对应的按钮。滚动视图使用 .viewAligned 滚动目标行为,边缘能看到两侧部分可见的相邻单元格。

Screenshot

预期行为:

  • 点击索引为N 的按钮应滚动到索引为N 的单元格
  • 手动滚动到索引为N 的单元格应选中索引为N 的按钮

实际行为:

  • 手动滚动工作正常——按钮选中状态与当前可见的单元格同步正确
  • 按钮点击有问题:

  • 点击按钮1(索引0)→ 滚动到单元格1 ✅

  • 点击按钮2(索引1)→ 不发生滚动,但按钮2 被选中,右边能看到单元格2 的边缘
  • 点击按钮3(索引2)→ 滚动到了单元格2(索引1),而不是单元格3(索引2)

以下是我的简化代码:

import SwiftUI

struct ContentView: View {
    let items = Array(1...8)
    @State private var selectedButtonsIndex: Int? = 0
    @State private var scrollPosition: Int? = 0

    var body: some View {
        VStack(spacing: 10) {
            // Buttons Collection
            LazyHStack(spacing: 15) {
                ForEach(Array(items.enumerated()), id: \.element) { index, item in
                    Button(action: {
                        withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                            selectedButtonsIndex = index
                        }
                    }) {
                        Text("\(item)")
                            .font(.title2.bold())
                            .foregroundColor(selectedButtonsIndex == index ? .white : .blue)
                            .frame(width: 30, height:30)
                            .background(
                                Circle()
                                    .fill(selectedButtonsIndex == index ? Color.blue : Color.blue.opacity(0.2))
                            )
                    }
                    .buttonStyle(PlainButtonStyle())
                }
            }
            .padding(.horizontal)
            // Cells Collection
            ScrollView(.horizontal) {
                LazyHStack(spacing: 10) {
                    ForEach(Array(items.enumerated()), id: \.element) { index, item in
                        CardView(number: item, isSelected: selectedButtonsIndex == index)
                            .containerRelativeFrame(.horizontal) { width, axis in
                                return width * 0.98
                            }
                            .id(index)
                            .onTapGesture {
                                withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                                    selectedButtonsIndex = index
                                }
                            }
                    }
                }
                .scrollTargetLayout()
            }
            .scrollTargetBehavior(.viewAligned)
            .safeAreaPadding(.horizontal, 20)
            .scrollPosition(id: $scrollPosition)
            .onChange(of: selectedButtonsIndex) { _, newValue in
                if let newIndex = newValue {
                    withAnimation(.spring(response: 0.65, dampingFraction: 0.82)) {
                        scrollPosition = newIndex
                    }
                }
            }
            .onChange(of: scrollPosition) { oldValue, newValue in
                if newValue != selectedButtonsIndex {
                    selectedButtonsIndex = newValue
                }
            }

            HStack {
                ForEach(0..<items.count, id: \.self) { index in
                    Circle()
                        .fill(index == selectedButtonsIndex ? Color.blue : Color.gray.opacity(0.3))
                        .frame(width: 8, height: 8)
                }
            }
        }
        .padding(.vertical)
    }
}

struct CardView: View {
    let number: Int
    let isSelected: Bool

    var body: some View {
        RoundedRectangle(cornerRadius: 25)
            .fill(
                LinearGradient(
                    colors: isSelected ? [.blue, .purple] : [.gray, .blue.opacity(0.7)],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
            )
            .overlay(
                Text("\(number)")
                    .font(.system(size: 80, weight: .bold))
                    .foregroundColor(.white)
            )
    }
}

解决方案

这个问题似乎是被滚动项的id引起的。你使用 selectedButtonsIndexscrollPosition 各自维护独立的状态变量,并用 onChange 的处理程序在两者之间引发变化,这大概也没有帮助。

我建议的改动如下:

  1. selectedButtonsIndex 作为参数传给 .scrollPosition,并移除 .onChange 修饰符。变量 scrollPosition 不再需要。
  2. 对于单元格集合,使用数组枚举的 offset 作为 idForEach 的值,而不是 element。这样就不需要对项应用 .id
// @State private var scrollPosition: Int? = 0
// Cells Collection
ScrollView(.horizontal) {
    HStack(spacing: 10) {
        ForEach(Array(items.enumerated()), id: \.offset) { index, item in
            CardView(number: item, isSelected: selectedButtonsIndex == index)
                .containerRelativeFrame(.horizontal) { width, axis in
                    return width * 0.98
                }
                .onTapGesture {
                    withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                        selectedButtonsIndex = index
                    }
                }
        }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.safeAreaPadding(.horizontal, 20)
.scrollPosition(id: $selectedButtonsIndex)
// .onChange deleted

顺便提一句,当你点击按钮滚动到某一项时,它并不是完全居中。原因是在 .containerRelativeFrame 闭包中把一个项的宽度设置为 width * 0.98。要解决,可以要么把闭包改为返回完整宽度,要么需要在 ScrollView 中给水平内容添加 .contentMargins。水平内容边距需要是 ScrollView 宽度的0.01,这是在扣除一个项的宽度后再除以2 得出的剩余部分。测量 ScrollView 宽度的一种方法是把它包裹在一个 GeometryReader 里。

此外,在项之间滚动时,你会注意到相邻项的位置有时会稍微“抖动”。回到第一项时尤其明显。这是因为容器是懒加载的。这个问题有时可以通过应用 .geometryGroup() 来解决(参阅 SwiftUI content jumping issue with LazyHStack embedded in a horizontal ScrollView in iOS 18),但实际在这里帮助并不大。然而,将 LazyHStack 改成 HStack 的确可以解决它。

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

相关文章