Good day. I am trying to create a quiz game in SwiftUI following MVVM. The game has three categories of questions (History, Geography and Sports). There are 50 questions in each category.
However, I have a problem with the fact that I cannot upload questions from the geography category to GeographyQuizView and also questions from the sport category to SportQuizView. I was able to load only questions from the history category into HistoryQuizView. What is the problem and how to fix it. Thank you.
import SwiftUI
struct ContentView: View {
@StateObject var gameManager = GameManagerVM()
var body: some View {
NavigationView {
ZStack {
LinearGradient(gradient: .init(colors: [.mint,.indigo]), startPoint: .zero, endPoint: .center)
.ignoresSafeArea()
VStack(spacing:250) {
Text("QUIZ GAME")
.font(.system(size: 50))
.bold()
.foregroundColor(.white)
.shadow(radius: 5)
VStack {
VStack(spacing: 20) {
NavigationLink {
HistoryQuizView()
.navigationBarHidden(true)
.environmentObject(gameManager)
}label: {
CategoryButton(text:"History")
}
NavigationLink {
GeographyQuizView()
.navigationBarHidden(true)
.environmentObject(gameManager)
}label: {
CategoryButton(text: "Geography")
}
NavigationLink {
SportQuizView()
.navigationBarHidden(true)
.environmentObject(gameManager)
}label: {
CategoryButton(text:"Sport")
}
}
}
.frame(width: 350, height: 400)
.background(.ultraThinMaterial)
.cornerRadius(40)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewInterfaceOrientation(.portrait)
}
}
import SwiftUI
struct GeographyQuizView: View {
@EnvironmentObject var gameManager: GameManagerVM
var body: some View {
VStack(spacing: 50){
VStack {
Text("\(gameManager.index + 1) out of \(gameManager.lenght)")
.foregroundColor(.white)
.fontWeight(.heavy)
ProgressBar(progress: gameManager.progress)
}
VStack(alignment: .leading, spacing: 20) {
if gameManager.category == "Geography"{
Text(gameManager.question)
.font(.system(size:20))
.bold()
.foregroundColor(.black)
ForEach(gameManager.answerChoices, id: \.id) { answer in AnswerDisplay(answer: answer)
.environmentObject(gameManager)
}
}
}
Button {
gameManager.nextQuestion()
} label: {
NextButton(text: "Next")
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LinearGradient(gradient: .init(colors: [.indigo, .white]), startPoint: .zero, endPoint:.bottom))
}
}
struct GeographyQuizView_Previews: PreviewProvider {
static var previews: some View {
GeographyQuizView()
.environmentObject(GameManagerVM())
}
}
import SwiftUI
struct HistoryQuizView: View {
@EnvironmentObject var gameManager: GameManagerVM
var body: some View {
VStack(spacing: 50){
VStack {
Text("\(gameManager.index + 1) out of \(gameManager.lenght)").foregroundColor(.white)
.fontWeight(.heavy)
ProgressBar(progress: gameManager.progress)
}
VStack(alignment: .leading, spacing: 20) {
if gameManager.category == "History"{
Text(gameManager.question)
.font(.system(size:20))
.bold()
.foregroundColor(.black)
ForEach(gameManager.answerChoices, id: \.id) { answer in AnswerDisplay(answer: answer)
.environmentObject(gameManager)
}
}
}
Button {
gameManager.nextQuestion()
} label: {
NextButton(text: "Next")
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LinearGradient(gradient: .init(colors: [.indigo, .white]), startPoint: .zero, endPoint:.bottom))
}
}
struct HistoryQuizView_Previews: PreviewProvider {
static var previews: some View {
HistoryQuizView()
.environmentObject(GameManagerVM())
}
}
import SwiftUI
struct SportQuizView: View {
@EnvironmentObject var gameManager: GameManagerVM
var body: some View {
VStack(spacing: 50) {
VStack {
Text("\(gameManager.index + 1) out of \(gameManager.lenght)").foregroundColor(.white)
.fontWeight(.heavy)
ProgressBar(progress: gameManager.progress)
}
VStack(alignment: .leading, spacing: 20) {
if gameManager.category == "Sports"{
Text(gameManager.question)
.font(.system(size: 20))
.bold()
.foregroundColor(.black)
ForEach(gameManager.answerChoices, id: \.id) { answer in
AnswerDisplay(answer: answer)
.environmentObject(gameManager)
}
}
}
Button {
gameManager.nextQuestion()
} label: {
NextButton(text: "Next")
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LinearGradient(gradient: .init(colors: [.indigo, .white]), startPoint: .zero, endPoint: .bottom))
}
}
struct SportQuizView_Previews: PreviewProvider {
static var previews: some View {
SportQuizView()
.environmentObject(GameManagerVM())
}
}
import Foundation
import SwiftUI
class GameManagerVM: ObservableObject {
private (set)var quiz: [Quiz.Result] = []
@Published private(set)var lenght = 0
@Published private(set)var index = 0
@Published private(set)var reachedEnd = false
@Published private(set)var answerIsSelected = false
@Published private(set)var question: AttributedString = ""
@Published private(set)var answerChoices: [Answer] = []
@Published private(set)var progress: CGFloat = 0.00
@Published private(set)var score = 0
@Published private(set)var category = ""
init() {
Task.init {
await fetchQuiz()
}
}
func fetchQuiz() async {
let categoryURLs = [
"https://opentdb.com/api.php?amount=50&category=21&difficulty=medium&type=multiple",
"https://opentdb.com/api.php?amount=50&category=22&difficulty=medium&type=multiple",
"https://opentdb.com/api.php?amount=50&category=23&difficulty=medium&type=multiple"
]
for urlStr in categoryURLs {
guard let url = URL(string: urlStr) else {
fatalError("Invalid URL: \(urlStr)")
}
do {
let urlRequest = URLRequest(url: url)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
fatalError("Error while fetching data")
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let decodedData = try decoder.decode(Quiz.self, from: data)
DispatchQueue.main.async {
self.quiz = decodedData.results
self.lenght = self.quiz.count
if self.quiz.count >= 50 {
self.setQuestion()
}
}
} catch {
print("Error fetching trivia: \(error)")
}
}
}
func nextQuestion() {
if index + 1 < lenght {
index += 1
setQuestion()
} else {
reachedEnd = true
}
}
func setQuestion() {
answerIsSelected = false
progress = CGFloat(Double(index + 1) / Double(lenght) * 350)
if index < lenght {
let currentTriviaQuestion = quiz[index]
question = currentTriviaQuestion.formattedQuestion
answerChoices = currentTriviaQuestion.answers
category = currentTriviaQuestion.category
}
}
func selectAnswer(answer: Answer) {
answerIsSelected = true
if answer.isCorrect {
score += 1
}
}
}
import SwiftUI
struct CategoryButton: View {
var text: String
var background: Color = Color.indigo
var body: some View {
Text(text)
.frame(width: 200, height: 80)
.foregroundColor(.white)
.background(background)
.cornerRadius(50)
.shadow(radius: 20)
.font(.largeTitle.bold())
.padding()
}
}
struct CategoryButton_Previews: PreviewProvider {
static var previews: some View {
CategoryButton(text: "Category")
}
}
import SwiftUI
struct AnswerDisplay: View {
@EnvironmentObject var gameManager: GameManagerVM
var answer: Answer
@State private var isSelected = false
var green = Color.green
var red = Color.red
var body: some View {
HStack(spacing: 20) {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text(answer.text)
.bold()
if isSelected {
Spacer()
Image(systemName: answer.isCorrect ? "checkmark.circle.fill" : "x.circle.fill")
.foregroundColor(answer.isCorrect ? green:red)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(gameManager.answerIsSelected ? (isSelected ? Color(.black):.yellow): Color(.black))
.background(.white)
.cornerRadius(10)
.shadow(color: .indigo, radius: 5, x: 0.5, y:0.5)
.onTapGesture {
if !gameManager.answerIsSelected {
isSelected = true
gameManager.selectAnswer(answer: answer)
}
}
}
}
struct AnswerDisplay_Previews: PreviewProvider {
static var previews: some View {
AnswerDisplay(answer: Answer(text: "Ljuba", isCorrect: false))
.environmentObject(GameManagerVM())
}
}
import SwiftUI
struct ProgressBar: View {
var progress: CGFloat
var body: some View {
ZStack(alignment: .leading) {
Rectangle()
.frame(maxWidth: 350, maxHeight: 4)
.foregroundColor(Color.gray)
.cornerRadius(10)
Rectangle()
.frame(width: min(progress, 350), height: 4)
.foregroundColor(Color.red)
.cornerRadius(10)
}
}
}
struct ProgressBar_Previews: PreviewProvider {
static var previews: some View {
ProgressBar(progress: 50)
}
}
import SwiftUI
struct NextButton: View {
var text: String
var body: some View {
let background: Color = Color.yellow
Text(text)
.frame(width: 90, height: 65)
.foregroundColor(.white)
.background(background)
.cornerRadius(20)
.font(.largeTitle.bold())
.shadow(radius: 5)
.padding()
}
}
struct NextButton_Previews: PreviewProvider {
static var previews: some View {
NextButton(text: "Next")
}
}
import Foundation
import SwiftUI
struct Quiz: Decodable {
var results: [Result]
struct Result: Decodable, Identifiable {
var id: UUID {
UUID()
}
var category: String
var type:String
var difficulty:String
var question: String
var correctAnswer: String
var incorrectAnswers: [String]
var formattedQuestion: AttributedString {
do {
return try AttributedString(markdown: question)
} catch {
print("Error setting formattedQuetion: \(error)")
return ""
}
}
var answers: [Answer] {
do{
let correct = [Answer(text:try AttributedString(markdown: correctAnswer), isCorrect: true)]
let incorrects = try incorrectAnswers.map { answer in Answer(text: try AttributedString(markdown: answer), isCorrect: false)
}
let allAnswers = correct + incorrects
return allAnswers.shuffled()
} catch {
print("Error setting answer")
return[]
}
}
}
}
import Foundation
struct Answer: Identifiable {
var id = UUID()
var text: AttributedString
var isCorrect: Bool
}
Welcome to Stack Overflow! Your GameManagerVM.fetchQuiz()
has a flaw in it. You are iterating over your URLSession
calls, one for each type, Sports, Geography and History, in that order. Each one of the calls is sending back the requested data. However, you are attempting to store them in the same variable, quiz
. So, each time you get new data, the old data is being replaced. This happens here in your code:
DispatchQueue.main.async {
self.quiz = decodedData.results // This overwrites the last saved data
self.lenght = self.quiz.count
if self.quiz.count >= 50 {
self.setQuestion()
}
}
I would consider storing quiz
not as an array of Quiz.Result
, but rather as a dict. It would look like this [String:[Quiz.Result]]
, where String
is a category
. Then, each time you download data, you can store it in its own category.
That would change the above section to:
DispatchQueue.main.async {
// The returned data is all one category, so you can extract the data
// from the first element. If this fails, you don't have a first element
// so you got no data, so you don't want it anyway
if let category = decodedData.results.first?.category {
self.quiz[category] = decodedData.results
self.lenght = self.quiz.count
...
}
}
You can check that you got all your categories and questions with a simple UI like this:
struct ContentView: View {
@StateObject var gameManager = GameManagerVM()
var body: some View {
NavigationView {
List {
// Dictionaries are unordered, so they can't be used as is in a
// ForEach. Here, I have turned the keys into an array of
// String, with an id of self (safe enough here as the
// categories must be unique.
ForEach(Array(gameManager.quiz.keys), id:\.self) { category in
Section(category) {
// because you are using a dictionary, accessing
// by the key will return an optional However,
// you know the data exists for the key, else
// you wouldn't have the key, so the easiest
// way to remove the optional is to nil coalesce
// with an empty array
ForEach(gameManager.quiz[category] ?? []) { quizQuestion in
Text(quizQuestion.question)
}
}
}
}
}
}
}
I will let you work through updating the code to handle a Dict
instead of an Array
. It is not too difficult, and there are many resources. If you get stuck, you can ask a new question on Stack Overflow.
The last point is to please try and post a clear question and the minimal code to show the question.