I'm still kind of a newbie to both Flutter and StackOverflow, so I hope the question is clear and I didn't miss anything dumb.
I'm working on a small quiz app, where I have ViewQuiz
, which is the page that displays the i-th card of the quiz. Each card is a Container that contains a question, 5 selectable answers (InkWell + Text), and other less relevant widgets. Check this picture to get a better idea.
Problem: I get an exception when the app tries to build ViewQuiz
, giving this error. In fact, each answer's InkWell is associated with void _setUserAnswer(int)
function (which takes the answer index, and saves the current choice of the user using setState()).
More details about the problem:
- The cause seems to be related to the function closure
(int index) => _setUserAnswer(index)
passed to the card widget, since it callssetState()
, but I don't get why I cannot update the state of the parentViewQuiz
, from the child widgetQuestionWidget
; - I've tried just printing something (instead of calling
_setUserAnswer()
, I tried to setonTap
toprint("test")
) when the InkWell gets tapped, and this is the result. As we can notice, the function gets called 5 times each time the timer ticks, which makes me think that there could also be a problem with the code of the timer; - If I don't use an "external widget" and put everything inside the
ViewQuiz
class, obviously the problem doesn't occur and everything goes as expected. This is the solution I'm using right now, but it seems like cheating and I would rather fix the problem, making the code more readable, and understand exactly why it happens, than leaving it this way.
Goal: I want to set the state of the current selected answer from the child widget, through the function closure (int index) => _setUserAnswer(index)
, which gets the index of the tapped answer element to know which one of the 5 answers has been tapped.
The project is available on GitHub (flutter app and latest release apk), but here's the full code of the involved classes (which is a bit different from the one currently on the repository, since I edited it to make it work):
ViewQuiz class
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:roquiz/model/Question.dart';
import 'package:roquiz/model/Answer.dart';
import 'package:roquiz/model/Quiz.dart';
import 'package:roquiz/model/Settings.dart';
import 'package:roquiz/model/Themes.dart';
import 'package:roquiz/widget/icon_button_widget.dart';
import 'package:roquiz/widget/question_widget.dart';
class ViewQuiz extends StatefulWidget {
const ViewQuiz({Key? key, required this.questions, required this.settings})
: super(key: key);
final List<Question> questions;
final Settings settings;
@override
State<StatefulWidget> createState() => _ViewQuizState();
}
class _ViewQuizState extends State<ViewQuiz> {
Quiz quiz = Quiz();
bool _isOver = false;
int _qIndex = 0;
int _correctAnswers = 0;
String _currentQuestion = "";
List<String> _currentAnswers = [];
List<Answer> _userAnswers = [];
late Timer _timer;
int _timerCounter = -1;
int _dragDirectionDX = 0;
void _previousQuestion() {
setState(() {
if (_qIndex > 0) _qIndex--;
});
}
void _nextQuestion() {
setState(() {
if (_qIndex < widget.settings.questionNumber - 1) _qIndex++;
});
}
void _loadQuestion() {
setState(() {
_currentQuestion = quiz.questions[_qIndex].question;
_currentAnswers = quiz.questions[_qIndex].answers;
});
}
void _setUserAnswer(int answer) {
setState(() {
_userAnswers[_qIndex] = Answer.values[answer];
if (Answer.values[answer] == quiz.questions[_qIndex].correctAnswer) {}
});
}
void _endQuiz() {
setState(() {
_isOver = true;
_timer.cancel();
});
for (int i = 0; i < widget.settings.questionNumber; i++) {
if (_userAnswers[i] == quiz.questions[i].correctAnswer) {
_correctAnswers++;
}
}
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
if (_timerCounter > 0) {
_timerCounter--;
} else {
_timer.cancel();
_endQuiz();
}
});
});
}
void _resetQuiz() {
setState(() {
_userAnswers = [];
for (int i = 0; i < widget.settings.questionNumber; i++) {
_userAnswers.add(Answer.NONE);
}
quiz.resetQuiz(widget.questions, widget.questions.length,
widget.settings.shuffleAnswers);
_qIndex = 0;
_correctAnswers = 0;
_isOver = false;
_currentQuestion = quiz.questions[_qIndex].question;
_currentAnswers = quiz.questions[_qIndex].answers;
_timerCounter = widget.settings.timer * 60;
});
_startTimer();
}
@override
void initState() {
super.initState();
quiz.resetQuiz(widget.questions, widget.questions.length,
widget.settings.shuffleAnswers);
_resetQuiz();
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
_endQuiz();
return true;
},
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
details.delta.dx > 5
? _dragDirectionDX = -1
: (details.delta.dx < -5 ? _dragDirectionDX = 1 : null);
});
},
onPanEnd: (details) {
_dragDirectionDX > 0
? _nextQuestion()
: (_dragDirectionDX < 0 ? _previousQuestion() : null);
_loadQuestion();
_dragDirectionDX = 0;
},
child: Scaffold(
appBar: AppBar(
title: const Text("Quiz"),
centerTitle: true,
automaticallyImplyLeading: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: () {
_endQuiz();
Navigator.pop(context);
},
),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AutoSizeText(
"Question: ${_qIndex + 1}/${widget.settings.questionNumber}",
maxLines: 1,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
AutoSizeText(
"Timer: ${_timerCounter ~/ 60}:${(_timerCounter % 60).toInt() < 10 ? "0" + (_timerCounter % 60).toInt().toString() : (_timerCounter % 60).toInt().toString()}",
maxLines: 1,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 10),
QuestionWidget(
questionText: _currentQuestion,
answers: _currentAnswers,
isOver: _isOver,
userAnswer: _userAnswers[_qIndex],
correctAnswer: widget.questions[_qIndex].correctAnswer,
onTapAnswer: !_isOver
? (int index) => _setUserAnswer(index) // ERROR: Throws exception here
: (_) =>
null,
backgroundQuizColor: Colors.cyan.withOpacity(0.1),
defaultAnswerColor: Colors.indigo.withOpacity(0.2),
selectedAnswerColor: Colors.indigo.withOpacity(0.5),
correctAnswerColor:
const Color.fromARGB(255, 42, 255, 49).withOpacity(0.5),
correctNotSelectedAnswerColor:
const Color.fromARGB(255, 27, 94, 32).withOpacity(0.8),
wrongAnswerColor: Colors.red.withOpacity(0.8),
),
],
),
),
),
persistentFooterButtons: [
Row(
children: [
// Show previous question
IconButtonLongPressWidget(
lightPalette: MyThemes.lightIconButtonPalette,
darkPalette: MyThemes.darkIconButtonPalette,
onUpdate: () {
_previousQuestion();
_loadQuestion();
},
width: 50.0,
height: 50.0,
icon: Icons.arrow_back_ios_rounded,
iconSize: 35,
),
const SizedBox(width: 20),
// Show next question
IconButtonLongPressWidget(
lightPalette: MyThemes.lightIconButtonPalette,
darkPalette: MyThemes.darkIconButtonPalette,
onUpdate: () {
_nextQuestion();
_loadQuestion();
},
width: 50.0,
height: 50.0,
icon: Icons.arrow_forward_ios_rounded,
iconSize: 35,
),
const Spacer(flex: 5),
// End/Restart quiz
ElevatedButton(
onPressed: () {
!_isOver ? _endQuiz() : _resetQuiz();
},
child: Container(
alignment: Alignment.center,
height: 50.0,
width: 100.0,
child: Text(
!_isOver ? "Termina" : "Riavvia",
style: const TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
),
),
)
],
),
],
),
),
);
}
}
QuestionWidget class
import 'package:flutter/material.dart';
import 'package:roquiz/model/Answer.dart';
class QuestionWidget extends StatelessWidget {
const QuestionWidget({
Key? key,
this.questionNumber,
required this.questionText,
this.alignmentQuestion = Alignment.center,
required this.answers,
required this.isOver,
required this.userAnswer,
required this.correctAnswer,
required this.onTapAnswer, // funzione vuota di default / null?
required this.backgroundQuizColor,
required this.defaultAnswerColor,
this.selectedAnswerColor = Colors.transparent,
required this.correctAnswerColor,
required this.correctNotSelectedAnswerColor,
this.wrongAnswerColor = Colors.transparent,
}) : super(key: key);
final String questionText;
final List<String> answers;
final int? questionNumber; // Index of the current question
final Alignment alignmentQuestion;
final bool isOver;
final Answer userAnswer; // User answer for that question (can be NONE)
final Answer correctAnswer; // Correct answer
final Function(int)
onTapAnswer; // Callback which updates the user selected answer
final Color backgroundQuizColor;
final Color defaultAnswerColor;
final Color selectedAnswerColor;
final Color correctAnswerColor;
final Color correctNotSelectedAnswerColor;
final Color wrongAnswerColor;
Color _getColor(int index) {
return !isOver && userAnswer == Answer.values[index]
? selectedAnswerColor
: (!isOver
? defaultAnswerColor
: (correctAnswer == Answer.values[index] &&
(userAnswer == Answer.NONE || userAnswer != correctAnswer)
? correctAnswerColor
: (correctAnswer == Answer.values[index]
? correctNotSelectedAnswerColor
: (userAnswer == Answer.values[index]
? wrongAnswerColor
: defaultAnswerColor))));
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(10.0),
decoration: BoxDecoration(
color: backgroundQuizColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: selectedAnswerColor)),
child: Column(
children: [
Container(
alignment: alignmentQuestion,
width: double.infinity,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(30))),
child: Padding(
padding: const EdgeInsets.all(8.0),
// Question text
child: Text(
(questionNumber != null ? "Q$questionNumber) " : "") +
questionText,
style: const TextStyle(fontSize: 16)),
),
),
// ANSWERS
...List.generate(
answers.length,
(index) => Padding(
padding: const EdgeInsets.only(bottom: 5.0),
child: InkWell(
enableFeedback: true,
onTap: onTapAnswer(index),
child: Container(
alignment: Alignment.centerLeft,
width: double.infinity,
decoration: BoxDecoration(
color: _getColor(index),
borderRadius:
const BorderRadius.all(Radius.circular(10))),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(children: [
// Answer letter
Text(Answer.values[index].name + ") ",
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
Flexible(
// Answer text
child: Text(answers[index],
style: const TextStyle(fontSize: 14)),
),
]),
),
),
),
),
),
],
),
);
}
}