Quiz Game using SwiftUI

Bartłomiej Lańczyk
8 min readMay 13, 2023

--

In this article, I will present a universal implementation of a simple quiz game, where the user will have a specific time to choose one out of four answers and will be constantly informed about their progress in the game.

Here’s a rough division of the article into several parts, where you can provide an overview of what is implemented and how, while making the complete code available on your GitHub.

Data

This is our data model in JSON format. The structure includes:

  • title
  • questions (each question has name, possible options and answer)
{
"title": "Sports Quiz",
"questions": [
{
"question": "Which country won the FIFA World Cup in 2018?",
"options": ["France", "Brazil", "Germany", "Spain"],
"answer": "France"
},
{
"question": "Who is the all-time leading scorer in NBA history?",
"options": ["Kareem Abdul-Jabbar", "LeBron James", "Michael Jordan", "Kobe Bryant"],
"answer": "Kareem Abdul-Jabbar"
},
{
"question": "Which team has won the most Super Bowl titles?",
"options": ["New England Patriots", "San Francisco 49ers", "Pittsburgh Steelers", "Dallas Cowboys"],
"answer": "New England Patriots"
}
]
}

Next, in Swift, we create a structure called ‘Quiz’ that will be Decodable and Hashable and have a static value of ‘empty’ to avoid working with optional values, as we will see later.

struct Quiz: Decodable, Hashable {
var title: String
var questions: [Question]
}

struct Question: Decodable, Hashable {
var question: String
var options: [String]
var answer: String
}

extension Quiz {
static var empty: Self = .init(title: "", questions: [])
}

If we are developing a mobile application and we have control over the data model, we can start by creating it in Swift and then ask ChatGPT to generate mocked values for us.

Decoding

We can decode the prepared structure and assign it to our local value before loading the view.

.onAppear {
do {
let jsonData = questionMock.data(using: .utf8)!
let decodedQuiz = try JSONDecoder().decode(Quiz.self, from: jsonData)
quiz = decodedQuiz
} catch {
print(error)
}
}

For now we are going through the entire process, but we will refactor this code later on.

Views

In our application, we will have 3 views:

  • IndroductionView (title, the number of questions, and a ‘Proceed’ button.)
  • QuestionView (question with answers)
  • ResultsView (quiz result with the option to go back.)

IndroductionView

For now, nothing interesting, just a simple view fetching data from the questionMock.

QuestionView

This view includes a timer that we will decrement to simulate the countdown of time and a progress indicator for the number of questions. Since we want to achieve a smooth transition from the maximum value to 0, the timerValue is set to a very high value.

ResultsView

Just the result of our game.

Next, we will move on to improving the design of our views.

Customization

For the sake of clarity in this article, I will include most of the view extensions and modifiers in one file. However, it is good practice to separate them thematically or functionally.

Our file contains:

  • Background gradient
  • Fonts
  • ViewModifier to our question view
  • ButtonStyle to our buttons
extension LinearGradient {
static var backgroundGradient: Self {
LinearGradient(
gradient: Gradient(colors: [Color.pink, Color.yellow]),
startPoint: .top,
endPoint: .bottom
)
}
}

extension Font {
static var primaryTitle: Self {
Font.system(size: 25, weight: .bold, design: .rounded)
}

static var primaryContent: Self {
Font.system(size: 15, weight: .bold, design: .rounded)
}
}

struct CustomViewModifier: ViewModifier {
var roundedCornes: CGFloat
var startColor: Color
var endColor: Color
var textColor: Color

func body(content: Content) -> some View {
content
.padding()
.background(
LinearGradient(gradient: Gradient(colors: [startColor, endColor]),
startPoint: .topLeading,
endPoint: .bottomTrailing)
)
.cornerRadius(roundedCornes)
.padding(3)
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(textColor)
.shadow(radius: 10)
}
}

struct CustomButtonStyle: ButtonStyle {
var cornerRadius: CGFloat = 40

func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 18, weight: .bold, design: .rounded))
.padding(8)
.background(LinearGradient.backgroundGradient)
.cornerRadius(cornerRadius)
.foregroundColor(configuration.isPressed ? .white.opacity(0.5) : .white)
.shadow(radius: 10)
}
}

Thanks to the application of the above modifiers, we obtain the following result.

For now, these views do not include any navigation logic, which renders our application unusable. Let's address that in the next step.

Navigation

Thanks to the article building-large-scale-apps-swiftui I have created a sleek implementation of view navigation using NavigationStack with Path.

class NavigationState: ObservableObject {
@Published var routes: [Routes] = []
}

enum Routes: Hashable {
case mainNavigation(QuizRoutes)

enum QuizRoutes: Hashable {
case question([Question], Int)
case results(correctAnswers: Int, numberOfQuestions: Int)
}
}

struct MainRouter {
let routes: Routes.QuizRoutes

@ViewBuilder
func configure() -> some View {
switch routes {
case .question(let questions, let questionNumber):
QuestionView(questions: questions, questionNumber: questionNumber)
case .results(let correctAnswers, let numberOfQuestions):
ResultsView(correctAnswers: correctAnswers, numberOfQuestions: numberOfQuestions)
}
}
}

We initialize our NavigationState in our main class and pass it through environmentObject.

@main
struct QuizGameApp: App {
@StateObject private var navigationState = NavigationState()

var body: some Scene {
WindowGroup {
NavigationStack(path: $navigationState.routes) {
IndroductionView()
.navigationDestination(for: Routes.self) { route in
switch route {
case .mainNavigation(let routes):
MainRouter(routes: routes).configure()
}
}
}
.environmentObject(navigationState)
}
}
}

Below, I present the use cases, of which I have identified only three in our application.

  1. Navigate to QuestionView with question number equal 0.
Button("Start") {
navigationState.routes.append(
.mainNavigation(.question(quiz.questions, 0))
)
}

2. Recursively navigate to the next views until we reach a value smaller than the number of questions.

 if questionNumber < questions.count - 1 {
navigationState.routes.append(
.mainNavigation(.question(questions, questionNumber + 1))
)
} else {
navigationState.routes.append(
.mainNavigation(
.results(correctAnswers: correctAnswers,
numberOfQuestions: questions.count)
)
)
}

3. Returning to the IntruductionView by clearing the Route.

navigationState.routes.removeAll()

And here is the result of our work.

Finally, let’s add logic to our quiz. I will selectively go through some solutions, but the complete code will be available on my GitHub repository.

Logic

Let’s make our views a bit more complex.

Timer

In the case of each view’s loading, we initialize our Timer, which decrements the timerValue at a low interval. This provides us with a smooth animation effect.

@State private var timerValue : Float = 1500
@State private var deadlineTimer: Timer? = nil

ProgressView("", value: timerValue, total: 1500)
.progressViewStyle(LinearProgressViewStyle(tint: .red))
.padding()
.scaleEffect(x: 1, y: 2, anchor: .center)
.shadow(radius: 10)
.onAppear {
deadlineTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { _ in
withAnimation {
self.timerValue -= 1
}
}
}

NextQuestion with Deadline

Next, we implement the action of selecting a question and apply Task.sleep to introduce a brief delay in navigating to the next view.

  1. Reset timer
  2. Perform some action
  3. Wait for a moment before transitioning to the next view
private func nextQuestion(action: @escaping () -> Void) {
Task {
deadlineTimer?.invalidate() //1
deadlineTimer = nil
action() //2
try? await Task.sleep(nanoseconds: 1_000_000_000) //3
if questionNumber < questions.count - 1 {
navigationState.routes.append(.mainNavigation(.question(questions, questionNumber + 1)))
} else {
navigationState.routes.append(
.mainNavigation(
.results(correctAnswers: correctAnswers,
numberOfQuestions: questions.count)
)
)
}
}
}

Scoring points

We implement the scoring system in the following way:

  1. We assign the selected option to our ‘selectedOption’ value.
  2. We assign the result of our answer to a separate value by checking if it is equal to our ‘answer’ value.
  3. We disable user interactions with the screen while transitioning to the next view.
@AppStorage("correctAnswers") var correctAnswers: Int = 0

Button {
nextQuestion {
selectedOption = option //1
selectedAnswer = selectedOption == questions[questionNumber].answer ? .correct : .bad //2
if selectedAnswer == .correct {
correctAnswers += 1
}
}
} label: {
Text(option)
.frame(width: 150, height: 50)
.minimumScaleFactor(0.5)
}
.padding(8)
.disabled(!selectedOption.isEmpty) //3

Making a choice effect

Thanks to the power of @ViewBuilder, we create an ‘ifElse’ function that gives us the following effect depending on the case:

  1. If we select the correct answer, choose a green color for our tile.
  2. If we select the wrong answer color the tile in red, and if it’s the correct tile color it in green.
  3. The remaining tiles will be colored with a default gradient.
extension View {
@ViewBuilder func ifElse<Content: View, ElseContent: View>(_ condition: Bool,
transform: (Self) -> Content,
elseTransform: (Self) -> ElseContent) -> some View {
if condition {
transform(self)
} else {
elseTransform(self)
}
}
}

Button {
// ...
}
.ifElse(selectedOption == option) { view in
view.buttonStyle(CustomButtonStyle(cornerRadius: 8,
answerType: selectedAnswer))
} elseTransform: { view in
view
.ifElse(option == questions[questionNumber].answer && selectedAnswer != .unvoted) { view in
view
.buttonStyle(CustomButtonStyle(cornerRadius: 8,
answerType: .correct))
} elseTransform: { view in
view
.buttonStyle(CustomButtonStyle(cornerRadius: 8))
}
}

Now we can see it in action.

Progress effect

The most challenging part was implementing the progress that we need to display on each view and remembering previous results.

We represent the progress using a Circle with the appropriate color, as shown below.

struct CircleIndicator: View {
@Binding var answear: AnswerType

var color: Color {
switch answear {
case .bad:
return .red
case .correct:
return .green
default:
return .gray
}
}

var body: some View {
Circle()
.fill(color)
.frame(width: 10, height: 10)
}
}

The best option I found to store and pass our progress through our views is to extend Dictionary so that it can be saved using AppStorage. Then, at each step, we save the result in it.

extension Dictionary: RawRepresentable where Key == Int, Value == Bool {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8), // convert from String to Data
let result = try? JSONDecoder().decode([Int: Bool].self, from: data)
else {
return nil
}
self = result
}

public var rawValue: String {
guard let data = try? JSONEncoder().encode(self), // data is Data type
let result = String(data: data, encoding: .utf8) // coerce NSData to String
else {
return "{}" // empty Dictionary resprenseted as String
}
return result
}

}

@AppStorage("circleProgress") var circleProgress: [Int: Bool] = [:]


if selectedAnswer == .correct {
correctAnswers += 1
circleProgress[questionNumber] = true
} else {
circleProgress[questionNumber] = false
}

This gives us a nice progress effect for the user.

Bonus

For testing our applications, I regularly use ChatGPT, which allows us to easily generate sample JSON for our project.

In our example, replace ‘questionMock’ with your desired JSON data and observe the effect.

Final word

Everything included in this article is based on my own experience. I would appreciate any feedback and suggestions regarding the code or the format of the articles.

All this tricks and tips are included in my game Trivial Game on the App Store which you can find at this link https://apps.apple.com/pl/app/trivial-game/id6478203647

Full code is available on Github: https://github.com/miltenkot/QuizGame

If you enjoyed this article and would like to see more, please leave a reaction.

--

--