Flutter Animated Quiz App Template |GoRoute + PageView + AnimationController | Easy to follow Guide |

Katrinashui
8 min readJun 11, 2023

Are you struggling with creating a Flutter quiz app with fancy animations? Look no further! This article provides an easy-to-follow guide to creating a quiz application using GoRoute, PageView, and AnimationController. The template includes three main components: Routes, Screen Pages, and Models.

This article provides a simple guide to creating a quiz application using GoRoute, PageView, and AnimationController in Flutter. The template mainly includes three main components:

  • Routes : Identifying the navigation between pages, using goRoute
  • Screen Pages:
    - Question Page (questionPage.dart)
    - Start Page (startPage.dart)
    - Result Page (resultPage.dart)
  • Models: storing the question banks

Here is the file structure:-

File Structure

1. Navigation between pages — GoRoute

To implement goRoute for navigation, you need to set up a StatelessWidget with routerDelegate and routeInformationParser in the main.dart file.

//in main.dart

import 'package:finalquiz/routes/app_route.dart';
import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
routeInformationParser: MyAppRoute().router.routeInformationParser,
routerDelegate: MyAppRoute().router.routerDelegate,
);
}
}

The route related dart is stored in the routes folder, the two files are:-

  • app_route_constant.dart
    - storing the constant name of pages
//app_route_constant.dart
class MyAppRouteConstraint {
static const String startPageName = 'start';
static const String questionRouteName = 'question';
static const String resultRouteName = 'result';
}
  • app_route.dart
    - storing the router of how to navigate among pages
//app_route.dart
import 'package:finalquiz/screens/questionPage.dart';
import 'package:finalquiz/screens/resultPage.dart';
import 'package:finalquiz/screens/startPage.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import './app_route_constant.dart';

class MyAppRoute {
GoRouter router = GoRouter(routes: [
GoRoute(
name: MyAppRouteConstraint.startPageName,
path: '/',
pageBuilder: (context, state) {
return MaterialPage(child: StartPage());
},
),
GoRoute(
name: MyAppRouteConstraint.questionRouteName,
path: '/question',
pageBuilder: (context, state) {
return MaterialPage(child: QuestionPage());
},
),
GoRoute(
name: MyAppRouteConstraint.resultRouteName,
path: '/result',
pageBuilder: (context, state) {
return MaterialPage(child: ResultPage());
})
]);
}

To allow route between pages, each new pages required to add the below code, defining the name, path and the corresponding MaterialPage Widget.

    GoRoute(
name: MyAppRouteConstraint.questionRouteName,
path: '/question',
pageBuilder: (context, state) {
return MaterialPage(child: QuestionPage());
},
),

2. Screens

2.1 Question Page (questionPage.dart)

Below is the major Widget used for question screen:

Major widgets arrangement (details please refer to github source code)

2.1.1 Page Controller

The Question Page is the main component of the app because:

  1. The components in the Question Page are the same for each question.
  2. The number of Question Pages depends on the number of questions.

To handle this, a PageView is introduced in the Question Page.

To coordinate the PageView, we need a controller. We can use a stateful widget and define a PageController.

//Part of code in questionPage.dart

class _QuestionPageState extends State<QuestionPage> {

//Define _controller variable
late PageController _controller;
...

void initState() {
//initialize the pageController
_controller = PageController(initialPage: 0);
...
}

@override
Widget build(BuildContext context) {
return PageView.builder( // use the PageView Widget
itemCount: questions.length, //define the number of page
physics: NeverScrollableScrollPhysics(),//define the scroll option
controller: _controller,
//what inside each page is wrapped in itemBuilder
itemBuilder: (context, index) {
Question question1 = questions[index];
_counter = index;
return Scaffold(...);
};
)};
};

2.1.2 Animation Controller

There are three major animations in the application:

  1. The “Left to Right Swipe Page” animation, which is handled by the page controller (mentioned in 2.1.1).
  2. The “Question Progress Bar” animation, which is handled by the animation controller defined later.
  3. The “Button Swipe Up” animation, which is also handled by the animation controller defined later.

To allow the animations to run on each new question page, a stateful widget is defined for the progress bar and answer button, as shown.

location of animatedBuilder

Animation: Question Progress Bar

To use animation in the progressBar widget, follow these steps:

  1. Define a variable for the animation controller.
  2. Define a variable for the animation.
  3. Define the duration of the animation inside initState().
  4. Define the animation itself.
  5. Activate the animation controller.
  6. Adjust the width of the progress bar using animation!.value.
//progressBar widget in questionPage.dart

class progressBar extends StatefulWidget {
const progressBar({super.key, required this.index});

final int index;

@override
State<progressBar> createState() => _progressBarState();
}

class _progressBarState extends State<progressBar>
with TickerProviderStateMixin {
// 1. Define variable of animation controller
AnimationController? animationController;
// 2. Define variable of animation
Animation<double>? animation;

@override
void initState() {
//initState indicate what happen at the begining when load the widget,
//3. Define the exact animation duration (update as you want)
animationController = AnimationController(
duration: const Duration(milliseconds: 2000), vsync: this);
//4. Define the exact animation, use Tween to store the begin to end
animation = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: animationController!,
curve: Interval((1 / 9) * 1, 1.0, curve: Curves.fastOutSlowIn)));
//5. **Remember to activate the animationController by animationController?.forward();
animationController?.forward();

super.initState();
}

@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animationController!,
builder: (context, child) {
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
height: 5,
width: 200,
decoration: BoxDecoration(
color: HexColor('#87A0E5').withOpacity(0.2),
borderRadius: BorderRadius.all(Radius.circular(4.0)),
),
child: Row(
children: <Widget>[
Container(
//6. Adjust the width of progress bar with animation!.value
width: (200 / questions.length * (widget.index)) +
((200 / questions.length) * animation!.value),

height: 5,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [
HexColor('#EDA276'),
HexColor('#EDA276').withOpacity(0.5),
]),
borderRadius: BorderRadius.all(Radius.circular(4.0)),
),
)
],
),
),
);
});
}
}

Animation: Swipe Up Button

Similar to “Question Progress Bar” animation, a statefulWidget, buttonWidget is defined with all similar set up as the progressBar widget, the differences are:

  1. Exact animation value
//Inside initState() in buttonWidget
animation = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: animationController!,
curve: Interval(
(1 / widget.question.options.length) * widget.ans_ind, 1.0,
curve: Curves.fastOutSlowIn)));

2. FadeTransition & Transform

//Inside build() in buttonWidget
return FadeTransition(
opacity: animation!,
child: Transform(
transform: Matrix4.translationValues(
0, 100 * (1.0 - animation!.value), 0.0),
child: Container(...),
),
)

The overall code of the buttonWidget is:

class buttonWidget extends StatefulWidget {
const buttonWidget(
{Key? key,
required this.nextQuestion,
required this.question,
required this.ans_ind,
required this.selected,
required this.selected_ind})
: super(key: key);
final Function nextQuestion;
final Question question;
final bool selected;
final int ans_ind;
final int selected_ind;

@override
___buttonWidgetState createState() => ___buttonWidgetState();
}

class ___buttonWidgetState extends State<buttonWidget>
with TickerProviderStateMixin {
AnimationController? animationController;
Animation<double>? animation;

@override
void initState() {
animationController = AnimationController(
duration: const Duration(milliseconds: 2000), vsync: this);
animation = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: animationController!,
curve: Interval(
(1 / widget.question.options.length) * widget.ans_ind, 1.0,
curve: Curves.fastOutSlowIn)));
animationController?.forward();
super.initState();
}

@override
void dispose() {
animationController?.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animationController!,
builder: (BuildContext context, Widget? child) {
int ind = widget.ans_ind;

return FadeTransition(
opacity: animation!,
child: Transform(
transform: Matrix4.translationValues(
0, 100 * (1.0 - animation!.value), 0.0),
child: Container(
// width: 400,
padding: const EdgeInsets.only(left: 40, right: 40),
child: ListTile(
title: ElevatedButton(
style: ButtonStyle(
backgroundColor: widget.selected
? (widget.question.options[ind].isCorrect
? MaterialStateProperty.all<Color>(
Color.fromARGB(255, 73, 121, 113))
: (widget.selected_ind == ind
? MaterialStateProperty.all<Color>(
Color.fromARGB(255, 209, 85, 89))
: MaterialStateProperty.all<Color>(
Color.fromARGB(255, 152, 149, 148))))
: MaterialStateProperty.all<Color>(Color(0xffEDA276)),
),
child: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
child: Text(
widget.question.options[ind].text,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Roboto',
fontWeight: FontWeight.w500,
fontSize: 20,
letterSpacing: -0.1,
color: Color(0xFFFAFAFA).withOpacity(0.9),
),
),
),
onPressed: () {
widget.nextQuestion(widget.ans_ind,
widget.question.options[ind].isCorrect);
},
),
),
),
),
);
});
}
}

2.2 & 3 Start Page & Result Page (startPage.dart & resultPage.dart)

These two pages are relatively straightforward. They mainly use the scaffold widget to wrap the required text, button, or sized box widget.

Start Page:

//startPage.dart
import 'package:finalquiz/routes/app_route_constant.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class StartPage extends StatefulWidget {
const StartPage({Key? key}) : super(key: key);

@override
_StartPageState createState() => _StartPageState();
}

class _StartPageState extends State<StartPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xffF7EBE1),
body: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: MediaQuery.of(context).size.width,
child: Image.asset(
'assets/driving_img1.jpg',
fit: BoxFit.cover,
),
),
const Padding(
padding: EdgeInsets.only(top: 80, bottom: 10),
child: Text(
'Ready for your Written Test??',
style: TextStyle(fontSize: 25, fontWeight: FontWeight.bold),
),
),
const SizedBox(
height: 4,
),
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 16),
child: InkWell(
onTap: () {
GoRouter.of(context)
.pushNamed(MyAppRouteConstraint.questionRouteName);
},
child: Container(
padding: EdgeInsets.only(
left: 48.0,
right: 48.0,
top: 16,
bottom: 16,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.0),
color: Color(0xff132137),
),
child: Text(
"Let's Begin",
style: TextStyle(
fontSize: 18,
color: Colors.white,
),
),
),
))
],
),
);
}
}

Result Page:

import 'package:finalquiz/routes/app_route_constant.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:finalquiz/models/questionBank.dart' as globals;

class ResultPage extends StatefulWidget {
const ResultPage({Key? key}) : super(key: key);

@override
_ResultPageState createState() => _ResultPageState();
}

class _ResultPageState extends State<ResultPage> {
@override
void initState() {
super.initState();
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xffF7EBE1),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(left: 20, right: 20),
child: Text(
'Your Score',
style: TextStyle(fontSize: 25),
),
),
Padding(
padding: EdgeInsets.only(top: 30, left: 20, right: 20),
child: Text(
'${globals.gtotal_score} / ${globals.questions.length}',
style: TextStyle(fontSize: 100, fontWeight: FontWeight.bold),
),
),
const SizedBox(
height: 30,
),
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 16),
child: InkWell(
onTap: () {
GoRouter.of(context)
.pushNamed(MyAppRouteConstraint.startPageName);
},
child: Container(
// height: 58,
padding: EdgeInsets.only(
left: 48.0,
right: 48.0,
top: 10,
bottom: 10,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.0),
color: Color(0xff132137),
),
child: Text(
"Retry",
style: TextStyle(
fontSize: 18,
color: Colors.white,
),
),
),
))
],
),
),
);
}
}

3. Models — the Question Bank (questionBank.dart)

The main data used in this quiz app are the questions, which include corresponding options and the correct answer.

class Question {
final String text;
final List<Option> options;
bool isLocked;
Option? selectedOption;

Question({
required this.text,
required this.options,
this.isLocked = false,
this.selectedOption,
});
}

class Option {
final String text;
final bool isCorrect;

const Option({
required this.text,
required this.isCorrect,
});
}

final questions = [
Question(
text: "What should you do when approaching a yellow traffic light?",
options: [
Option(
text: "Speed up and cross the intersection quickly",
isCorrect: false),
Option(text: "Come to a complete stop", isCorrect: false),
Option(text: "Slow down and prepare to stop", isCorrect: true),
Option(text: "Ignore the light and continue driving", isCorrect: false),
],
),
Question(
text: "What does a red octagonal sign indicate?",
options: [
Option(text: "Yield right of way", isCorrect: false),
Option(text: "Stop and proceed when safe", isCorrect: true),
Option(text: "Merge with traffic", isCorrect: false),
Option(text: "No left turn allowed", isCorrect: false),
],
),
Question(
text: "What is the purpose of a crosswalk?",
options: [
Option(text: "A designated area for parking", isCorrect: false),
Option(text: "A place to stop and rest", isCorrect: false),
Option(text: "A path for pedestrians to cross the road", isCorrect: true),
Option(text: "A location for U-turns", isCorrect: false),
],
),
];

That’s the outline of this code. I hope you find it useful. If you want to use this code quickly, simply replace the questions with your own and adjust the number of questions and options for each question. The pages and page views will update automatically.

If you need the full code, you can check out here. And if you have any questions or just want to say hi, feel free to drop me a message.

Source Code:

https://github.com/Katrinasms/Flutter-Quiz-App-Template

--

--