I have the following 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))
When I swipe from quizSide
to another quizSide
, the animation is smooth.
Smooth animation
However, when I swipe from quizSize
to the last page resultSide
, or from last page resultSide
to quizSide
, the animation is jumpy.
Jumpy animation
I thought the animation should be smooth, as I have already applied id
and tag
inside the TabView
.
Do you have any idea why is is so? How I can avoid such? Here’s the complete code snippet.
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)
// /questions/56507497/views-compressed-by-other-views-in-swiftui-vstack-and-list
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxHeight: .infinity, alignment: .bottom)
}
}