This is a three-part series. You can find the other parts here:
In the two previous tutorials we saw that widgets are blueprints for everything that you can see (and many things that you can't see) in the user interface. Simple widgets can be combined together to make complex layouts. The majority of these layouts can be built by dividing your design into rows (use the Row widget), columns (use the Column widget), and layers (use the Stack widget).
The thing about those layouts, though, is that they were static. You could touch and tap and swipe them all day and they wouldn't do a thing. We are going to fix that in this lesson.
Today we’re going to explore how to actually do something when the user interacts with the widgets that we’ve added to our layout. The emphasis will be on simple, easy to reproduce examples. I strongly encourage you to work along as we go through each one. Make little changes to the code and see how that affects the behavior. This will greatly increase your overall learning.
This tutorial is for beginning Flutter developers. However, I’m assuming that you have the Flutter development environment set up and that you know how to create basic layouts using widgets. If not, refer to the following links:
In this tutorial we’ll start to do a little more programming with the Dart language. I’m assuming that you have a basic knowledge of object oriented programming, but I don't assume that you know Dart.
This lesson was tested using Flutter 1.0 with Android Studio. If you are using Visual Studio Code, though, it shouldn't be a problem. The commands and shortcuts are a little different, but they both fully support Flutter.
Before I give you the boilerplate code that we’ll use in the examples below, let's see if you can create the following layout on your own.
How did you do? If you weren't able to do it, you might want to check out the previous lesson on building layouts. You may have created something like this:
1void main() => runApp(MyApp()); 2 3 class MyApp extends StatelessWidget { // <--- StatelessWidget 4 @override 5 Widget build(BuildContext context) { 6 return MaterialApp( 7 ... 8 body: myLayoutWidget(), 9 ... 10 } 11 12 Widget myLayoutWidget() { 13 return Column( 14 children: [ 15 Text(...), 16 RaisedButton(...), 17 ], 18 ); 19 }
That layout above was fine as far as layouts go, but if you try to change the text when the button is pressed, you’ll run into problems. That's because widgets are immutable: they can't be changed. They can only be recreated. But to recreate the Text widget we need to put the string into a variable. We call that variable the state. It’s similar in idea to the phrases “the state of affairs” or a “State of the Union Address,” which deal with the current conditions of some people or country. Similarly, when we talk about a widget, the state refers to the values (in other words, the current condition) of the variables associated with that widget.
You notice in the code above that it's a StatelessWidget. StatelessWidgets don't have any state. That is, they don't have any mutable variables. So if we have a variable that we want to change, then we need a StatefulWidget.
StatefulWidgets work like this:
NOTE: Behind the scenes there is also an Element that is created from the widget. But as I said, it's behind the scenes and we can happily ignore it at this point in our journey.
So practically speaking, whenever we need a StatefulWidget, we have to create two classes, a widget class and a State class. Here is the basic setup:
1// widget class 2 class MyWidget extends StatefulWidget { 3 @override 4 _MyWidgetState createState() => _MyWidgetState(); 5 } 6 7 // state class 8 class _MyWidgetState extends State<MyWidget> { 9 @override 10 Widget build(BuildContext context) { 11 return ...; // widget layout 12 } 13 }
Notice that
createState()
method that returns the State. The State class has a build()
method that builds the widget._
underscore at the beginning of the name _MyWidgetState
makes it private. It can only be seen within this file. This is a characteristic of the Dart language.Now that we’ve talked about state, we’re ready to use it to make our widgets respond to user input.
Replace the code in your main.dart
file with the following code:
1import 'package:flutter/material.dart'; 2 3 void main() => runApp(MyApp()); 4 5 // boilerplate code 6 class MyApp extends StatelessWidget { 7 @override 8 Widget build(BuildContext context) { 9 return MaterialApp( 10 title: 'Flutter', 11 home: Scaffold( 12 appBar: AppBar( 13 title: Text('Flutter'), 14 ), 15 body: MyWidget(), 16 ), 17 ); 18 } 19 } 20 21 // widget class 22 class MyWidget extends StatefulWidget { 23 @override 24 _MyWidgetState createState() => _MyWidgetState(); 25 } 26 27 // state class 28 // We will replace this class in each of the examples below 29 class _MyWidgetState extends State<MyWidget> { 30 31 // state variable 32 String _textString = 'Hello world'; 33 34 // The State class must include this method, which builds the widget 35 @override 36 Widget build(BuildContext context) { 37 return Column( 38 children: [ 39 Text( 40 _textString, 41 style: TextStyle(fontSize: 30), 42 ), 43 RaisedButton( // <--- Button 44 child: Text('Button'), 45 onPressed: () { 46 _doSomething(); 47 }, 48 ), 49 ], 50 ); 51 } 52 53 // this private method is run whenever the button is pressed 54 void _doSomething() { 55 // Using the callback State.setState() is the only way to get the build 56 // method to rerun with the updated state value. 57 setState(() { 58 _textString = 'Hello Flutter'; 59 }); 60 } 61 }
Run the code that you pasted in above. It should look the same as our original layout, but now the first time we press the button, the text gets updated.
Remember:
onPressed
parameter where you can add a function that will be called whenever the button is pressed.setState()
method if you want the changes to be reflected in the UI.In this example whenever a TextField is changed, the Text widget above it gets updated.
Replace the _MyWidgetState()
class with the following code:
1class _MyWidgetState extends State<MyWidget> { 2 3 String _textString = 'Hello world'; 4 5 @override 6 Widget build(BuildContext context) { 7 return Column( 8 children: [ 9 Text( 10 _textString, 11 style: TextStyle(fontSize: 30), 12 ), 13 TextField( // <--- TextField 14 onChanged: (text) { 15 _doSomething(text); 16 }, 17 ) 18 ], 19 ); 20 } 21 22 void _doSomething(String text) { 23 setState(() { 24 _textString = text; 25 }); 26 } 27 }
Remember:
onChanged
parameter for a callback method. This method provides the current string after a change has been made.onChanged
, you can set the TextField’s controller
parameter. See this post.For a checkbox with a label you can use a CheckboxListTile.
Replace the _MyWidgetState()
class with the following code:
1class _MyWidgetState extends State<MyWidget> { 2 3 bool _checkedValue = false; 4 5 @override 6 Widget build(BuildContext context) { 7 return CheckboxListTile( // <--- CheckboxListTile 8 title: Text('this is my title'), 9 value: _checkedValue, 10 onChanged: (newValue) { 11 _doSomething(newValue); 12 }, 13 // setting the controlAffinity to leading makes the checkbox come 14 // before the title instead of after it 15 controlAffinity: ListTileControlAffinity.leading, 16 ); 17 } 18 19 void _doSomething(bool isChecked) { 20 setState(() { 21 _checkedValue = isChecked; 22 }); 23 } 24 }
PRO TIP: If you want to create a custom checkbox then you can use the Checkbox widget. It doesn't have a title included.Try commenting out the
controlAffinity
line to see how that affects the layout. See this post also. Here is an example of a list of checkboxes.
There are a few kinds of dialogs in Flutter, but let's looks at a common one: the AlertDialog. It's not difficult to set up.
Replace the _MyWidgetState()
class with the following code:
1class _MyWidgetState extends State<MyWidget> { 2 3 @override 4 Widget build(BuildContext context) { 5 return RaisedButton( 6 child: Text('Button'), 7 onPressed: () { 8 _showAlertDialog(); 9 }, 10 ); 11 } 12 13 void _showAlertDialog() { 14 15 // set up the button 16 Widget okButton = FlatButton( 17 child: Text("OK"), 18 onPressed: () { 19 // This closes the dialog. `context` means the BuildContext, which is 20 // available by default inside of a State object. If you are working 21 // with an AlertDialog in a StatelessWidget, then you would need to 22 // pass a reference to the BuildContext. 23 Navigator.pop(context); 24 }, 25 ); 26 27 // set up the AlertDialog 28 AlertDialog alert = AlertDialog( 29 title: Text("Dialog title"), 30 content: Text("This is a Flutter AlertDialog."), 31 actions: [ 32 okButton, 33 ], 34 ); 35 36 // show the dialog 37 showDialog( 38 context: context, 39 builder: (BuildContext context) { 40 return alert; 41 }, 42 ); 43 44 } 45 }
NOTE: An AlertDialog needs the BuildContext. This is passed into the
build()
method and is also a property of the State object. The Navigator is used to close the dialog. We will look more at navigators shortly.
Try a little more:
In the examples above we’ve seen how to respond to user input using some of the common widgets that are available. These widgets provide callback properties like onPressed
and onChanged
. Other widgets (like Text or Container) don't have a built in way to interact with them. Flutter gives us an easy way to make them interactive, though. All you have to do is wrap any widget with a GestureDetector, which is itself a widget.
For example, here is a Text widget wrapped with a GestureDetector widget.
1GestureDetector( 2 child: Text('Hello world'), 3 onTap: () { 4 // do something 5 }, 6 );
When the text is tapped, the onTap
callback will be run. Super easy, isn't it?
You can try it. Every time you tap the text, the color changes.
Add import 'dart:math';
to your main.dart
file and replace the _MyWidgetState()
class with the following code:
1class _MyWidgetState extends State<MyWidget> { 2 3 Color textColor = Colors.black; 4 5 @override 6 Widget build(BuildContext context) { 7 return GestureDetector( // <--- GestureDetector 8 child: Text( 9 'Hello world', 10 style: TextStyle( 11 fontSize: 30, 12 color: textColor, 13 ), 14 ), 15 onTap: () { // <--- onTap 16 _doSomething(); 17 }, 18 ); 19 } 20 21 void _doSomething() { 22 setState(() { 23 // have to import 'dart:math' in order to use Random() 24 int randomHexColor = Random().nextInt(0xFFFFFF); 25 int opaqueColor = 0xFF000000 + randomHexColor; 26 textColor = Color(opaqueColor); 27 }); 28 } 29 }
You are not limited to detecting a tap. There tons of other gestures that are just as easy to detect. Replace onTap
in the code above with a few of them. See how the gestures are detected.
onDoubleTap
onLongPress
onLongPressUp
onPanDown
onPanStart
onPanUpdate
onPanEnd
onPanCancel
onScaleStart
onScaleUpdate
onScaleEnd
onTap
onTapDown
onTapUp
onTapCancel
onHorizontalDragDown
onHorizontalDragUpdate
onHorizontalDragEnd
onHorizontalDragCancel
onVerticalDragStart
onVerticalDragDown
onVerticalDragUpdate
onVerticalDragEnd
onVerticalDragCancel
A lesson about responding to user input wouldn't be complete without talking about navigation. How do we go to a different screen in Flutter? And once there, how do we go back?
Well, as you might expect, a new screen in Flutter is just a new widget. The way to get to these widgets is called a route, and Flutter uses a class called a Navigator to manage the routes. To show a new screen, you use the Navigator to push a route onto a stack. To dismiss a screen and go back to the previous screen, you pop the route off the top of the stack.
Here is how you would navigate to a new widget called SecondScreen.
1Navigator.push( 2 context, 3 MaterialPageRoute( 4 builder: (context) => SecondScreen(), 5 ));
The context
is the BuildContext of the current widget that is wanting to navigate to the new screen. The MaterialPageRoute is what creates the route to the new screen. And Navigator.push
means that we are adding the route to the stack.
Here is how you would return from the SecondScreen back to the first one.
Navigator.pop(context);
Does that look familiar? Yes, we already used that same code to dismiss the AlertDialog that we made before.
Try it out yourself. Here is what it will look like on the iOS simulator.
Replace all of the code in main.dart
with the following code.
1import 'package:flutter/material.dart'; 2 3 void main() { 4 runApp(MaterialApp( 5 title: 'Flutter', 6 home: FirstScreen(), 7 )); 8 } 9 10 class FirstScreen extends StatelessWidget { 11 @override 12 Widget build(BuildContext context) { 13 return Scaffold( 14 appBar: AppBar(title: Text('First screen')), 15 body: Center( 16 child: RaisedButton( 17 child: Text( 18 'Go to second screen', 19 style: TextStyle(fontSize: 24), 20 ), 21 onPressed: () { 22 _navigateToSecondScreen(context); 23 }, 24 ) 25 ), 26 ); 27 } 28 29 void _navigateToSecondScreen(BuildContext context) { 30 Navigator.push( 31 context, 32 MaterialPageRoute( 33 builder: (context) => SecondScreen(), 34 )); 35 } 36 } 37 38 class SecondScreen extends StatelessWidget { 39 @override 40 Widget build(BuildContext context) { 41 return Scaffold( 42 appBar: AppBar(title: Text('Second screen')), 43 body: Center( 44 child: RaisedButton( 45 child: Text( 46 'Go back to first screen', 47 style: TextStyle(fontSize: 24), 48 ), 49 onPressed: () { 50 _goBackToFirstScreen(context); 51 }, 52 ), 53 ), 54 ); 55 } 56 57 void _goBackToFirstScreen(BuildContext context) { 58 Navigator.pop(context); 59 } 60 }
Sometimes you need to send data to the new screen that you are displaying. This is easy to do by passing it in as a parameter in the SecondScreen widget's constructor.
1class SecondScreen extends StatelessWidget { 2 final String text; 3 SecondScreen({Key key, @required this.text}) : super(key: key);
The Dart constructor syntax may look a little strange to you. Here is a brief explanation:
{ }
braces are the named parameters. They're optional, but users will be warned if they don't provide an @required
parameter.this.
prefix is used for variables that are defined in the current class.:
colon is a comma separated initialization list. The lines in this list are run before the super class's constructor. In this case there is nothing here except a call to a specific constructor of the super class.Now that the constructor is set up, you can pass in data when you call it from the FirstScreen.
1Navigator.push( 2 context, 3 MaterialPageRoute( 4 builder: (context) => SecondScreen(text: 'Hello',), 5 ));
You can find the full code for the example here.
At other times you need to send data back to the previous screen. Flutter does this in an interesting way.
async
and await
keywords below. Dart makes it easy to do things that you have to wait for (like web requests or long running tasks). Read this for more information. It’s way easier than Android AsyncTasks!1void startSecondScreen(BuildContext context) async { 2 3 // start the SecondScreen and wait for it to finish with a result 4 final result = await Navigator.push( 5 context, 6 MaterialPageRoute( 7 builder: (context) => SecondScreen(), 8 )); 9 10 // after the SecondScreen result comes back, update the Text widget with it 11 setState(() { 12 text = result; 13 }); 14 15 }
pop
method. Navigator.pop(context, 'How are you?');
You can find the full code for the example here.
In this lesson we have gone from static layouts to dynamic ones with widgets that respond to user input. Making responsive widgets like this means that we need to deal with things that change, whether it's text, color, size, or any number of other things that affect the UI. The value of these variables is known as the state, and widgets that have state are known as StatefulWidgets.
Properly managing state in Flutter is a big topic. You have already seen two ways to do it in this lesson. One was having a method variable in the State class. It was available to all of the widgets throughout the class. As the complexity increases, though, it is not practical to include the entire layout in a single build()
method, nor is it good practice to allow global variables.
Another way we managed state in this lesson was passing data as a parameter into another widget. This works great when one widget is directly calling another, but it can get cumbersome when you need the state from another widget somewhere far away in the widget tree.
As you continue to study you will hear about topics like inherited widgets, Streams, BLoC, and Redux. They are things that you should learn about eventually, but don't worry about them right now. I like what Hillel Coren said:
My approach when working with a new technology is to start with the simplest implementation and only add in extras once I’ve felt the pain they’re designed to eliminate.
You already know enough now to begin making Flutter apps. You can deal with the difficulties as they come. So take that app design that you've got in your head and start creating it! You’re ready to go!
This concludes our First Steps with Flutter series. You can find the code for this tutorial on GitHub.