Multi-platform Development with Flutter
0. Setup & Hello World
- Install Flutter SDK on your development machine.
- Create a new Flutter project
- Delete the sample code, so that you are left with only [ this code ].
import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { } }
Let's start building a widget tree using Material library's MaterialApp and Scaffold widgets. Widgets in Flutter usually have several named parameters which define their configuration.
- make the build() method return a MaterialApp widget.
- Set the MaterialApp-s home named parameter to a Scaffold Widget
- For Scaffold, add a body named parameter, set it to Column,
- for Column, set children to be a list of widgets:
<Widget>[ ]
- for Column, set children to be a list of widgets:
- For Scaffold, add a body named parameter, set it to Column,
- Add a single Text Widget to the list
You will notice that the Text will be obstructed by the OS notification bar. To tell Flutter to fit your Widgets so that they don't overlap with the OS elements, wrap your Column widget inside a SafeArea widget, setting the Column as the SafeArea's child parameter.
1. Creating an Exam app
We want to manage a list of questions, and show a single question to the user at a time. For a single question we also want to show answer options as Button widgets.
- in MyApp class, define a list of strings for our questions:
var questions = [ "Which process virtual machine does modern Android use?", "What does Android use for build management?", "Which of these is not an Android App Component?"];
- Also define a variable to keep track of which question is currently shown
var questionIndex = 0;
- Define a function that gets called when the user selects an answer
void answerPressed(){ //TODO: update question index, log new value to console }
- Update the Text widget so that it displays a question based on the current index
- Add some hardcoded buttons for allowing the user to choose an answer
RaisedButton(child: Text("Dalvik"), onPressed: answerPressed,),
- NB! Note that we are specifying answerPressed instead of answerPressed() - that is, we are passing a reference to the function, not invoking the function!
You should now have something like this, and when a button is pressed, you should see output in the console. However, right now pressing a button does not affect the UI (screenshot below also has added an Actionbar to the Scaffold):
2. Updating the question after answering
To make the UI update when some variables change, we have to start using StatefulWidgets. Let's convert MyApp to a StatefulWidget:
- (In the same .dart file), create a subclass of State called "MyAppState".
- Override the implementation of the build function for MyAppState
@override Widget build(BuildContext context) { // TODO: implement build return null; }
- Modify MyApp:
- Move the existing build() implementation from MyApp to the new MyAppState class
- Move the created variables to the new MyAppState
- Change MyApp so that it extends StatefulWidget instead of StatelessWidget
- Override implementation of createState(), make it return new instance of MyAppState ( return MyAppState())
Now, MyApp is a stateful widget which can manage state and Flutter can update the UI when the state is changed. However, to notify flutter about state changes, we need to use setState()
- update answerPressed(), wrapping the index incrementing inside setState.
setState(() { // todo });
Clicking on an answer should now make the next question in the list be displayed. If the index becomes larger than the list, you will see a Not in range error, this is expected. Restart the app to get around it for now.
3. Creating answers dynamically
Let's replace the manually entered answers with something better.
- Replace the questions String list with a list of Map objects. Each element of a list is a map with 2 values: "text" is a String, while "answers" is a list of Strings. [ Show new questions object ].
var questions = [ { "text" : "Which process virtual machine does modern Android use?", "answers" : ["Dalvik", "LLVM", "ART"] }, { "text" : "What does Android use for build management?", "answers" : ["Gradle", "Maven", "ANT"] }, { "text" : "Which of these is not an Android App Component?", "answers" : ["Activity", "Intent", "Service"] }, ];
- Now update the Text widget displaying the question so that it displays the question text value from the Map:
- You can access map values with
someMap["some_key"]
- You can access map values with
3.1. Generating Buttons
Let's dynamically create a Button Widget for each answer in the map.
- Erase all of your existing RaisedButtons
- Let's add code which takes the list of answers in the Map and transforms each one into a Widget using list.map( ) function, resulting in a list of widgets:
...(questions[questionIndex]["answers"] as List<String>) .map( (answer){ //TODO: return button with correct text return null; } ),
- The above code should be placed inside children of Column, after the question text, we use ... (spread operator) to concatenate lists in a flat way.
After finishing the above code, you should be able to navigate between questions, and the answers should be changing accordingly.
4. Showing 'Exam Finished'
We still have the issue that the application crashes once we run out of questions. Let's create a new Widget that should be displayed after all questions are answered.
- Create a new StatelessWidget called ExamResults
- in its' build() method, create a column containing:
- a Text widget, telling the user they finished the exam
- A button which will restart the exam if pressed (put null as value of onPressed for now).
- in its' build() method, create a column containing:
- We want to show the ExamResults widget only if questionIndex >= questions.length , otherwise we want to show our existing column with question and answers.
We can use a ternary if-statement within the build() of MyAppState to control when to show questions and when to show ExamResults.
// inside build(), Scaffold: body: questionIndex < questions.length ? Column( // existing code which shows question + answers ) : ExamResults(),
After adding the above code, after answering the final question, you should see the ExamResults widget.
5. Restarting the exam
To restart the exam, ExamResults widget needs to be able to manage its Parent MyAppState-s state. Let's do this by passing a function to ExamResults that modifies the state.
- Create a function in MyAppState called restartExam(), which uses setState to set the question index back to 0.
- Modify ExamResults, adding a field for a reference to the functtion that will modify state, and add a constructor:
Function restartFunction; // reference to function to use in Button ExamResults(this.restartFunction); // constructor with setter
- Update the call of ExamResults() within the MyAppState build() method so it matches the new constructor.
- Now you can update the button in ExamResult to invoke restartFunction which was passed from MyAppState to ExamResults
6. Keep count of score
Finally, let's add a some value to right/wrong questiosn. Let's update our questions data structure, changing the answers from a List<String> to List<Map<String,Object>>.
var questions = [ { "text": "Which process virtual machine does modern Android use?", "answers": [ {"text": "Dalvik", "score": 1}, {"text": "ART", "score": 3}, {"text": "LLVM", "score": 0} ] }, { "text": "What does Android use for build management?", "answers": [ {"text": "Gradle", "score": 3}, {"text": "Maven", "score": 0}, {"text": "ANT", "score": 0} ] }, { "text": "Which of these is not an Android App Component?", "answers": [ {"text": "Service", "score": 0}, {"text": "Intent", "score": 3}, {"text": "Activity", "score": 0} ] }, ];
This change means we have to update our existing code to the new structure.
- Change the type in the dynamically generated Answer buttons, so that instead of
...(questions[questionIndex]["answers"] as List<String>)
, it gets cast to List<Map<String,Object>> - Now, the argument of function which .map( ..) is a Map object, update the Buttons text accordingly.
Once your app is running again, let's make use of the scores.
- Define a totalScore variable in MyAppState
- Update answerPressed(), so that it accepts an int argument, which then gets added to the totalScore.
- Since you updated answerPressed, you also have to update the call to it.
- We can use an anonymous function to set up an invocation of answerPressed with some argument, and pass the reference of the anonymous function to onPressed.
Now the score is being tracked.
Finally, try to update ExamResults to display the final score to the user. (This can be done by passing that value through the constructor, as we did with restartFunction).
More bonus tasks:
- Refactor the code to separate files, subwidgets, (e.g., the question + answers could be
refactored into its own widget).
- Adjust the styling - try centering widgets, adjust text size, color, etc.
- Add a limit to maximum retries for the exam. Make the "Try again" button disable after 3 tries.