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

解决SwiftUI TabView切换至结果页时的跳转动画问题

SwiftUI TabView 滑动卡顿问题

我实现了如下SwiftUI TabView:

TabView(selection: $currentTab) {
    ForEach(0...(quizViewModel.quizzes.count), id: \.self) {
        if $0 < quizViewModel.quizzes.count {
            quizSide(index: $0)
                .tag($0)
        } else {
            resultSide()
                .tag($0)
        }
    }
}
.tabViewStyle(.page(indexDisplayMode: .never))
.indexViewStyle(.page(backgroundDisplayMode: .always))

在quizSide页面之间滑动时动画流畅,但从quizSide滑到最后一页resultSide,或从resultSide滑回quizSide时,动画出现跳跃卡顿。我已在TabView中设置了id和tag,求问题原因及解决办法,完整代码如下:

struct QuizView: View {
    @Environment(\.colorScheme) private var colorScheme

    @Environment(\.dismiss) var dismiss
    
    @ObservedObject var quizViewModel: QuizViewModel
    
    @State private var currentTab = 0
    
    @State private var isSheetPresented = false
    
    private let successBackgroundColor = Color(hex: "388E3C")
    
    var body: some View {
        NavigationView {
            VStack {
                if quizViewModel.quizzes.isEmpty {
                    VStack(spacing: 0) {
                        Text("quizzes_loading...")
                            .foregroundColor(.secondary)
                            .font(.title)
                        
                        LottieView(animation: .named("chat"))
                            .looping()
                            .frame(width: 200, height: 56)
                    }.padding(.horizontal, 16)
                } else {
                    
                    Spacer()
                    
                    TabView(selection: $currentTab) {
                        ForEach(0...(quizViewModel.quizzes.count), id: \.self) {
                            if $0 < quizViewModel.quizzes.count {
                                quizSide(index: $0)
                                    .tag($0)
                            } else {
                                resultSide()
                                    .tag($0)
                            }
                        }
                    }
                    .tabViewStyle(.page(indexDisplayMode: .never))
                    .indexViewStyle(.page(backgroundDisplayMode: .always))
                    

                    // Navigation Buttons
                    HStack {
                        Button(action: {
                            withAnimation {
                                currentTab = max(0, currentTab - 1)
                            }
                        }) {
                            Image(systemName: "chevron.left.circle.fill")
                                .font(.system(size: 44))
                                .foregroundStyle(.white, .blue)
                        }
                        .opacity(currentTab > 0 ? 1 : 0.3)
                        
                        Spacer()
                        
                        if currentTab < quizViewModel.quizzes.count {
                            Text(verbatim: "\(currentTab+1) / \(quizViewModel.quizzes.count)")
                                .multilineTextAlignment(.center)
                                .lineLimit(1)
                                .minimumScaleFactor(0.5)
                                .foregroundColor(.secondary)
                                .font(.body)
                            
                            Spacer()
                        }
                        
                        Button(action: {
                            withAnimation {
                                currentTab = min(quizViewModel.quizzes.count, currentTab + 1)
                            }
                        }) {
                            Image(systemName: "chevron.right.circle.fill")
                                .font(.system(size: 44))
                                .foregroundStyle(.white, .blue)
                        }
                        .opacity(currentTab < quizViewModel.quizzes.count ? 1 : 0.3)
                    }
                    .padding(.horizontal, 16)
                    .padding(.bottom, 16)
                }
            }
            .navigationBarTitle("quiz", displayMode: .inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: { dismiss() }) {
                        Image(systemName: "xmark")
                            .imageScale(.medium)        // This matches UIKit's "medium" symbol scale
                            .foregroundColor(.primary)  // This matches UIKit's "Label" color
                    }
                }
            }
        }
        .navigationViewStyle(StackNavigationViewStyle()) // Force single-column navigation (iPad)
    }
}

extension QuizView {
    private func resultSide() -> some View {
        VStack {
            Text(verbatim: "🥳")
                .foregroundColor(.primary)
                .font(.largeTitle)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity) // Ensures VStack fills the whole screen
        .background(.red) // Background now covers the entire screen
    }
    
    private func quizSide(index: Int) -> some View {
        VStack {
            let selection = quizViewModel.selections[index]
            
            let userMadeSelection = selection >= 0
            
            let quiz = quizViewModel.quizzes[index]
            
            if userMadeSelection {
                if let explanation = quiz.choices[selection].explanation, !explanation.isTrimmedEmpty {
                    
                    let correct = selection == quiz.answer
                    
                    let backgroundColor: Color = (correct ? successBackgroundColor : Color(UIColor.systemRed))
                        .opacity(colorScheme == .dark ? 0.4 : 0.2)
                    
                    let content =
                        Text(explanation)
                            .foregroundColor(.primary)
                            .font(.body)
                            .padding(.vertical, 8)
                            .padding(.horizontal, 16)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .quizCard(backgroundColor: backgroundColor, cornerRadius: 8)
                    
                    let text = Text("tap_for_explanation")
                        .foregroundColor(.blue) // Blue color like a hyperlink
                        .underline() // Adds an underline
                        .font(.body)
                        .onTapGesture {
                            isSheetPresented = true // Show the bottom sheet
                        }
                    
                    ViewThatFits(in: .vertical) {
                        content
                        text
                    }
                    .padding(.top, 0)
                    .padding(.bottom, 16)
                    .sheet(isPresented: $isSheetPresented) {
                        NavigationView {
                            content
                            .toolbar {
                                ToolbarItem(placement: .navigationBarTrailing) {
                                    Button(action: {
                                        isSheetPresented = false
                                    }) {
                                        Image(systemName: "xmark")
                                            .imageScale(.medium) // Matches UIKit's "medium" symbol scale
                                            .foregroundColor(.primary) // Matches UIKit's "Label" color
                                    }
                                }
                            }
                        }
                        .presentationDetents([.medium])
                    }
                }
            }
            
            VStack {
                HStack(alignment: .top) {
                    Text(verbatim: "\(index + 1). ")
                        .foregroundColor(.primary)
                        .font(.title3)
                        .fontWeight(.bold)
                    Text(verbatim: "\(quiz.question)")
                        .foregroundColor(.primary)
                        .minimumScaleFactor(0.5)
                        .font(.title3)
                        .fontWeight(.medium)
                        .lineLimit(3)
                        .multilineTextAlignment(.leading)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.bottom, 8)

                
                ForEach(Array(quiz.choices.enumerated()), id: \.offset) { buttonIndex, choice in
                    Button(action: {
                        quizViewModel.selections[index] = buttonIndex
                    }) {
                        let correct = buttonIndex == quiz.answer
                        let selected = selection == buttonIndex
                        
                        let foregroundColor: Color = correct ? Color("successTextColor") : Color(UIColor.systemRed)
                        
                        HStack(alignment: .center) {
                            Text(choice.text)
                                .foregroundColor(.primary)
                                .minimumScaleFactor(0.5)
                                .font(.title3)
                                .lineLimit(2)
                                .multilineTextAlignment(.leading)
                            
                            Spacer()
                            
                            let systemName = correct ? "checkmark.circle.fill" : "xmark.circle.fill"
                            let opacity = correct ? (userMadeSelection ? 1.0 : 0.0) : (selected ? 1.0 : 0.0)
                            
                            Image(systemName: systemName)
                                .font(.system(size: 22))
                                .foregroundStyle(.white, foregroundColor)
                                .opacity(opacity)
                        }
                        .padding(.vertical, 8)
                        .padding(.horizontal, 16)
                        .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading)
                        .background(Color.gray.opacity(0.2))
                        .cornerRadius(8)
                        .overlay(
                            selected ?
                            RoundedRectangle(cornerRadius: 8)
                                .stroke(foregroundColor, lineWidth: 2)
                            : nil
                        )
                    }
                }
            }
            .padding(.vertical, 24)
            .padding(.horizontal, 16)
            .quizCard()
            .padding(.bottom, 16)
            // https://stackoverflow.com/questions/56507497/views-compressed-by-other-views-in-swiftui-vstack-and-list
            .fixedSize(horizontal: false, vertical: true)
        }
        .frame(maxHeight: .infinity, alignment: .bottom)
    }
}

问题原因

  1. 视图布局差异导致尺寸突变:
    quizSide的根布局是.frame(maxHeight: .infinity, alignment: .bottom),内容靠底部对齐;而resultSide是.frame(maxWidth: .infinity, maxHeight: .infinity)且内容居中。两者布局尺寸和对齐方式不一致,TabView切换时需要重新计算布局,引发动画跳跃卡顿。
  2. 异构视图的ForMix结构:
    你在同一个ForEach中混合了quizSide和resultSide两种不同类型的视图,TabView的page样式对同构视图的动画优化更充分,异构视图切换会增加布局计算开销。

解决办法

方法1:统一页面根布局

让两个页面的根布局保持一致,仅调整内部内容的对齐方式:

private func quizSide(index: Int) -> some View {
    VStack {
        // 原有内容保持不变
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity) // 和resultSide统一根布局
    .frame(maxHeight: .infinity, alignment: .bottom) // 内部内容仍靠底部对齐
}

方法2:拆分视图结构

将quizSide和resultSide分开添加到TabView,避免在同一个ForEach中混合不同视图:

TabView(selection: $currentTab) {
    // 遍历所有quiz页面
    ForEach(0..<quizViewModel.quizzes.count, id: \.self) { index in
        quizSide(index: index)
            .tag(index)
    }
    // 单独添加结果页
    resultSide()
        .tag(quizViewModel.quizzes.count)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.indexViewStyle(.page(backgroundDisplayMode: .always))

方法3:添加统一尺寸约束

如果统一布局后仍有问题,给两个页面添加一致的尺寸约束:

// 修改resultSide
private func resultSide() -> some View {
    VStack {
        Text(verbatim: "🥳")
            .foregroundColor(.primary)
            .font(.largeTitle)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .fixedSize(horizontal: false, vertical: true) // 和quizSide保持一致
    .background(.red)
}

内容的提问来源于stack exchange,提问作者Cheok Yan Cheng

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.15 16:20:56