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

SwiftUI中在ScrollView内使用matchedGeometryEffect时,如何兼顾过渡动画与滚动位置的保留?

SwiftUI中在ScrollView内使用matchedGeometryEffect时,如何兼顾过渡动画与滚动位置的保留?

嘿,我仔细看了你遇到的问题,其实核心矛盾根本不是“二选一”,而是你之前的代码里ScrollView被临时销毁导致滚动位置丢失!咱们一步步拆解解决:

先揪出核心问题

你为了避免「Multiple inserted views」警告,把ScrollView放在了if !isPressed的分支里——这就意味着,当你展开视图时,ScrollView会被完全移除(销毁);当你收起时,ScrollView会重新初始化,滚动位置自然就回到了初始状态。你手动记录lastScrollPosition再恢复的操作,又和matchedGeometryEffect的动画时机冲突,才会出现视图错乱的情况。

至于那个警告,本质是同一时刻不能有多个视图带着同一个matchedGeometryEffect id且isSource=true,你的代码其实已经做对了(ExpandedView里的视图设为isSource=false),但结合ScrollView的销毁重建,才引发了连锁问题。

解决方案:让ScrollView始终“活着”

我们的思路很简单:

  1. 让ScrollView永久保留在视图层级中,不用if !isPressed隐藏它,而是通过opacity和allowsHitTesting控制可见性和可交互性;
  2. 展开时单独隐藏选中的卡片,避免和ExpandedView的id冲突,彻底消除警告;
  3. 移除手动记录/恢复滚动位置的代码,因为ScrollView没被销毁,滚动状态会自动保留。

修改后的完整代码

首先补全ColorStruct的定义(确保每个实例有唯一id):

struct ColorStruct: Identifiable {
    let id = UUID()
    var color: Color
}

然后修改Home视图:

struct Home: View {
    @State private var colorStructs: [ColorStruct] = [
        ColorStruct(color: .red),
        ColorStruct(color: .blue),
        ColorStruct(color: .green),
        ColorStruct(color: .yellow)
    ]
    @Namespace private var resultsNamespace
    @State private var isPressed: Bool = false
    @State var selectedColor: ColorStruct? = nil
    @State private var scrollPosition: UUID? = nil // 用UUID和ColorStruct的id精准匹配

    var body: some View {
        ZStack {
            // 1. 永久存在的ScrollView,再也不会被销毁
            VStack {
                ScrollView(.horizontal) {
                    LazyHStack(spacing: 0) {
                        ForEach(colorStructs) { color in
                            RoundedRectangle(cornerRadius: 25)
                                .fill(color.color.gradient)
                                .matchedGeometryEffect(id: color.id.uuidString + "Color", in: resultsNamespace)
                                .padding(.horizontal, 15)
                                .containerRelativeFrame(.horizontal)
                                .overlay {
                                    Text(color.id.uuidString.prefix(5)) // 显示短UUID方便识别
                                        .matchedGeometryEffect(id: color.id.uuidString + "Text", in: resultsNamespace)
                                        .padding(.horizontal)
                                }
                                .onTapGesture {
                                    selectedColor = color
                                    withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                                        isPressed.toggle()
                                    }
                                }
                                // 2. 展开时隐藏选中的卡片,避免id冲突
                                .opacity(isPressed && selectedColor?.id == color.id ? 0 : 1)
                                .id(color.id)
                        }
                    }
                }
                .scrollTargetLayout()
            }
            .scrollPosition(id: $scrollPosition)
            .scrollTargetBehavior(.viewAligned)
            .scrollIndicators(.hidden)
            .frame(height: 250)
            .safeAreaPadding(.vertical, 15)
            .safeAreaPadding(.horizontal, 25)
            // 3. 展开时让ScrollView透明且不可点击
            .opacity(isPressed ? 0 : 1)
            .allowsHitTesting(!isPressed)

            // 展开视图:仅当isPressed为true且有选中项时显示
            if isPressed, let selectedColor = selectedColor {
                ExpandedView(
                    colorStruct: selectedColor,
                    dismiss: $isPressed,
                    namespace: resultsNamespace
                )
            }
        }
        .navigationTitle("Custom Indicator")
    }
}

最后修改ExpandedView,移除不必要的onDismiss回调:

struct ExpandedView: View {
    let colorStruct: ColorStruct
    @Binding var dismiss: Bool
    var namespace: Namespace.ID

    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 25)
                .matchedGeometryEffect(id: colorStruct.id.uuidString + "Color", in: namespace, isSource: false)
                .frame(height: 550)
                .foregroundStyle(colorStruct.color)

            VStack {
                Text(colorStruct.id.uuidString.prefix(5))
                    .matchedGeometryEffect(id: colorStruct.id.uuidString + "Text", in: namespace, isSource: false)
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .offset(y: -150)
                    .padding(.horizontal)
            }
        }
        .onTapGesture {
            withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                dismiss.toggle()
            }
        }
        .padding()
    }
}

关键改动说明

  1. ScrollView永久存在:不再用if !isPressed包裹,而是通过opacity和allowsHitTesting控制可见性——这样ScrollView的滚动状态会被完整保留,不会因为重建丢失位置;
  2. 选中卡片单独隐藏:展开时把被选中的卡片设为opacity:0,避免它和ExpandedView的matchedGeometryEffect id冲突,彻底消除警告;
  3. 移除手动滚动控制:因为ScrollView没被销毁,系统会自动记住之前的滚动位置,收起时直接回到原来的位置,动画和滚动状态完全同步;
  4. 统一id类型:用UUID作为scrollPosition的类型,和ColorStruct的id精准对应,避免字符串匹配的潜在问题。

效果验证

这样修改后,你会发现:

  • 展开/收起的过渡动画完全流畅,没有任何警告;
  • 收起时ScrollView会精准回到之前的滚动位置,不会跳回顶部;
  • 再也不会出现视图错乱(比如绿色卡片收起后露出红色卡片)的问题。

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.08 12:28:07