I find the Flutter framework for mobile app development to be appealing and exciting for several reasons:
- A common source code base can be used to build both an Android and iPhone app.
- While an app is running, you can change the source code, and the app's behavior will change while it is still running, even retaining the state of its objects.
- The layout of widgets is straightforward, with a strong flavor of "trust the platform". It rewards figuring out the right relative arrangements of the widgets without trying to micromanage them.
- Related to this, Flutter builds its widgets in a lightweight manner. All it uses from the underlying platform is the ability to draw pixels. This, in turn, helps avoid the Android nightmare of having to use old versions of the platform on older devices.
Hello, World!
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
This program starts executing in the main() function. It calls runApp() with a newly created object of the MyApp class:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
The architecture of a Flutter app has a StatelessWidget at its root, to organize the basic layout. Its stateless nature is implemented by the fact that, under typical circumstances, its build() method is only called when it is created, and it really doesn't do anything other than answer calls to its build() method.
The actual layout, then, is determined by the Widget returned by the build() method. In this program, that Widget is a MaterialApp widget, which is easily configured as shown by specifying a title, a theme, and a home. The title is simply a short string used as the name of the app. The theme describes the app's look and feel. The primary swatch is the default color used for app components. The visual density describes how densely components should be laid out. Specifying adaptive platform density basically says, "let the platform that this is running on make the decision." Finally, the home has a constructor for an object that will be the app's "home page" or starting point. In this case, it is an object of the MyHomePage class:
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
Just as StatelessWidget.build() is called when a StatelessWidget is created, StatefulWidget.createState() is called when a StatefulWidget is created. The resulting State object will mutate as needed in response to the pertinent system inputs. Much as with the StatelessWidget, there really isn't much to a StatefulWidgetother than the createState() method. The real action is in the State object, as we see next:
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
There are a number of important observations to make about the _MyHomePageState class:
- Dart does not include public or private keywords; instead, any class or instance variable name that starts with an underscore ("_") is private. So the _MyHomePageState class, its _counter instance variable and its _incrementCounter() method are all private.
- Calling State.setState() places a call to this State object's build() method on the scheduler's agenda. The build() method returns a Widget that should reflect the new state of the object. It follows that since build() is called for every state update, it is important to make sure the widget doesn't take too long to generate.
- In this specific case, the build() method creates a Scaffold widget containing a couple of Text widgets in its body as well as a FloatingActionButton. When the latter button is pressed, the _incrementCounter() method is called, which in turn calls setState() and regenerates the widget once again.
- What is nice about the Scaffold widget is that it provides the app with an aesthetically appealing default appearance. The "main action" so to speak goes into the body. In this case, the body is a Center widget, which centers its contents. In turn, it contains a Column widget, instantiated with an array of widgets that are contained in the column.
Math Quizzer
class _MyHomePageState extends State<MyHomePage> {
int _correct = 0;
int _incorrect = 0;
int _x = 0;
int _y = 0;
Random _rng = new Random();
TextEditingController _controller;
void initState() {
super.initState();
_controller = TextEditingController();
_reset_nums();
}
void dispose() {
_controller.dispose();
super.dispose();
}
void _reset_nums() {
_x = _rng.nextInt(12) + 1;
_y = _rng.nextInt(12) + 1;
}
void _check(String answer) {
try {
var target = int.parse(answer);
setState(() {
int total = _x + _y;
if (total == target) {
_correct += 1;
} else {
_incorrect += 1;
}
_reset_nums();
});
} on FormatException {
}
}
@override
Widget build(BuildContext context) {
TextStyle _ts = Theme.of(context).textTheme.headline4;
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column (
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row (
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('$_x + $_y = ', style: _ts),
SizedBox(width: 100, child: TextField(controller: _controller,style: _ts,
onSubmitted: (String value) {_check(value); _controller.clear();})),
],),
Text(
'Correct: $_correct',style: _ts,
),
Text(
'Incorrect: $_incorrect',style: _ts,
)
],
),
),
);
}
}
A More Sophisticated Math Quizzer
Data Structures
enum Op { plus, minus, times, divide } class OpData { int Function(int,int) _op; Op _inverse; String _symbol; OpData(this._op, this._inverse, this._symbol); int Function(int,int) get op => _op; Op get inverse => _inverse; String get symbol => _symbol; } Map<Op,OpData> opData = { Op.plus: OpData((a, b) => a + b, Op.minus, "+"), Op.minus: OpData((a, b) => a - b, Op.plus, "-"), Op.times: OpData((a, b) => a * b, Op.divide, "x"), Op.divide: OpData((a, b) => a ~/ b, Op.times, "/") };
This enum, Op
, represents the four different arithmetic operators employed in a math quiz. (It is the custom in Dart to use lower-case letters for enum values.) The OpData class contains the three elements that we need to represent each Op: the calculation it makes, its inverse, and a symbol to represent it textually. The calculation is represented using a Function object. The data type of a function object is its return type, the keyword Function
, and the types of its parameters. Class members whose names start with an underscore are private. The paramaters of a constructor can be designated with this.member
to auto-initialize the specified member with the specified parameter. The get
keyword denotes a getter method. It is invoked without parentheses or parameters to yield a non-mutable attribute.
Next we examine an ArithmeticProblem
, which combines an Op
with two operands to represent a problem for a student to solve:
class ArithmeticProblem { int _x; Op _op; int _y; int _result; int _hash; bool _valid = true; ArithmeticProblem(this._x, this._op, this._y) { _valid = _y != 0 || _op != Op.divide; if (_valid) { _result = opData[_op].op(_x, _y); } _hash = toString().hashCode; } ArithmeticProblem inverse() => ArithmeticProblem(_result, opData[_op].inverse, _y); int get answer => _result; bool get valid => _valid; bool operator ==(o) => o is ArithmeticProblem && _x == o._x && _y == o._y && _op == o._op; int get hashCode => _hash; String toString() { return "$_x ${opData[_op].symbol} $_y"; } }
The opData
Map defined earlier is employed throughout the implementation of the ArithmeticProblem
class. Aside from the operands and operator, each instance also includes its result, its hash code, and whether or not it is a valid problem. (A problem is invalid if there is a division by zero.) The constructor directly initializes the operands and operator, and calculates the validity, the result, and the hash code, which are then employed by the other methods as needed.
A Quiz
contains a bunch of ArithmeticProblem
objects as well as a random number generator. Each Quiz
contains all possible problems for the given operator and the given range of operands. These problems are randomly permuted. Any incorrectly answered problems are cycled back into the problem rotation. Once all problems have been answered correctly, the Quiz
is considered complete.
enum Outcome { correct, incorrect } class Quiz { List<ArithmeticProblem> _problems = List(); List<ArithmeticProblem> _incorrect = List(); Random _random; Quiz(Op op, int max, this._random) { for (int x = 0; x <= max; x++) { for (int y = 0; y <= max; y++) { _addValidProblem(_makeFrom(x, y, op)); } } _problems.shuffle(_random); } void _addValidProblem(ArithmeticProblem p) { if (p.valid) { _problems.add(p); } } ArithmeticProblem _makeFrom(int x, int y, Op op) { if (op == Op.minus || op == Op.divide) { return ArithmeticProblem(x, opData[op].inverse, y).inverse(); } else { return ArithmeticProblem(x, op, y); } } bool finished() => _problems.isEmpty && _incorrect.isEmpty; ArithmeticProblem current() { if (_problems.isEmpty) { _problems = _incorrect; _incorrect = List(); _problems.shuffle(_random); } return _problems.last; } Outcome enterResponse(int response) { ArithmeticProblem p = _problems.removeLast(); if (p.answer == response) { return Outcome.correct; } else { _incorrect.add(p); return Outcome.incorrect; } } String toString() => 'Problems:${_problems.toString()}; Incorrect:${_incorrect.toString()}'; }
The enum Outcome
is introduced to enable a Quiz
to report whether a given answer is correct. If it is not, the problem is added to the incorrect problems list. Once all problems have been asked, remaining incorrect problems are shuffled back into the list. Subtraction and division problems are generated by inverting addition and multiplication problems. This avoids issues of subtraction problems with negative answers and division problems with non-integer answers, both of which were beyond the scope of what was envisioned for this app.
Unit Testing
import 'dart:math'; import 'package:flutter_test/flutter_test.dart'; import 'package:simple_math/problems.dart'; void main() { test('2 + 3', () { basicTest(Op.plus, 2, 3, 5); }); test('2 * 3', () { basicTest(Op.times, 2, 3, 6); }); test('Problems, addition', () { testQuiz(Op.plus, [1, 1, 2, 2, 2, 4, 4, 3, 0, 3], [Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.incorrect, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct]); }); test('Problems, subtraction', () { testQuiz(Op.minus, [1, 0, 2, 0, 2, 1, 2, 2, 0, 1], [Outcome.correct, Outcome.correct, Outcome.incorrect, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct]); }); test('Problems, multiplication', () { testQuiz(Op.times, [0, 0, 1, 0, 0, 2, 4, 2, 0], [Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct]); }); test('Problems, division', () { testQuiz(Op.divide, [1, 2, 0, 1, 2, 0], [Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct]); }); } void basicTest(Op op, int operand1, int operand2, int expectedValue) { ArithmeticProblem p1 = ArithmeticProblem(operand1, op, operand2); expect(p1.answer, expectedValue); expect(ArithmeticProblem(expectedValue, opData[op].inverse, operand2), p1.inverse()); expect(p1.inverse().answer, operand1); expect(p1.inverse().inverse(), p1); } void testQuiz(Op op, List<int> answers, List<Outcome> expected) { Quiz probs = Quiz(op, 2, new Random(2)); print("$probs"); for (var i = 0; i < answers.length; i++) { expect(probs.enterResponse(answers[i]), expected[i]); } expect(probs.finished(), true); }
Dart's unit testing infrastructure is straightforward, albeit a bit different in layout than the various xUnit
frameworks. A single main()
function calls all the tests. Each test is run by a call to the test
function, which supplies the test code in a Function
object. Within the test code, each assertion is tested using an expect
function call. The expression being evaluated is the first expect
parameter, and the target value is the second parameter.
User Interface Code
_MyHomePageState
class.Widget Function() _screen; Quiz _problems; Random _rng = new Random(); TextEditingController _controller; TextStyle _ts; Op _selected = Op.plus; bool _lastCorrect = true; int _lastMax = 12; void initState() { super.initState(); _resetController(); _screen = setup; } void _resetController() { _controller = TextEditingController(text: '$_lastMax'); } void dispose() { _controller.dispose(); super.dispose(); }
The _screen
object is a reference to the Function
that should be employed for rendering the current app screen. I found this to be an elegant way to transition between screens - just change _screen
to the appropriate function.
The _problems
and _rng
objects are used for the application state. The _controller
and _ts
objects simplify the use of certain UI components. The _selected
and _lastMax
objects represent the user's inputs at the start screen, and the _lastCorrect
object is used to give the user feedback based on whether the last answer was correct.
The initState()
method is where any object setup that is not suitable for immediate object initialization occurs. In this case, setting up the controller and setting the initial screen are both facilitated by being included here. The dispose
method is unfortunately necessary for cleaning up resource usage from the _controller
object.
The functions below represent the three different screens the app displays: a start screen, a problem-answering screen, and a congratulatory concluding screen.
@override Widget build(BuildContext context) { _ts = Theme.of(context).textTheme.headline4; return Scaffold( appBar: AppBar(title: Text(widget.title)), backgroundColor: pickBackground(), body: Center(child: _screen())); } Widget setup() { return Column ( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ radio("+", Op.plus), radio("-", Op.minus), radio("x", Op.times), radio("/", Op.divide), numericalEntry(200, "Maximum", (String value) { }), RaisedButton(child: Text("Start", style: _ts), onPressed: () {_start(_controller.text);}), ], ); } Widget quizScreen() { _controller.clear(); return Column ( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Row ( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text("${_problems.current()} = ", style: _ts), numericalEntry(200, "answer", _check), ],), _restartButton() ], ); } Widget done() { return Column ( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text("Congratulations! All answers correct.", style: _ts), _restartButton(), ], ); } Color pickBackground() { if (_screen == quizScreen) { return _lastCorrect ? Colors.green : Colors.red; } else { return Colors.white; } }
The build
method shows how the _screen
object is used to redirect to the current screen. (The text style is set up there due to access to the BuildContext
object.) All of the screens in this app have the same basic framing, which is given in build
; the role of the _screen
object is to plug in a child Widget
describing the elements specific to that screen. The pickBackground
method determines the background color to be displayed. During a quiz, it depends on whether the last answer was correct. Otherwise, it is simply white, which given the theming would not have been otherwise necessary to specify.
The setup
method lays out the startup screen. The user picks one of four radio buttons to specify the operator, specifies a maximum operand value, and when ready starts the quiz. Note that in most cases, the pertinent widgets are actually created by helper functions. This makes the logic of the screen clear and easy to follow. Given the significant boilerplate code associated with each widget, it greatly minimizes code replication as well.
The quizScreen
method is the heart of the app. The column contains a row in which the user is presented the problem and given the opportunity to answer. Below that is a button for restarting the app. The string describing the current problem makes nice use of Dart's format string syntax. A $
introduces a variable; a ${}
allows the insertion of a full expression.
Finally, the done
method congratulates the user on completing the quiz, and uses the same _restartButton
method to create a path back to the opening screen. This and the other helper methods are shown below.
Widget numericalEntry(double width, String label, void Function(String) onSubmitted) { return SizedBox(width: width, child: TextField(controller: _controller, style: _ts, keyboardType: TextInputType.number, onSubmitted: onSubmitted, decoration: InputDecoration(labelText: label))); } Widget radio(String label, Op value) { return ListTile( title: Text(label, style: _ts), leading: Radio(groupValue: _selected, value: value, onChanged: (o) {setState(() { _selected = value;});}), ); } Widget _restartButton() { return RaisedButton(onPressed: _restart, child: Text("Restart", style: _ts,)); }
The numericalEntry
method creates an area that allows text entry of numerical values. The Flutter TextField
is somewhat finicky; it needs to be deployed within a SizedBox
(or another size-controlling widget). The InputDecoration
provides a handy way to provide a hint to the user as to what is expected in the box without having to prefix it with a label. The onSubmitted
object represents the event handler.
Radio buttons are very straightforward as well. The groupValue
designates a variable that tracks which radio button the user has selected. It is updated in the event handler, which in this case is in-place. (The other event handlers in this app are separate functions.)
The raisedButton
object is used for the restart button. Note that placing text on a button requires giving it a child widget.
Finally we have the event-handling methods:
void _processNumberEntry(String value, void Function(int) processor) { try { processor(int.parse(value)); } on FormatException { print("Threw an exception..."); } } void _start(String value) { _processNumberEntry(value, (v) { setState(() { _lastMax = v; _lastCorrect = true; _problems = Quiz(_selected, _lastMax, _rng); _screen = quizScreen; }); }); } void _check(String answer) { _processNumberEntry(answer, (target) { setState(() { Outcome result = _problems.enterResponse(target); _lastCorrect = (result == Outcome.correct); if (_problems.finished()) { _screen = done; } }); }); } void _restart() { setState(() { _resetController(); _screen = setup; }); }
The _start
method starts a quiz, and the _check
method processes an answer. I didn't write a substantive event handler, as using the numeric entry screen should suffice to ensure that FormatException
is not possible to raise. By placing the exception handling code in _processNumberEntry
, if at a later time I wish to do something more substantive, I won't have to replicate it in both _start
and _check
. The _restart
method returns to the start screen. The resetController
call makes sure that the previous maximum operand value reappears on the start screen.
In all three event handlers, an update to the user interface is triggered by a call to setState
. The code passed to setState
is executed later by the user interface framework prior to refreshing the screen. Transitions between screens are controlled by setting the _screen
object to the desired new function. In this way, _screen
represents a state in a state machine that transitions based on the events that occur and the app's state.
Happy app building!
No comments:
Post a Comment