SwiftUI中.rect修饰符导致标签切换动画失效问题求助
切换标签时原本会触发立方体旋转动画(支持点击屏幕左右区域或滑动触发),但添加.rect修饰符后,从第2个标签切换至第3个标签时动画完全失效,请问该修饰符引发此Bug的原因是什么?
复现代码
import SwiftUI struct cubeexample: View { @State var small: CGFloat = 0.0 @State var big: CGFloat = 0.0 @State var opacity = 1.0 @State private var progress: CGFloat = .zero @State var selection = "Story 1" let data = ["Story 1", "Story 2", "Story 3"] var body: some View { ZStack { Color.black.opacity(opacity).ignoresSafeArea() TabView(selection: $selection){ ForEach(data, id: \.self) { story in GeometryReader { g in ZStack { HStack(spacing: 0){ Rectangle() .fill(Color.blue) .onTapGesture { if let index = data.firstIndex(where: { $0 == selection }), index > 0 { withAnimation(.easeInOut(duration: 0.25)){ selection = data[index - 1] } } } Rectangle() .fill(Color.blue) .onTapGesture { if let index = data.firstIndex(where: { $0 == selection }), (index + 1) < data.count { withAnimation(.easeInOut(duration: 0.25)){ selection = data[index + 1] } } } } Text(story) .foregroundColor(.white) } .frame(width: g.frame(in: .global).width, height: g.frame(in: .global).height) .rect { tabProgress(tabID: story, rect: $0, size: g.size) } .rotation3DEffect( .init(degrees: getAngle(xOffset: g.frame(in: .global).minX)), axis: (x: 0.0, y: 1.0, z: 0.0), anchor: g.frame(in: .global).minX > 0 ? .leading : .trailing, perspective: 2.5 ) }.tag(story) } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) } } func getAngle(xOffset: CGFloat) -> Double { let tempAngle = xOffset / (UIScreen.main.bounds.width / 2) let rotationDegree: CGFloat = 25 return Double(tempAngle * rotationDegree) } func tabProgress(tabID: String, rect: CGRect, size: CGSize) { if let index = data.firstIndex(where: { $0 == tabID }), let last = data.last, tabID == selection && last == tabID { let offsetX = rect.minX - (size.width * CGFloat(index)) progress = -offsetX / size.width var temp: CGFloat = progress - CGFloat(index) if (temp + 0.005) >= 0.0 { self.opacity = 1.0 - min(1.0, ((temp + 0.005) / 0.08)) } else { self.opacity = 1.0 } if temp >= 0.0 { self.big = temp if small > big { //close view } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.small = temp } } } } } struct RectKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() } } extension View { @ViewBuilder func rect(completion: @escaping (CGRect) -> ()) -> some View { self .overlay { GeometryReader { let rect = $0.frame(in: .scrollView(axis: .horizontal)) Color.clear .preference(key: RectKey.self, value: rect) .onPreferenceChange(RectKey.self, perform: completion) } } } } #Preview { cubeexample() }
原因分析
PreferenceKey的reduce逻辑错误
RectKey的reduce方法直接用新值覆盖旧值,而非合并所有子视图的偏好数据。当TabView包含多个标签时,最后一个标签的rect会覆盖前面所有标签的信息,导致切换到第3个标签时,只有它自己的rect被传递,中间标签的几何变化无法被正确捕获,进而影响旋转动画的角度计算。视图复用与状态更新的冲突
PageTabViewStyle会复用标签视图,.rect修饰符添加在每个标签内部后,会在视图复用过程中干扰几何信息的实时更新。加上tabProgress函数里仅对当前选中的最后一个标签进行状态更新的判断(tabID == selection && last == tabID),导致切换到第3个标签时,滑动过程中的几何偏移无法触发opacity、progress等状态的正确变化,最终让旋转动画失去依赖的动态参数。坐标系不一致导致角度计算失效
旋转动画依赖g.frame(in: .global)获取全局坐标系的minX计算角度,但.rect修饰符里使用的是.scrollView(axis: .horizontal)坐标系。两种坐标系的偏移量计算逻辑不同,当切换到最后一个标签时,minX的数值无法正确反映滑动过程中的偏移,导致getAngle返回的角度始终为0,旋转动画自然无法生效。
修复建议
- 修正PreferenceKey的合并逻辑:将
RectKey的默认值改为[CGRect](),reduce方法改为value.append(contentsOf: nextValue()),确保收集所有标签的rect信息。 - 调整修饰符的作用位置:将
.rect修饰符移到TabView外层,避免干扰单个标签的几何计算,同时修改tabProgress的逻辑来处理所有标签的rect数据。 - 统一坐标系使用:在
getAngle函数中改用.scrollView(axis: .horizontal)坐标系获取偏移量,确保角度计算和.rect修饰符的坐标系一致。
内容的提问来源于stack exchange,提问作者Ahmed Zaidan

