First steps with Flutter - Part 3: Responding to user input

Introduction

This is a three-part series. You can find the other parts here:

Introduction

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.

Prerequisites

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.

Review exercise

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.

flutter-user-input-1

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    }

Widgets and state

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:

  • There is a StatefulWidget class and a State class.
  • The StatefulWidget class initializes the State class.
  • The State class holds the variables and tells the StatefulWidget class when and how to build itself.

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

  • the widget class has a createState() method that returns the State. The State class has a build() method that builds the widget.
  • the _ 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.

Responsive widgets

Now that we’ve talked about state, we’re ready to use it to make our widgets respond to user input.

Buttons

flutter-user-input-2

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:

  • The RaisedButton widget has an onPressed parameter where you can add a function that will be called whenever the button is pressed.
  • You have to update variables inside the setState() method if you want the changes to be reflected in the UI.
  • Do a hot restart (instead of a hot reload) to reset the state to the initial values.

TextFields

In this example whenever a TextField is changed, the Text widget above it gets updated.

flutter-user-input-3

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:

  • TextField has an onChanged parameter for a callback method. This method provides the current string after a change has been made.
  • If you want to get the text value without listening to onChanged, you can set the TextField’s controller parameter. See this post.

Checkboxes

For a checkbox with a label you can use a CheckboxListTile.

flutter-user-input-4

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.

Dialogs

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.

flutter-user-input-5

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:

  • Can you make two buttons?
  • Three buttons?
  • See this post for the answer.

Gesture detectors

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.

flutter-user-input-6

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.

flutter-user-input-7

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    }

Passing data forward

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:

  • The comma separated items between the { } braces are the named parameters. They're optional, but users will be warned if they don't provide an @required parameter.
  • In Flutter a Key is used to differentiate widgets in the widget tree. See this video for more.
  • The this. prefix is used for variables that are defined in the current class.
  • The part after the : 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.
  • See the answers to this Stack Overflow question for more about constructors.

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        ));
  • Note the text "Hello" being passed in as a parameter.
flutter-user-input-8

You can find the full code for the example here.

Passing data back

At other times you need to send data back to the previous screen. Flutter does this in an interesting way.

  • The first screen starts the second screen and then waits for a result, which it can use after the second screen finishes. You will notice the 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      }
  • In the second screen you pass the data back by supplying it as a parameter in the pop method.
    Navigator.pop(context, 'How are you?');
flutter-user-input-9

You can find the full code for the example here.

Conclusion

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.

Further study