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

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()
}

原因分析

  1. PreferenceKey的reduce逻辑错误
    RectKey的reduce方法直接用新值覆盖旧值,而非合并所有子视图的偏好数据。当TabView包含多个标签时,最后一个标签的rect会覆盖前面所有标签的信息,导致切换到第3个标签时,只有它自己的rect被传递,中间标签的几何变化无法被正确捕获,进而影响旋转动画的角度计算。

  2. 视图复用与状态更新的冲突
    PageTabViewStyle会复用标签视图,.rect修饰符添加在每个标签内部后,会在视图复用过程中干扰几何信息的实时更新。加上tabProgress函数里仅对当前选中的最后一个标签进行状态更新的判断(tabID == selection && last == tabID),导致切换到第3个标签时,滑动过程中的几何偏移无法触发opacity、progress等状态的正确变化,最终让旋转动画失去依赖的动态参数。

  3. 坐标系不一致导致角度计算失效
    旋转动画依赖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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 07:19:51