Building beautiful UIs with Flutter

1. Introduction

Flutter is Google's UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase. In this codelab, you'll create a simple chat application for Android, iOS, and (optionally) the web.

This codelab provides a deeper dive into Flutter than Write Your First Flutter App, part 1 and part 2. If you want a gentler introduction to Flutter, start with those.

What you learn

  • How to write a Flutter app that looks natural on both Android and iOS
  • How to use the Android Studio IDE, using many shortcuts supported by the Flutter plugin for Android Studio and IntelliJ
  • How to debug your Flutter app
  • How to run your Flutter app on an emulator, a simulator, and a device

What would you like to learn from this codelab?

I'm new to the topic, and I want a good overview. I know something about this topic, but I want a refresher. I'm looking for example code to use in my project. I'm looking for an explanation of something specific.

2. Set up your Flutter environment

You need two pieces of software to complete this codelab: the Flutter SDK ( download) and an editor ( configure). This codelab assumes that you use Android Studio, but you can use your preferred editor.

You can run this codelab using any of the following devices:

  • A physical device (Android or iOS) connected to your computer and set to developer mode
  • The Android emulator
  • The iOS simulator
  • The Chrome browser
  • Windows, macOS or Linux desktop (if you enable Flutter's desktop support)

If you are running on Android, you must do some setup in Android Studio. If you are running on iOS, you must also have Xcode installed on a Mac. For more information, see Set up an editor.

3. Start a new Flutter project

Create a simple templated Flutter app. You modify this starter app to create the finished app.

a3c16fc17be25f6c.pngLaunch Android Studio.

  1. If you do not have open projects, then select Start a new Flutter app from the welcome page. Otherwise, select File > New > New Flutter Project.
  2. Select Flutter Application as the project type, and click Next.
  3. Verify that the Flutter SDK path specifies the SDK's location. (Select Install SDKif the text field is blank.)
  4. Enter friendly_chat as the project name, and click Next.
  5. Use the default package name suggested by Android Studio, and click Next.
  6. Click Finish.
  7. Wait for Android Studio to install the SDK and create the project.

a3c16fc17be25f6c.pngAlternatively, create a Flutter app on the command line.

$ flutter create friendly_chat
$ cd friendly_chat
$ flutter run

Problems?

See the Test drive page for more information about creating a simple templated app. Or, use the code at the following links to get back on track.

4. Build the main user interface

In this section, you begin modifying the default sample app, to make it a chat app. The goal is to use Flutter to build FriendlyChat, a simple, extensible chat app with these features:

  • The app displays text messages in real time.
  • Users can enter a text string message, and send it either by pressing the Return key or the Send button.
  • The UI runs on Android and iOS devices, as well as the web.

Try the finished app on DartPad!

Create the main app scaffold

The first element you add is a simple app bar that shows a static title for the app. As you progress through subsequent sections of this codelab, you incrementally add more responsive and stateful UI elements to the app.

The main.dart file is located under the lib directory in your Flutter project, and contains the main() function that starts the execution of your app.

a3c16fc17be25f6c.pngReplace all of the code in main.dart with the following:

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      title: 'FriendlyChat',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('FriendlyChat'),
        ),
      ),
    ),
  );
}

71dd22da186608e5.png Observations

  • Any Dart program, whether it's a command-line app, an AngularDart app, or a Flutter app, starts with a main() function.
  • The main() and runApp() function definitions are the same as in the automatically generated app.
  • The runApp() function takes as its argument a Widget, which the Flutter framework expands and displays to the screen at run time.
  • This chat app uses Material Design elements in the UI, so a MaterialApp object is created and passed to the runApp() function. The MaterialApp widget becomes the root of your app's widget tree.
  • The home argument specifies the default screen that users see in your app. In this case, it consists of a Scaffold widget that has a simple AppBar as its child widget. This is typical for a Material app.

a3c16fc17be25f6c.pngRun the app by clicking the Run icon afdb56a70d8aaf27.png in the editor . The first time you run an app, it can take a while. The app is faster in later steps.

50ff8705e2104489.png

You should see something like the following:

Pixel 3XL

iPhone 11

Build the chat screen

To lay the groundwork for interactive components, you break the simple app into two different subclasses of widget: a root-level FriendlyChatApp widget that never changes and a child ChatScreen widget that rebuilds when messages are sent and internal state changes. For now, both these classes can extend StatelessWidget. Later, you modify ChatScreen to be a stateful widget. That way, you can change its state as needed.

a3c16fc17be25f6c.pngCreate the FriendlyChatApp widget:

  1. Inside main(), place the cursor in front of the M in MaterialApp.
  2. Right-click, and select Refactor > Extract > Extract Flutter widget.

4c6a0b41a79cab83.png

  1. Enter FriendlyChatApp into the ExtractWidget dialog, and click the Refactor button. The MaterialApp code is placed in a new stateless widget called FriendlyChatApp, and main() is updated to call that class when it calls the runApp() function.
  2. Select the block of text after home:. Start with Scaffold( and end with the Scaffold's closing parenthesis, ). Do not include the ending comma.
  3. Start typing ChatScreen, and select ChatScreen() from the popup. (Choose the ChatScreen entry that is marked with an equal sign inside the yellow circle. This gives you a class with empty parentheses, rather than a constant.)

a3c16fc17be25f6c.pngCreate a stateless widget, ChatScreen:

  1. Under the FriendlyChatApp class, around line 27, start typing stless. The editor asks if you want to create a Stateless widget. Press Return to accept. The boilerplate code appears, and the cursor is positioned for you to enter the name of your stateless widget.
  2. Enter ChatScreen.

a3c16fc17be25f6c.pngUpdate the ChatScreen widget:

  1. Inside the ChatScreen widget, select Container, and start typing Scaffold. Select Scaffold from the popup.
  2. The cursor should be positioned inside the parentheses. Press Return to start a new line.
  3. Start typing appBar, and select appBar: from the popup.
  4. After appBar:, start typing AppBar, and select the AppBar class from the popup.
  5. Within the parentheses, start typing title, and select title: from the popup.
  6. After title:, start typing Text, and select the Text class.
  7. The boilerplate code for Text contains the word data. Delete the first comma after data. Select data, and replace it with 'FriendlyChat'. (Dart supports single or double quotation marks, but prefers single quotation marks unless the text already contains a single quotation mark.)

Look in the upper, right corner of the code pane. If you see a green checkmark, then your code passes the analysis. Congratulations!

71dd22da186608e5.png Observations

This step introduces several key concepts of the Flutter framework:

  • You describe the part of the user interface represented by a widget in its build() method. The framework calls the build() methods for FriendlyChatApp and ChatScreen when inserting these widgets into the widget hierarchy and when their dependencies change.
  • @override is a Dart annotation that indicates that the tagged method overrides a superclass's method.
  • Some widgets, like Scaffold and AppBar, are specific to Material Design apps. Other widgets, like Text, are generic and can be used in any app. Widgets from different libraries in the Flutter framework are compatible and can work together in a single app.
  • Simplifying the main() method enables hot reload because hot reload doesn't rerun main().

a3c16fc17be25f6c.pngClick the hot reload c222adb5b39f083a.png button to see the changes almost instantly. After dividing the UI into separate classes and modifying the root widget, you should see no visible change in the UI.

Problems?

If your app is not running correctly, look for typos. If needed, use the code at the following link to get back on track.

5. Add a UI for composing messages

In this section, you learn how to build a user control that enables the user to enter and send chat messages.

18ca3bd335f3552b.png

On a device, clicking the text field brings up a soft keyboard. Users can send chat messages by typing a non-empty string and pressing the Return key on the soft keyboard. Alternatively, users can send their typed messages by pressing the graphical Send button next to the input field.

For now, the UI for composing messages is at the top of the chat screen, but after you add the UI for displaying messages in the next step, you move it to the bottom of the chat screen.

Add an interactive text input field

The Flutter framework provides a Material Design widget called TextField. It's a StatefulWidget (a widget that has mutable state) with properties for customizing the behavior of the input field. State is information that can be read synchronously when the widget is built and might change during the lifetime of the widget. Adding the first stateful widget to the FriendlyChat app requires making a few modifications.

a3c16fc17be25f6c.pngChange the ChatScreen class to be stateful:

  1. Select ChatScreen in the line class ChatScreen extends StatelessWidget.
  2. Press Option+Return (macOS) or Alt+Enter (Linux and Windows) to bring up a menu.
  3. From the menu, select Convert to StatefulWidget. The class is automatically updated with the boilerplate code for a stateful widget including a new _ChatScreenState class for managing state.

To manage interactions with the text field, you use a TextEditingController object for reading the contents of the input field and for clearing the field after the chat message is sent.

a3c16fc17be25f6c.pngAdd a TextEditingController to _ChatScreenState.

Add the following as the first line in the _ChatScreenState class:

final _textController = TextEditingController();

Now that your app has the ability to manage state, you can build out the _ChatScreenState class with an input field and a Send button.

a3c16fc17be25f6c.pngAdd a _buildTextComposer function to _ChatScreenState:

Widget _buildTextComposer() {
  return Container(
    margin: const EdgeInsets.symmetric(horizontal: 8.0),
    child: TextField(
      controller: _textController,
      onSubmitted: _handleSubmitted,
      decoration: const InputDecoration.collapsed(hintText: 'Send a message'),
    ),
  );
}

71dd22da186608e5.png Observations

  • In Flutter, stateful data for a widget is encapsulated in a State object. The State object is then associated with a widget that extends the StatefulWidget class.
  • The code above defines a private method called _buildTextComposer() that returns a Container widget with a configured TextField widget.
  • The Container widget adds a horizontal margin between the edge of the screen and each side of the input field.
  • The units passed to EdgeInsets.symmetric are logical pixels that get translated into a specific number of physical pixels, depending on a device's pixel ratio. You might be familiar with the equivalent term for Android (density-independent pixels) or for iOS (points).
  • The onSubmitted property provides a private callback method, _handleSubmitted(). At first, this method just clears the field, but later you extend it to send the chat message.
  • The TextField with the TextEditingController gives you control over the text field. This controller will clear the field and read its value.

a3c16fc17be25f6c.pngAdd the _handleSubmitted function to _ChatScreenState for clearing the text controller:

void _handleSubmitted(String text) {
  _textController.clear();
}

Add a text composer widget

a3c16fc17be25f6c.pngUpdate the build() method for _ChatScreenState.

After the appBar: AppBar(...) line, add a body: property:

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: Text('FriendlyChat')),
    body: _buildTextComposer(),    // NEW
  );
}

71dd22da186608e5.png Observations

  • The _buildTextComposer method returns a widget that encapsulates the text input field.
  • Adding _buildTextComposer to the body property causes the app to display the text input user control.

a3c16fc17be25f6c.pngHot reload the app. You should see a screen that looks like the following:

Pixel 3XL

iPhone 11

Add a responsive Send button

Next, you add a Send button to the right of the text field. This involves adding a bit more structure to the layout.

a3c16fc17be25f6c.pngIn the _buildTextComposer function, wrap the TextField inside a Row:

  1. Select TextField in _buildTextComposer.
  2. Press Option+Return (macOS) or Alt+Enter (Linux and Windows) to bring up a menu, and select Wrap with widget. A new widget is added that wraps the TextField. The placeholder name is selected, and the IDE waits for you to enter a new placeholder name.
  3. Start typing Row, and select Row from the list that appears. A popup appears containing the definition for the Row's constructor. The child property has a red border, and the analyzer tells you that you are missing the required children property.
  4. Hover over child and a popup appears. In the popup, it asks if you want to change the property to children. Select that option.
  5. The children property takes a list, rather than a single widget. (Right now, there is only one item in the list, but you will add another soon.) Convert the widget to a list of one by typing a left bracket ([) after the children: text. The editor also provides the closing right parenthesis. Delete the closing bracket. Several lines down, just before the right parenthesis that closes the row, type the right bracket followed by a comma (],). The analyzer should now show a green checkmark.
  6. The code is now correct, but is not well formatted. Right-click in the code pane, and select Reformat Code with dartfmt.

a3c16fc17be25f6c.pngWrap the TextField inside a Flexible:

  1. Select Row.
  2. Press Option+Return (macOS) or Alt+Enter (Linux and Windows) to bring up a menu, and select Wrap with widget. A new widget is added that wraps the TextField. The placeholder name is selected, and the IDE waits for you to enter a new placeholder name.
  3. Start typing Flexible, and select Flexible from the list that appears. A popup appears containing the definition for the Row's constructor.
Widget _buildTextComposer() {
  return Container(
    margin: const EdgeInsets.symmetric(horizontal: 8.0),
    child: Row(                // NEW
      children: [              // NEW
        Flexible(              // NEW
          child: TextField(
            controller: _textController,
            onSubmitted: _handleSubmitted,
            decoration:
                const InputDecoration.collapsed(hintText: 'Send a message'),
          ),
        ),                     // NEW
      ],                       // NEW
    ),                         // NEW
  );
}

71dd22da186608e5.png Observations

  • Using a Row allows you to place the Send button adjacent to the input field.
  • Wrapping the TextField in a Flexible widget tells the Row to automatically size the text field to use the remaining space that isn't used by the button.
  • Adding the comma after the right bracket tells the formatter how to format the code.

Next, you add a Send button. This is a Material app, so use the corresponding Material icon 715252632482fc96.png:

a3c16fc17be25f6c.pngAdd the Send button to the Row.

The Send button becomes the second item in the Row's list.

  1. Position the cursor at the end of the Flexible widget's closing right bracket and comma, and press Return to start a new line.
  2. Start typing Container, and select Container from the popup. The cursor is positioned inside the container's parentheses. Press Return to start a new line.
  3. Add the following lines of code to the container:
margin: const EdgeInsets.symmetric(horizontal: 4.0),
child: IconButton(
    icon: const Icon(Icons.send),
    onPressed: () => _handleSubmitted(_textController.text)), 

71dd22da186608e5.png Observations

  • The IconButton displays the Send button.
  • The icon property specifies the Icons.send constant from the Material library to create a new Icon instance.
  • Placing the IconButton inside a Container widget lets you customize the margin spacing of the button so that it visually fits better next to your input field.
  • The onPressed property uses an anonymous function to invoke the _handleSubmitted() method and passes the contents of the message using the _textController.
  • In Dart, the arrow syntax ( => expression) is sometimes used in declaring functions. This is shorthand for { return expression; } and is only used for one-line functions. For an overview of Dart function support, including anonymous and nested functions, see the Dart Language Tour.

a3c16fc17be25f6c.pngHot reload the app to see the Send button:

Pixel 3XL

iPhone 11

The color of the button is black, which comes from the default Material Design theme. To give the icons in your app an accent color, pass the color argument to IconButton, or apply a different theme.

a3c16fc17be25f6c.pngIn _buildTextComposer(), wrap the Container in an IconTheme:.

  1. Select Container at the top of the _buildTextComposer() function.
  2. Press Option+Return (macOS) or Alt+Enter (Linux and Windows) to bring up a menu, and select Wrap with widget. A new widget is added that wraps the Container. The placeholder name is selected, and the IDE waits for you to enter a new placeholder name.
  3. Start typing IconTheme, and select IconTheme from the list. The child property is surrounded by a red box, and the analyzer tells you that the data property is required.
  4. Add the data property:
return IconTheme(
  data: IconThemeData(color: Theme.of(context).colorScheme.secondary), // NEW
  child: Container(                                    

71dd22da186608e5.png Observations

  • Icons inherit their color, opacity, and size from an IconTheme widget, which uses an IconThemeData object to define these characteristics.
  • The IconTheme's data property specifies the ThemeData object for the current theme. This gives the button (and any other icons in this part of the widget tree) the accent color of the current theme.
  • A BuildContext object is a handle to the location of a widget in your app's widget tree. Each widget has its own BuildContext, which becomes the parent of the widget returned by the StatelessWidget.build or State.build function. This means that _buildTextComposer() can access the BuildContext object from its encapsulating State object. You don't need to pass the context to the method explicitly.

a3c16fc17be25f6c.pngHot reload the app. The Send button should now be blue:

Pixel 3XL

iPhone 11

Problems?

If your app is not running correctly, look for typos. If needed, use the code at the following link to get back on track.

6. Debug your app

There are a couple of ways to debug your app. You can either use your IDE directly to set breakpoints, or you can use Dart DevTools (not to be confused with Chrome DevTools). This codelab demonstrates how to set breakpoints using Android Studio and IntelliJ. If you are using another editor, like VS Code, use DevTools for debugging. For a gentle introduction to Dart DevTools, see Step 2.5 of Write your first Flutter app on the web.

The Android Studio and IntelliJ IDEs enable you to debug Flutter apps running on an emulator, a simulator, or a device. With these editors, you can:

  • Select a device or simulator to debug your app on.
  • View the console messages.
  • Set breakpoints in your code.
  • Examine variables and evaluate expressions at runtime.

The Android Studio and IntelliJ editors show the system log while your app is running, and provides a Debugger UI to work with breakpoints and control the execution flow.

ac5fb63d32c416e4.png

Work with breakpoints

a3c16fc17be25f6c.pngDebug your Flutter app using breakpoints:

  1. Open the source file in which you want to set a breakpoint.
  2. Locate the line where you want to set a breakpoint, click it, and then select Run > Toggle Line Breakpoint. Alternatively, you can click in the gutter (to the right of the line number) to toggle a breakpoint.
  3. If you weren't running in debug mode, stop the app.
  4. Restart the app using Run > Debug, or by clicking the Run debug button in the UI.

The editor launches the Debugger UI and pauses the execution of your app when it reaches the breakpoint. You can then use the controls in the Debugger UI to identify the cause of the error.

Practice using the debugger by setting breakpoints on the build() methods in your FriendlyChat app, and then run and debug the app. You can inspect the stack frames to see the history of method calls by your app.

7. Add a UI for displaying messages

With the basic app scaffolding and screen in place, now you're ready to define the area where chat messages are displayed.

7c904b221e30907c.png

Implement a chat message list

In this section, you create a widget that displays chat messages using composition (creating and combining multiple smaller widgets). You start with a widget that represents a single chat message. Then, you nest that widget in a parent scrollable list. Finally, you nest the scrollable list in the basic app scaffold.

a3c16fc17be25f6c.pngAdd the ChatMessage stateless widget:

  1. Position the cursor after the FriendlyChatApp class and start to type stless. (The order of the classes doesn't matter, but this order makes it easier to compare your code to the solution.)
  2. Enter ChatMessage for the class name.

a3c16fc17be25f6c.pngAdd a Row to the build() method for ChatMessage:

  1. Position the cursor inside the parentheses in return Container(), and press Return to start a new line.
  2. Add a margin property:
margin: const EdgeInsets.symmetric(vertical: 10.0),
  1. The Container's child will be a Row. The Row's list contains two widgets: an avatar and a column of text.
return Container(
  margin: const EdgeInsets.symmetric(vertical: 10.0),
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Container(
        margin: const EdgeInsets.only(right: 16.0),
        child: CircleAvatar(child: Text(_name[0])),
      ),
      Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(_name, style: Theme.of(context).textTheme.headline4),
          Container(
            margin: const EdgeInsets.only(top: 5.0),
            child: Text(text),
          ),
        ],
      ),
    ],
  ),
);
  1. Add a text variable and a constructor to the top of ChatMessage:
class ChatMessage extends StatelessWidget {
  const ChatMessage({
    required this.text,              // NEW
    Key? key,
  }) : super(key: key);
  final String text;                 // NEW

At this point, the analyzer should only complain about _name being undefined. You fix that next.

a3c16fc17be25f6c.pngDefine the _name variable.

Define the _name variable as shown, replacing Your Name with your own name. You use this variable to label each chat message with the sender's name. In this codelab, you hard-code the value for simplicity, but most apps retrieve the sender's name through authentication. After the main() function, add the following line:

String _name = 'Your Name';

71dd22da186608e5.png Observations

  • The build() method for ChatMessage returns a Row that displays a simple graphical avatar to represent the user who sent the chat message, a Column widget containing the sender's name, and the text of the message.
  • The CircleAvatar is personalized by labeling it with the user's first initial by passing the first character of the _name variable's value to a child Text widget.
  • The crossAxisAlignment parameter specifies CrossAxisAlignment.start in the Row constructor to position the avatar and messages relative to their parent widgets. For the avatar, the parent is a Row widget whose main axis is horizontal, so CrossAxisAlignment.start gives it the highest position along the vertical axis. For messages, the parent is a Column widget whose main axis is vertical, so CrossAxisAlignment.start aligns the text at the furthest left position along the horizontal axis.
  • Next to the avatar, two Text widgets are vertically aligned to display the sender's name on top and the text of the message below.
  • Theme.of(context)provides the default Flutter ThemeData object for the app. In a later step, you override this default theme to style your app differently for Android and iOS.
  • The ThemeData's textTheme property gives you access to Material Design logical styles for text like headline4, so you can avoid hard-coding font sizes and other text attributes. In this example, the sender's name is styled to make it larger than the message text.

a3c16fc17be25f6c.pngHot reload the app.

Type messages into the text field. Press the Send button to clear the message. Type a long message into the text field to see what happens when the text field overflows. Later, in step 9, you wrap the column in an Expanded widget to make the Text widget wrap.

Implement a chat message list in the UI

The next refinement is to get the list of chat messages and show it in the UI. You want this list to be scrollable so that users can view the message history. The list should also present the messages in chronological order, with the most recent message displayed at the bottom-most row of the visible list.

a3c16fc17be25f6c.pngAdd a _messages list to _ChatScreenState.

In the _ChatScreenState definition, add a List member called _messages to represent each chat message:

class _ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = [];      // NEW  
  final _textController = TextEditingController();

a3c16fc17be25f6c.pngModify the _handleSubmitted() method in _ChatScreenState.

When the user sends a chat message from the text field, the app should add the new message to the message list. Modify the _handleSubmitted() method to implement this behavior:

void _handleSubmitted(String text) {
  _textController.clear();
  var message = ChatMessage(      // NEW
    text: text,                   // NEW
  );                              // NEW
  setState(() {                   // NEW
    _messages.insert(0, message); // NEW
  });                             // NEW
}

a3c16fc17be25f6c.pngPut the focus back on the text field after content submission.

  1. Add a FocusNode to _ChatScreenState:
class _ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = [];
  final _textController = TextEditingController();
  final FocusNode _focusNode = FocusNode();    // NEW
  1. Add the focusNode property to the TextField in _buildTextComposer():
child: TextField(
  controller: _textController,
  onSubmitted: _handleSubmitted,
  decoration:
      const InputDecoration.collapsed(hintText: 'Send a message'),
  focusNode: _focusNode,  // NEW
),
  1. In _handleSubmitted(), after the call to setState(), request focus on the TextField:
setState(() {
  _messages.insert(0, message);
});
_focusNode.requestFocus();  // NEW

71dd22da186608e5.png Observations

  • Each item in the list is a ChatMessage instance.
  • The list is initialized to be empty.
  • Calling setState() to modify _messages lets the framework know that this part of the widget tree changed, and it needs to rebuild the UI. Only synchronous operations should be performed in setState() because otherwise the framework could rebuild the widgets before the operation finishes. When making multiple changes to the internal state of a State object, it is generally considered idiomatic to combine all of the changes into a single setState() call.
  • In general, it is possible to call setState() with an empty closure after some private data changed outside of this method call. However, updating data inside setState's closure is preferred, so you don't forget to call it afterward.

a3c16fc17be25f6c.pngHot reload the app.

Enter text into the text field and press Return. The text field once again has the focus.

Place the message list

You're now ready to display the list of chat messages. Get the ChatMessage widgets from the _messages list, and put them in a ListView widget, for a scrollable list.

a3c16fc17be25f6c.pngIn the build() method for _ChatScreenState, add a ListView inside a Column:

Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text ('FriendlyChat')),
    body: Column(                                            // MODIFIED
      children: [                                            // NEW
        Flexible(                                            // NEW
          child: ListView.builder(                           // NEW
            padding: const EdgeInsets.all(8.0),              // NEW
            reverse: true,                                   // NEW
            itemBuilder: (_, index) => _messages[index],     // NEW
            itemCount: _messages.length,                     // NEW
          ),                                                 // NEW
        ),                                                   // NEW
        const Divider(height: 1.0),                          // NEW
        Container(                                           // NEW
          decoration: BoxDecoration(
            color: Theme.of(context).cardColor),             // NEW
          child: _buildTextComposer(),                       // MODIFIED
        ),                                                   // NEW
      ],                                                     // NEW
    ),                                                       // NEW
  );
}

71dd22da186608e5.png Observations

  • The ListView.builder factory method builds a list on demand by providing a function that is called once per item in the list. The function returns a new widget at each call. The builder also automatically detects mutations of its children parameter and initiates a rebuild.
  • The parameters passed to the ListView.builder constructor customize the list contents and appearance:
  • padding creates whitespace around the message text.
  • itemCount specifies the number of messages in the list.
  • itemBuilder provides the function that builds each widget in [index]. Because you don't need the current build context, you can ignore the first argument of IndexedWidgetBuilder. Naming the argument with an underscore (_) and nothing else is a convention indicating that the argument won't be used.
  • The body property of the Scaffold widget now contains the list of incoming messages as well as the input field and Send button. The layout uses the following widgets:
  • Column: Lays out its direct children vertically. The Column widget takes a list of child widgets (same as a Row) that becomes a scrolling list and a row for an input field.
  • Flexible, as a parent of ListView: Tells the framework to let the list of received messages expand to fill the Column height while TextField remains a fixed size.
  • Divider: Draws a horizontal line between the UI for displaying messages and the text input field for composing messages.
  • Container, as a parent of the text composer: Defines background images, padding, margins, and other common layout details.
  • decoration: Creates a new BoxDecoration object that defines the background color. In this case you're using the cardColor defined by the ThemeData object of the default theme. This gives the UI for composing messages a different background from the messages list.

a3c16fc17be25f6c.pngHot reload the app. You should see a screen that looks as follows:

Pixel 3XL

iPhone 11

a3c16fc17be25f6c.pngTry sending a few chat messages using the UIs for composing and displaying that you just built!

Pixel 3XL

iPhone 11

Problems?

If your app is not running correctly, look for typos. If needed, use the code at the following link to get back on track.

8. Animate your app

You can add animation to your widgets to make the user experience of your app more fluid and intuitive. In this section, you learn how to add a basic animation effect to your chat message list.

When the user sends a new chat message, instead of simply displaying it in the message list, you animate the message to vertically ease up from the bottom of the screen.

Animations in Flutter are encapsulated as Animation objects that contain a typed value and a status (such as forward, reverse, completed, and dismissed). You can attach an animation object to a widget or listen for changes to the animation object. Based on changes to the animation object's properties, the framework can modify the way your widget appears and rebuild the widget tree.

Specify an animation controller

Use the AnimationController class to specify how the animation should run. The AnimationController lets you define important characteristics of the animation, such as its duration and playback direction (forward or reverse).

a3c16fc17be25f6c.pngUpdate the _ChatScreenState class definition to include a TickerProviderStateMixin:

class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {   // MODIFIED
  final List<ChatMessage> _messages = [];
  final _textController = TextEditingController();
  final FocusNode _focusNode = FocusNode();
  ...

a3c16fc17be25f6c.pngIn the ChatMessage class definition, add a variable to store the animation controller:

class ChatMessage extends StatelessWidget {
  const ChatMessage({
    required this.text,
    required this.animationController,                // NEW
    Key? key,
  }) : super(key: key);
  final String text;
  final AnimationController animationController;      // NEW
  ...

a3c16fc17be25f6c.pngAdd an animation controller to the _handleSubmitted() method:

void _handleSubmitted(String text) {
  _textController.clear();
  var message = ChatMessage(
    text: text,
    animationController: AnimationController(      // NEW
      duration: const Duration(milliseconds: 700), // NEW
      vsync: this,                                 // NEW
    ),                                             // NEW
  );                                               // NEW
  setState(() {
    _messages.insert(0, message);
  });
  _focusNode.requestFocus();
  message.animationController.forward();           // NEW
}

71dd22da186608e5.png Observations

  • The AnimationController specifies the animation's runtime duration to be 700 milliseconds. (This longer duration slows the animation effect so you can see the transition happen more gradually. In practice, you probably want to set a shorter duration when running your app.)
  • The animation controller is attached to a new ChatMessage instance, and specifies that the animation should play forward whenever a message is added to the chat list.
  • When creating an AnimationController, you must pass it a vsync argument. The vsync is the source of heartbeats (the Ticker) that drives the animation forward. This example uses _ChatScreenState as the vsync, so it adds a TickerProviderStateMixin mixin to the _ChatScreenState class definition.
  • In Dart, a mixin allows a class body to be reused in multiple class hierarchies. For more information, see Adding features to a class: mixins, a section in the Dart Language Tour.

Add a SizeTransition widget

Adding a SizeTransition widget to the animation has the effect of animating a ClipRect that increasingly exposes the text as it slides in.

a3c16fc17be25f6c.pngAdd a SizeTransition widget to the build() method for ChatMessage:

  1. In the build() method for ChatMessage, select the first Container instance.
  2. Press Option+Return (macOS) or Alt+Enter (Linux and Windows) to bring up a menu, and select Wrap with widget.
  3. Enter SizeTransition. A red box appears around the child: property. This indicates that a required property is missing from the widget class. Hover over SizeTransition, and a tooltip points out that sizeFactor is required and offers to create it. Choose that option, and the property appears with a null value.
  4. Replace null with an instance CurvedAnimation. This adds the boilerplate code for two properties: parent (required) and curve.
  5. For the parent property, replace null with the animationController.
  6. For the curve property, replace null with Curves.easeOut, one of the constants from the Curves class.
  7. Add a line after sizeFactor (but at the same level), and enter an axisAlignment property to the SizeTransition, with a value of 0.0.
@override
Widget build(BuildContext context) {
  return SizeTransition(             // NEW
    sizeFactor:                      // NEW
        CurvedAnimation(parent: animationController, curve: Curves.easeOut),  // NEW
    axisAlignment: 0.0,              // NEW
    child: Container(                // MODIFIED
    ...

71dd22da186608e5.png Observations

  • The CurvedAnimation object, in conjunction with the SizeTransition class, produces an ease-out animation effect. The ease-out effect causes the message to slide up quickly at the beginning of the animation and slow down until it comes to a stop.
  • The SizeTransition widget behaves as an animating ClipRect that exposes more of the text as it slides in.

Dispose of the animation

It's good practice to dispose of your animation controllers to free up your resources when they are no longer needed.

a3c16fc17be25f6c.pngAdd the dispose() method to _ChatScreenState.

Add the following method to the bottom of _ChatScreenState:

@override
void dispose() {
  for (var message in _messages){
    message.animationController.dispose();
  }
  super.dispose();
}

a3c16fc17be25f6c.pngThe code is now correct, but is not well formatted. Right-click in the code pane, and select Reformat Code with dartfmt.

a3c16fc17be25f6c.pngHot reload the app (or hot restart, if the running app contains chat messages), and enter a few messages to observe the animation effect.

If you want to experiment further with animations, then here are a few ideas to try:

  • Speed up or slow down the animation by modifying the duration value specified in the _handleSubmitted() method.
  • Specify different animation curves by using the constants defined in the Curves class.
  • Create a fade-in animation effect by wrapping the Container in a FadeTransition widget instead of a SizeTransition.

Problems?

If your app is not running correctly, look for typos. If needed, use the code at the following link to get back on track.

9. Apply finishing touches

In this optional step, you give your app a few sophisticated details, like making the Send button enabled only when there's text to send, wrapping longer messages, and adding native-looking customizations for Android and for iOS.

Make the send button context-aware

Currently, the Send button appears enabled, even when there is no text in the input field. You might want the button's appearance to change depending on whether the field contains text to send.

a3c16fc17be25f6c.pngDefine _isComposing, a private variable that is true whenever the user types in the input field:

class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
  final List<ChatMessage> _messages = [];
  final _textController = TextEditingController();
  final FocusNode _focusNode = FocusNode();
  bool _isComposing = false;            // NEW

a3c16fc17be25f6c.pngAdd an onChanged() callback method to _ChatScreenState.

In the _buildTextComposer() method, add the onChanged property to the TextField, and update the onSubmitted property:

Flexible(
  child: TextField(
    controller: _textController,
    onChanged: (text) {                   // NEW
      setState(() {                       // NEW
        _isComposing = text.isNotEmpty;   // NEW
      });                                 // NEW
    },                                    // NEW
    onSubmitted: _isComposing ? _handleSubmitted : null, // MODIFIED
    decoration:
        InputDecoration.collapsed(hintText: 'Send a message'),
    focusNode: _focusNode,
  ),
),

a3c16fc17be25f6c.pngUpdate the onPressed() callback method in _ChatScreenState.

While still in the _buildTextComposer() method, update the onPressed property for the IconButton:

Container(
  margin: EdgeInsets.symmetric(horizontal: 4.0),
  child: IconButton(
      icon: const Icon(Icons.send),
      onPressed: _isComposing                            // MODIFIED
          ? () => _handleSubmitted(_textController.text) // MODIFIED
          : null,                                        // MODIFIED
      )
      ...
)

a3c16fc17be25f6c.pngModify _handleSubmitted to set _isComposing to false when the text field is cleared:

void _handleSubmitted(String text) {
  _textController.clear();
  setState(() {                             // NEW
    _isComposing = false;                   // NEW
  });                                       // NEW

  ChatMessage message = ChatMessage(
  ...

71dd22da186608e5.png Observations

  • The onChanged callback notifies the TextField that the user edited its text. TextField calls this method whenever its value changes from the current value of the field.
  • The onChanged callback calls setState() to change the value of _isComposing to true when the field contains some text.
  • When _isComposing is false, the onPressed property is set to null.
  • The onSubmitted property was also modified so that it won't add an empty string to the message list.
  • The _isComposing variable now controls the behavior and the visual appearance of the Send button.
  • If the user types a string in the text field, then _isComposing is true. When the user presses the Send button, the framework invokes _handleSubmitted().
  • If the user types nothing in the text field, then _isComposing is false, and the widget's onPressed property is set to null, disabling the Send button. The framework automatically changes the button's color to Theme.of(context).disabledColor.

a3c16fc17be25f6c.pngHot reload your app to try it out!

Wrap long lines

When a user sends a chat message that exceeds the width of the UI for displaying messages, the lines should wrap so the entire message displays. Right now, lines that overflow are truncated, and a visual overflow error displays. A simple way of making sure that the text wraps correctly is to put it inside of an Expanded widget.

a3c16fc17be25f6c.pngWrap the Column widget with an Expanded widget:

  1. In the build() method for ChatMessage, select the Column widget inside the Row for the Container.
  2. Press Option+Return (macOS) or Alt+Enter (Linux and Windows) to bring up a menu.
  3. Start typing Expanded, and select Expanded from the list of possible objects.

The following code sample shows how the ChatMessage class looks after making this change:

...
Container(
  margin: const EdgeInsets.only(right: 16.0),
  child: CircleAvatar(child: Text(_name[0])),
),
Expanded(            // NEW
  child: Column(     // MODIFIED
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(_name, style: Theme.of(context).textTheme.headline4),
      Container(
        margin: EdgeInsets.only(top: 5.0),
        child: Text(text),
      ),
    ],
  ),
),                    // NEW
...

71dd22da186608e5.png Observations

The Expanded widget allows its child widget (like Column) to impose layout constraints (in this case the Column's width) on a child widget. Here, it constrains the width of the Text widget, which is normally determined by its contents.

Customize for Android and iOS

To give your app's UI a natural look and feel, you can add a theme and some simple logic to the build() method for the FriendlyChatApp class. In this step, you define a platform theme that applies a different set of primary and accent colors. You also customize the Send button to use a Material Design IconButton on Android and a CupertinoButton on iOS.

a3c16fc17be25f6c.pngAdd the following code to main.dart, after the main() method:

final ThemeData kIOSTheme = ThemeData(
  primarySwatch: Colors.orange,
  primaryColor: Colors.grey[100],
  primaryColorBrightness: Brightness.light,
);

final ThemeData kDefaultTheme = ThemeData(
  colorScheme: ColorScheme.fromSwatch(primarySwatch: Colors.purple)
      .copyWith(secondary: Colors.orangeAccent[400]),
);

71dd22da186608e5.png Observations

  • The kDefaultTheme ThemeData object specifies colors for Android (purple with orange accents).
  • The kIOSTheme ThemeData object specifies colors for iOS (light grey with orange accents).

a3c16fc17be25f6c.pngModify the FriendlyChatApp class to vary the theme using the theme property of your app's MaterialApp widget:

  1. Import the foundation package at the top of the file:
import 'package:flutter/foundation.dart';  // NEW
import 'package:flutter/material.dart';
  1. Modify the FriendlyChatApp class to choose an appropriate theme:
class FriendlyChatApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(                                  // MODIFIED
      title: 'FriendlyChat',
      theme: defaultTargetPlatform == TargetPlatform.iOS // NEW
        ? kIOSTheme                                      // NEW
        : kDefaultTheme,                                 // NEW
      home: const ChatScreen(),                          // MODIFIED
    );
  }
}

a3c16fc17be25f6c.pngModify the theme of the AppBar widget (the banner at the top of your app's UI).

  1. In the build() method of _ChatScreenState, find the following line of code:
      appBar: AppBar(title: Text('FriendlyChat')),
  1. Place the cursor between the two right parentheses ())), type a comma, and press Return to start a new line.
  2. Add the following two lines:
elevation:
   Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : 4.0, // NEW
  1. Right-click in the code pane, and select Reformat code with dartfmt.

71dd22da186608e5.png Observations

  • The top-level defaultTargetPlatform property and conditional operators are used to select the theme.
  • The elevation property defines the z-coordinates of the AppBar. A z-coordinate value of 4.0 has a defined shadow (Android), and a value of 0.0 has no shadow (iOS). .

a3c16fc17be25f6c.pngCustomize the send icon for Android and for iOS.

  1. Add the following import to the top of main.dart:
import 'package:flutter/cupertino.dart';   // NEW
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
  1. In _ChatScreenState's _buildTextComposer()method, modify the line that assigns an IconButton as the child of the Container. Change the assignment to be conditional on the platform. For iOS, use a CupertinoButton; otherwise, stay with an IconButton:
Container(
   margin: EdgeInsets.symmetric(horizontal: 4.0),
   child: Theme.of(context).platform == TargetPlatform.iOS ? // MODIFIED
   CupertinoButton(                                          // NEW
     child: const Text('Send'),                              // NEW
     onPressed: _isComposing                                 // NEW
         ? () =>  _handleSubmitted(_textController.text)     // NEW
         : null,) :                                          // NEW
   IconButton(                                               // MODIFIED
       icon: const Icon(Icons.send),
       onPressed: _isComposing ?
           () =>  _handleSubmitted(_textController.text) : null,
       )
   ),

a3c16fc17be25f6c.pngWrap the top-level Column in a Container widget, and give it a light grey border on its upper edge.

This border helps visually distinguish the app bar from the body of the app on iOS. To hide the border on Android, apply the same logic used for the app bar in the previous code sample:

  1. In _ChatScreenState's build() method, select the Column that appears after body:.
  2. Press Option+Return (macOS) or Alt+Enter (Linux and Windows) to bring up a menu, and select Wrap with Container.
  3. After the end of that Column, but before the end of the Container, add the code (shown) that conditionally adds the appropriate button depending on the platform.
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text('FriendlyChat'),
      elevation:
          Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : 4.0,
    ),
    body: Container(
        child: Column(
          children: [
            Flexible(
              child: ListView.builder(
                padding: EdgeInsets.all(8.0),
                reverse: true,
                itemBuilder: (_, int index) => _messages[index],
                itemCount: _messages.length,
              ),
            ),
            Divider(height: 1.0),
            Container(
              decoration: BoxDecoration(color: Theme.of(context).cardColor),
              child: _buildTextComposer(),
            ),
          ],
        ),
        decoration: Theme.of(context).platform == TargetPlatform.iOS // NEW
            ? BoxDecoration(                                 // NEW
                border: Border(                              // NEW
                  top: BorderSide(color: Colors.grey[200]!), // NEW
                ),                                           // NEW
              )                                              // NEW
            : null,                                          // NEW
    ),
  );
}

a3c16fc17be25f6c.pngHot reload the app. You should see different colors, shadows, and icon buttons for Android and for iOS.

Pixel 3XL

iPhone 11

Problems?

If your app is not running correctly, look for typos. If needed, use the code at the following link to get back on track.

10. Next steps

Congratulations!

You now know the basics of building cross-platform mobile apps with the Flutter framework.

What we covered

  • How to build a Flutter app from the ground up
  • How to use some of the shortcuts provided in Android Studio and IntelliJ
  • How to run, hot reload, and debug your Flutter app on an emulator, a simulator, and a device
  • How to customize your user interface with widgets and animations
  • How to customize your user interface for Android and iOS

What's next

Try one of the other Flutter codelabs. Interested in learning more about building apps with Material components? Learn more by building a shopping app in the 5-part Material Components (MDC) codelab series starting with MDC-101 Flutter: Material Components (MDC) Basics.

Continue learning about Flutter:

For more information about keyboard shortcuts:

11. Optional: Get the sample code

You might want to download the sample code to view the samples as reference or start the codelab at a specific section. To get a copy of the sample code for the codelab, run this command from your terminal:

 git clone https://github.com/flutter/codelabs

The sample code for this codelab is in the friendly_chat folder. Each numbered step folder lines up with how the code looks at the end of the numbered steps of this codelab. You can also drop the code from the lib/main.dart file from any of these steps into a DartPad instance and run them from there.