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始终“活着”
我们的思路很简单:
- 让ScrollView永久保留在视图层级中,不用
if !isPressed隐藏它,而是通过opacity和allowsHitTesting控制可见性和可交互性; - 展开时单独隐藏选中的卡片,避免和ExpandedView的id冲突,彻底消除警告;
- 移除手动记录/恢复滚动位置的代码,因为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() } }
关键改动说明
- ScrollView永久存在:不再用
if !isPressed包裹,而是通过opacity和allowsHitTesting控制可见性——这样ScrollView的滚动状态会被完整保留,不会因为重建丢失位置; - 选中卡片单独隐藏:展开时把被选中的卡片设为
opacity:0,避免它和ExpandedView的matchedGeometryEffectid冲突,彻底消除警告; - 移除手动滚动控制:因为ScrollView没被销毁,系统会自动记住之前的滚动位置,收起时直接回到原来的位置,动画和滚动状态完全同步;
- 统一id类型:用
UUID作为scrollPosition的类型,和ColorStruct的id精准对应,避免字符串匹配的潜在问题。
效果验证
这样修改后,你会发现:
- 展开/收起的过渡动画完全流畅,没有任何警告;
- 收起时ScrollView会精准回到之前的滚动位置,不会跳回顶部;
- 再也不会出现视图错乱(比如绿色卡片收起后露出红色卡片)的问题。
内容来源于stack exchange
相关产品推荐
相关产品推荐

