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.
Create a simple templated Flutter app. You modify this starter app to create the finished app.
Launch Android Studio.
FriendlyChat
as the project name, and click Next.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.
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:
Try the finished app on DartPad!
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.
Replace 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: Text('FriendlyChat'),
),
),
),
);
}
Observations
main()
function.main()
and runApp()
function definitions are the same as in the automatically generated app.runApp()
function takes as its argument a Widget
, which the Flutter framework expands and displays to the screen at run time.MaterialApp
object is created and passed to the runApp()
function. The MaterialApp
widget becomes the root of your app's widget tree.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.Run the app by clicking the Run icon
in the editor . The first time you run an app, it can take a while. The app is faster in later steps.
You should see something like the following:
Pixel 3XL | iPhone 11 |
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.
Create the
FriendlyChatApp
widget:
main()
, place the cursor in front of the M
in MaterialApp
.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.home:
. Start with Scaffold(
and end with the Scaffold's closing parenthesis, )
. Do not include the ending comma. 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.)Create a stateless widget,
ChatScreen
:
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.ChatScreen
.Update the
ChatScreen
widget:
ChatScreen
widget, select Container, and start typing Scaffold
. Select Scaffold from the popup.appBar,
and select appBar: from the popup.appBar:
, start typing AppBar,
and select the AppBar class from the popup.title,
and select title: from the popup.title:
, start typing Text,
and select the Text class.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 passed analysis. Congratulations!
Observations
This step introduces several key concepts of the Flutter framework:
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.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. main()
method enables hot reload becausehot reload doesn't rerun main()
.Click the hot reload
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.
In this section, you learn how to build a user control that enables the user to enter and send chat messages.
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.
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.
Change the
ChatScreen
class to be stateful:
ChatScreen
in the line class ChatScreen extends StatelessWidget
.Option
+
Return
(macOS) or Alt
+
Enter
(Linux and Windows) to bring up a menu._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.
Add 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.
Add a
_buildTextComposer
function to _ChatScreenState
:
Widget _buildTextComposer() {
return Container(
margin: EdgeInsets.symmetric(horizontal: 8.0),
child: TextField(
controller: _textController,
onSubmitted: _handleSubmitted,
decoration: InputDecoration.collapsed(
hintText: 'Send a message'),
),
);
}
Observations
State
object. The State
object is then associated with a widget that extends the StatefulWidget
class. _buildTextComposer()
that returns a Container
widget with a configured TextField
widget.Container
widget adds a horizontal margin between the edge of the screen and each side of the input field. 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).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.TextField
with the TextEditingController
gives you control over the text field. This controller will clear the field and read its value.Add the
_handleSubmitted
function to _ChatScreenState
for clearing the text controller:
void _handleSubmitted(String text) {
_textController.clear();
}
Update 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
);
}
Observations
_buildTextComposer
method returns a widget that encapsulates the text input field._buildTextComposer
to the body
property causes the app to display the text input user control.Hot reload the app. You should see a screen that looks like the following:
Pixel 3XL | iPhone 11 |
Next, you add a Send button to the right of the text field. This involves adding a bit more structure to the layout.
In the
_buildTextComposer
function, wrap the TextField
inside a Row:
TextField
in _buildTextComposer
.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.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.child
and a popup appears. In the popup, it asks if you want to change the property to children
. Select that option.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 bracket. 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.Wrap the
TextField
inside a Flexible
:
Row
.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.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: EdgeInsets.symmetric(horizontal: 8.0),
child: Row( // NEW
children: [ // NEW
Flexible( // NEW
child: TextField(
controller: _textController,
onSubmitted: _handleSubmitted,
decoration: InputDecoration.collapsed(
hintText: 'Send a message'),
),
), // NEW
], // NEW
), // NEW
);
}
Observations
Row
allows you to place the Send button adjacent to the input field.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.Next, you add a Send button. This is a Material app, so use the corresponding Material icon :
Add the Send button to the
Row
.
The Send button becomes the second item in the Row
's list.
Flexible
widget's closing right bracket and comma, and press Return to start a new line.Container,
and select Container from the popup. The cursor is positioned inside the container's parentheses. Press Return to start a new line.margin: EdgeInsets.symmetric(horizontal: 4.0),
child: IconButton(
icon: const Icon(Icons.send),
onPressed: () => _handleSubmitted(_textController.text)),
Observations
IconButton
displays the Send button.icon
property specifies the Icons.send
constant from the Material library to create a new Icon
instance. 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.onPressed
property uses an anonymous function to invoke the _handleSubmitted()
method and passes the contents of the message using the _textController
. => 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.Hot 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.
In
_buildTextComposer()
, wrap the Container
in an IconThem:
.
Container
at the top of the _buildTextComposer()
function.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.Row,
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.data
property:return IconTheme(
data: IconThemeData(color: Theme.of(context).accentColor), // NEW
child: Container(
Observations
IconTheme
widget, which uses an IconThemeData
object to define these characteristics.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.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.Hot 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.
You've found something special!
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:
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.
Debug your Flutter app using breakpoints:
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.
With the basic app scaffolding and screen in place, now you're ready to define the area where chat messages are displayed.
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.
Add the
ChatMessage
stateless widget:
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.)ChatMessage
for the class name.Add a
Row
to the build()
method for ChatMessage
:
return Container()
, and press Return to start a new line.margin
property:margin: EdgeInsets.symmetric(vertical: 10.0),
Container'
s child will be a Row
. The Row
's list contains two widgets: an avatar and a column of text.return Container(
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: EdgeInsets.only(top: 5.0),
child: Text(text),
),
],
),
],
),
);
text
variable and a constructor to the top of ChatMessage
:class ChatMessage extends StatelessWidget {
ChatMessage({this.text}); // NEW
final String text; // NEW
At this point, the analyzer should only complain about _name
being undefined. You fix that next.
Define 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';
Observations
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.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.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.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.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.Hot 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.
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.
Add 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();
Modify 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();
ChatMessage message = ChatMessage( //NEW
text: text, //NEW
); //NEW
setState(() { //NEW
_messages.insert(0, message); //NEW
}); //NEW
}
Put the focus back on the text field after content submission.
FocusNode
to ChatScreenState
:class _ChatScreenState extends State<ChatScreen> {
final List<ChatMessage> _messages = [];
final _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); // NEW
focusNode
property to the TextField
in _buildTextComposer()
:child: TextField(
controller: _textController,
onSubmitted: _handleSubmitted,
decoration: InputDecoration.collapsed(hintText: 'Send a message'),
focusNode: _focusNode, // NEW
),
_handleSubmitted()
, after the call to setState()
, request focus on the TextField
: setState(() {
_messages.insert(0, message);
});
_focusNode.requestFocus(); // NEW
Observations
ChatMessage
instance.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.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.Hot reload the app.
Enter text into the text field and press Return
. The text field once again has the focus.
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.
In the
build()
method for ChatScreenState
, add a ListView
inside a Column
:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text ('FriendlyChat')),
body: Column( // MODIFIED
children: [ // NEW
Flexible( // NEW
child: ListView.builder( // NEW
padding: EdgeInsets.all(8.0), // NEW
reverse: true, // NEW
itemBuilder: (_, int index) => _messages[index], // NEW
itemCount: _messages.length, // NEW
), // NEW
), // NEW
Divider(height: 1.0), // NEW
Container( // NEW
decoration: BoxDecoration(
color: Theme.of(context).cardColor), // NEW
child: _buildTextComposer(), //MODIFIED
), // NEW
], // NEW
), // NEW
);
}
Observations
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.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. 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.Hot reload the app. You should see a screen that looks as follows:
Pixel 3XL | iPhone 11 |
Try 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.
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.
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).
Update 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();
...
In the
ChatMessage
class definition, add a variable to store the animation controller:
class ChatMessage extends StatelessWidget {
ChatMessage({this.text, this.animationController}); // MODIFIED
final String text;
final AnimationController animationController; // NEW
...
Add an animation controller to the
_handleSubmitted()
method:
void _handleSubmitted(String text) {
_textController.clear();
ChatMessage 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
}
Observations
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.) ChatMessage
instance, and specifies that the animation should play forward whenever a message is added to the chat list.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.Adding a SizeTransition
widget to the animation has the effect of animating a ClipRect
that increasingly exposes the text as it slides in.
Add a
SizeTransition
widget to the build()
method for ChatMessage
:
build()
method for ChatMessage
, select the first Container
instance.Option
+
Return
(macOS) or Alt
+
Enter
(Linux and Windows) to bring up a menu, and select Wrap with widget.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.null
with an instance CurvedAnimation
. This adds the boilerplate code for two properties: parent
(required) and curve
.parent
property, replace null
with the animationController
.curve
property, replace null
with Curves.easeOut
, one of the constants from the Curves
class.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
...
Observations
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.SizeTransition
widget behaves as an animating ClipRect
that exposes more of the text as it slides in.It's good practice to dispose of your animation controllers to free up your resources when they are no longer needed.
Add the
dispose()
method to ChatScreenState.
Add the following method to the bottom of ChatScreenstate
:
@override
void dispose() {
for (ChatMessage message in _messages)
message.animationController.dispose();
super.dispose();
}
The code is now correct, but is not well formatted. Right-click in the code pane, and select Reformat Code with dartfmt.
Hot 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:
duration
value specified in the _handleSubmitted()
method. Curves
class. 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.
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.
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.
Define
_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();
bool _isComposing = false; // NEW
Add 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: (String text) { // NEW
setState(() { // NEW
_isComposing = text.length > 0; // NEW
}); // NEW
}, // NEW
onSubmitted: _isComposing ? _handleSubmitted : null, // MODIFIED
decoration:
InputDecoration.collapsed(hintText: 'Send a message'),
focusNode: _focusNode,
),
),
Update 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
)
...
)
Modify
_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(
...
Observations
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. onChanged
callback calls setState()
to change the value of _isComposing
to true when the field contains some text._isComposing
is false, the onPressed
property is set to null
. onSubmitted
property was also modified so that it won't add an empty string to the message list._isComposing
variable now controls the behavior and the visual appearance of the Send button. _isComposing
is true,
and the button's color is set to Theme.of(context).accentColor
. When the user presses the Send button, the framework invokes _handleSubmitted()
._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
.Hot reload your app to try it out!
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.
Wrap the
Column
widget with an Expanded
widget:
build()
method for ChatMessage
, select the Column
widget inside the Row
for the Container
. Option
+
Return
(macOS) or Alt
+
Enter
(Linux and Windows) to bring up a menu.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
...
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.
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.
Add 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(
primarySwatch: Colors.purple,
accentColor: Colors.orangeAccent[400],
);
Observations
kDefaultTheme ThemeData
object specifies colors for Android (purple with orange accents).kIOSTheme
ThemeData
object specifies colors for iOS (light grey with orange accents).Modify the
FriendlyChatApp
class to vary the theme using the theme
property of your app's MaterialApp
widget:
import 'package:flutter/foundation.dart'; // NEW
import 'package:flutter/material.dart';
FriendlyChatApp
class to choose an appropriate theme:class FriendlyChatApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'FriendlyChat',
theme: defaultTargetPlatform == TargetPlatform.iOS // NEW
? kIOSTheme // NEW
: kDefaultTheme, // NEW
home: ChatScreen(),
);
}
}
Modify the theme of the
AppBar
widget (the banner at the top of your app's UI).
build()
method of ChatScreenState
, find the following line of code: appBar: AppBar(title: Text('FriendlyChat')),
))
), type a comma, and press Return to start a new line.elevation:
Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : 4.0, // NEW
Observations
defaultTargetPlatform
property and conditional operators are used to select the theme.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). .Customize the send icon for Android and for iOS.
main.dart
:import 'package:flutter/cupertino.dart'; // NEW
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
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: 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,
)
),
Wrap 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:
_ChatScreenState
's build()
method, select the Column
that appears after body:
.Option
+
Return
(macOS) or Alt
+
Enter
(Linux and Windows) to bring up a menu, and select Wrap with Container.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, // NEW
),
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), // MODIFIED
);
}
Hot 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.
Congratulations!
You now know the basics of building cross-platform mobile apps with the Flutter framework.
Try one of the other Flutter codelabs.
Continue learning about Flutter:
For more information about keyboard shortcuts:
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/friendlychat-steps.git
The sample code is in the steps
folder. Only the modified files are included, so the pubspec.yaml
file in step-0 is used for all the following steps. You can also drop the code from the main.dart
file from any of these steps into a DartPad instance and run them from there.