9.1 The Concept of Widget
From the previous introduction, we know that in Flutter, almost all objects are widgets. Unlike the concept of "controls" in native development, widgets in Flutter have a broader meaning. They can represent not only UI elements but also functional components, such as GestureDetector
for gesture detection and Theme
for passing app theme data. In contrast, controls in native development usually refer only to UI elements. In the following content, when we refer to UI elements, we might use terms like "controls" or "components," and readers should understand that these are all widgets—just different expressions in different contexts. Since Flutter is primarily used for building user interfaces, readers can generally consider a widget as a control without getting bogged down in terminology.
In Flutter, the UI and event handling are constructed by nesting widgets, so remember: everything in Flutter is a widget.
9.2 Widget Interface
In Flutter, the function of a widget is to "describe the configuration information of a UI element." This means that a widget does not represent the final display element drawn on the device's screen. The so-called configuration information refers to the parameters received by the widget. For example, for a Text
widget, the content of the text, alignment, and text style are all part of its configuration information. Let's first look at the declaration of the Widget
class:
@immutable // Immutable abstract class Widget extends DiagnosticableTree { const Widget({ this.key }); final Key? key; @protected @factory Element createElement(); @override String toStringShort() { final String type = objectRuntimeType(this, 'Widget'); return key == null ? type : '$type-$key'; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.dense; } @override @nonVirtual bool operator ==(Object other) => super == other; @override @nonVirtual int get hashCode => super.hashCode; static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key; } ... }
The @immutable
annotation indicates that the widget is immutable, which means that the properties (i.e., configuration information) defined in the widget must be immutable (final). Why are property changes not allowed in a widget? Because if properties change in Flutter, it will rebuild the widget tree, i.e., create a new widget instance to replace the old one. Therefore, allowing property changes in a widget is meaningless because once the widget's properties change, it will be replaced. This is why properties defined in the widget must be final
.
The widget
class inherits from DiagnosticableTree
, which serves as a "diagnostic tree" primarily to provide debugging information.
Key: This
key
property is similar to thekey
in React/Vue, and its main role is to determine whether to reuse the old widget during the next build. The conditions for this are defined in thecanUpdate()
method.createElement(): As mentioned earlier, "a widget can correspond to multiple Elements." When building the UI tree, the Flutter framework first calls this method to generate the corresponding
Element
object for the node. This method is called implicitly by the Flutter framework and is generally not called directly during development.debugFillProperties(...): This overrides the parent class method and is mainly used to set some features of the diagnostic tree.
canUpdate(...): This is a static method primarily used to reuse the old widget when the widget tree is rebuilt. Specifically, it checks whether to update the configuration of the corresponding
Element
object on the old UI tree with the new widget object. As seen in its source code, it will reuse the new widget to update theElement
object’s configuration if theruntimeType
andkey
ofnewWidget
andoldWidget
are equal; otherwise, it will create a newElement
.
Details regarding Key
and widget reuse will be discussed in more advanced sections later in the book. For now, readers should know that explicitly adding a key to a widget might (but not necessarily) make the UI more efficient during reconstruction. This parameter can be ignored for now, as it will be explained in detail later in the book.
Additionally, the Widget
class itself is an abstract class, with the most crucial part being the definition of the createElement()
interface. In Flutter development, we generally do not directly inherit the Widget
class to implement a new component. Instead, we typically inherit from StatelessWidget
or StatefulWidget
to indirectly inherit from the Widget
class. Both StatelessWidget
and StatefulWidget
are direct subclasses of Widget
, and these two classes are very important abstract classes in Flutter. They introduce two widget models, which we will focus on introducing next.
9.3 The Four Trees in Flutter
Since a widget merely describes the configuration information of a UI element, who is responsible for the actual layout and rendering? The processing flow of the Flutter framework is as follows:
Generate an
Element
tree from theWidget
tree, with nodes in theElement
tree inheriting from theElement
class.Generate a
Render
tree (rendering tree) from theElement
tree, with nodes in the rendering tree inheriting from theRenderObject
class.Generate a
Layer
tree from the rendering tree, which is then displayed on the screen, with nodes in the Layer tree inheriting from theLayer
class.
The actual layout and rendering logic occur in the Render
tree. The Element
acts as the glue between Widget
and RenderObject
, functioning as an intermediary proxy. Let’s illustrate this with an example, assuming we have the following widget tree:
Container( // A container widget color: Colors.blue, // Set the background color of the container child: Row( // Align child widgets horizontally children: [ Image.network('https://www.example.com/1.png'), // Widget to display an image const Text('A'), ], ), );
Note that if the Container
sets a background color, it will create a new ColoredBox
to fill the background, with the relevant logic as follows:
if (color != null) current = ColoredBox(color: color!, child: current);
The Image
widget will render the image via RawImage
, while the Text
widget will render the text using RichText
. Thus, the final structure of the widget tree, element tree, and rendering tree is shown in Figure:
Here, it is important to note:
In the three trees,
Widget
andElement
correspond one-to-one, but they do not correspond one-to-one withRenderObject
. For example, bothStatelessWidget
andStatefulWidget
do not have correspondingRenderObject
.Before displaying on the screen, the rendering tree will generate a
Layer
tree, which will be discussed in detail in later chapters. For now, readers should just remember the three trees mentioned above.
9.4 StatelessWidget
1. Introduction
In the previous chapters, we briefly introduced StatelessWidget
. StatelessWidget
is relatively straightforward; it inherits from the Widget
class and overrides the createElement()
method:
@override StatelessElement createElement() => StatelessElement(this);
StatelessElement
indirectly inherits from the Element
class and corresponds to StatelessWidget
(as its configuration data).
StatelessWidget
is used in scenarios where no state needs to be maintained. It typically constructs the UI in the build
method by nesting other widgets, recursively building its nested widgets in the process. Let’s look at a simple example:
class Echo extends StatelessWidget { const Echo({ Key? key, required this.text, this.backgroundColor = Colors.grey, // Default is grey }):super(key:key); final String text; final Color backgroundColor; @override Widget build(BuildContext context) { return Center( child: Container( color: backgroundColor, child: Text(text), ), ); } }
The above code implements an Echo
widget that echoes a string.
By convention, the parameters of a widget's constructor should use named parameters, and required parameters should be marked with the required
keyword, which helps static code analyzers with checks. When inheriting from a widget, the first parameter is typically Key
. Additionally, if a widget needs to accept child widgets, the child
or children
parameters are usually placed at the end of the parameter list. Following convention, widget properties should be declared as final
whenever possible to prevent accidental changes.
We can use it as follows:
Widget build(BuildContext context) { return Echo(text: "hello world"); }
The result after running looks like Figure :
2. Context
The build
method has a context
parameter, which is an instance of the BuildContext
class. It represents the context of the current widget within the widget tree. Each widget corresponds to a context
object (because each widget is a node in the widget tree). In fact, context
serves as a handle for performing "related operations" based on the position of the current widget in the widget tree. For instance, it provides methods for traversing up the widget tree from the current widget and for locating parent widgets by type. Here’s an example of retrieving a parent widget in a subtree:
class ContextRoute extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Context Test"), ), body: Container( child: Builder(builder: (context) { // Look up the nearest parent `Scaffold` widget in the widget tree Scaffold scaffold = context.findAncestorWidgetOfExactType<Scaffold>(); // Directly return the title of AppBar, which is actually Text("Context Test") return (scaffold.appBar as AppBar).title; }), ), ); } }
The result after running looks like Figure :
Note: Readers can familiarize themselves with BuildContext
for now. As the content of this book unfolds, methods related to Context
will be utilized, allowing readers to develop an intuitive understanding through specific scenarios. More information regarding BuildContext
will also be covered in greater detail in the advanced sections later in the book.
9.5 StatefulWidget
Similar to StatelessWidget
, StatefulWidget
also inherits from the Widget
class and overrides the createElement()
method. However, the returned Element
object is different; additionally, the StatefulWidget
class introduces a new interface, createState()
.
Let’s examine the class definition of StatefulWidget
:
abstract class StatefulWidget extends Widget { const StatefulWidget({ Key? key }) : super(key: key); @override StatefulElement createElement() => StatefulElement(this); @protected State createState(); }
StatefulElement
indirectly inherits from the Element
class and corresponds to StatefulWidget
(as its configuration data). The StatefulElement
may call createState()
multiple times to create a state (State
) object.
createState()
is used to create the state associated with the StatefulWidget
, and it may be called multiple times during the lifecycle of the StatefulWidget
. For example, when a StatefulWidget
is inserted into multiple locations in the widget tree, the Flutter framework will call this method to generate an independent State
instance for each location. Essentially, a StatefulElement
corresponds to a single State
instance.
In a StatefulWidget
, there is a one-to-one relationship between the State
object and StatefulElement
. Therefore, in the Flutter SDK documentation, you may often see descriptions like "removing a State object from the tree" or "inserting a State object into the tree," where "the tree" refers to the Element
tree generated from the widget tree. The SDK documentation frequently mentions "trees," and we can determine which tree is being referred to based on the context. Regardless of which tree it is, the ultimate goal is to describe the structure and rendering information of the UI. Thus, when encountering the concept of "trees" in Flutter, unless specified otherwise, we can understand it as "a node tree that constitutes the user interface." Readers need not get bogged down in these concepts; the key takeaway is to "grasp the essence and forget the form."
9.6 State
1. Introduction
A StatefulWidget
class corresponds to a State
class. State
represents the state that the corresponding StatefulWidget
needs to maintain. The state information stored in State
can:
Be synchronously read when the widget is built.
Be modified during the widget's lifecycle. When the state is changed, the
setState()
method can be called manually to notify the Flutter framework of the change. After receiving the message, Flutter will call itsbuild
method again to reconstruct the widget tree, thereby updating the UI.
There are two commonly used properties in State
:
widget
: This represents the widget instance associated with theState
instance, dynamically set by the Flutter framework. Note that this association is not permanent because, during the application's lifecycle, the widget instance at a specific node on the UI tree may change during reconstruction. However, theState
instance is only created the first time it is inserted into the tree. When the widget is rebuilt, if the widget has changed, Flutter dynamically setsState.widget
to the new widget instance.context
: TheBuildContext
corresponding to theStatefulWidget
, serving the same role asBuildContext
in aStatelessWidget
.
2. State Lifecycle
Understanding the lifecycle of State
is crucial for Flutter development. To make it more memorable, we will demonstrate the lifecycle of State
through an example. In the following example, we will continue using the counter functionality to implement a CounterWidget
component. When clicked, the counter increments by 1. Since we need to store the counter's value as state, we should extend StatefulWidget
. The code is as follows:
class CounterWidget extends StatefulWidget { const CounterWidget({Key? key, this.initValue = 0}); final int initValue; @override _CounterWidgetState createState() => _CounterWidgetState(); }
CounterWidget
receives an initValue
integer parameter, which represents the initial value of the counter. Now, let’s look at the State
code:
class _CounterWidgetState extends State<CounterWidget> { int _counter = 0; @override void initState() { super.initState(); // Initialize state _counter = widget.initValue; print("initState"); } @override Widget build(BuildContext context) { print("build"); return Scaffold( body: Center( child: TextButton( child: Text('$_counter'), // Increment counter on click onPressed: () => setState( () => ++_counter, ), ), ), ); } @override void didUpdateWidget(CounterWidget oldWidget) { super.didUpdateWidget(oldWidget); print("didUpdateWidget "); } @override void deactivate() { super.deactivate(); print("deactivate"); } @override void dispose() { super.dispose(); print("dispose"); } @override void reassemble() { super.reassemble(); print("reassemble"); } @override void didChangeDependencies() { super.didChangeDependencies(); print("didChangeDependencies"); } }
Next, we create a new route, where we only display the CounterWidget
:
class StateLifecycleTest extends StatelessWidget { const StateLifecycleTest({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return CounterWidget(); } }
When we run the application and open the new route, a 0
appears in the center of the screen, and the console log outputs:
I/flutter (5436): initState I/flutter (5436): didChangeDependencies I/flutter (5436): build
As you can see, when a StatefulWidget
is inserted into the widget tree, the initState
method is called first.
Then, we click the ⚡️ button for a hot reload, and the console outputs:
I/flutter (5436): reassemble I/flutter (5436): didUpdateWidget I/flutter (5436): build
At this point, neither initState
nor didChangeDependencies
are called, but didUpdateWidget
is.
Next, we remove the CounterWidget
from the widget tree by modifying the build
method of StateLifecycleTest
:
Widget build(BuildContext context) { // Remove the counter // return CounterWidget(); // Return a random Text() instead return Text("xxx"); }
After a hot reload, the log is as follows:
I/flutter (5436): reassemble I/flutter (5436): deactivate I/flutter (5436): dispose
We can see that when the CounterWidget
is removed from the widget tree, deactivate
and dispose
are called in sequence.
Let’s now go over the various callback functions:
initState: Called when the widget is first inserted into the widget tree. For each
State
object, the Flutter framework will call this callback only once, so it is usually used for one-time operations, such as state initialization or subscribing to subtree event notifications. You should not callBuildContext.dependOnInheritedWidgetOfExactType()
in this callback because, after initialization, theInheritedWidget
in the widget tree might change. The correct practice is to call it inbuild()
ordidChangeDependencies()
.didChangeDependencies: Called when the dependencies of the
State
object change. For example, if the previousbuild()
method included anInheritedWidget
(introduced in Chapter 7) and theInheritedWidget
changes in a subsequentbuild()
, this callback will be called. A typical scenario is when the system locale or app theme changes, and Flutter notifies the widget to call this callback. It is important to note thatdidChangeDependencies
is called when the component is first created and mounted (including when it is recreated).build(): You should already be quite familiar with this callback. It is primarily used to construct the widget subtree and is called in the following situations:
After calling
initState()
.After calling
didUpdateWidget()
.After calling
setState()
.After calling
didChangeDependencies()
.After the
State
object is removed from one location in the tree (callingdeactivate
) and inserted into another location.reassemble(): This callback is provided specifically for development and debugging. It is called during a hot reload and is never called in release mode.
didUpdateWidget(): When the widget is rebuilt, the Flutter framework calls
widget.canUpdate
to check the new and old nodes at the same position in the widget tree and decide whether to update. Ifwidget.canUpdate
returnstrue
, this callback is invoked. As mentioned earlier,widget.canUpdate
returnstrue
when thekey
andruntimeType
of the new and old widgets are equal. Thus, when thekey
andruntimeType
of the new and old widgets are equal,didUpdateWidget()
will be called.deactivate(): Called when the
State
object is removed from the tree. In some scenarios, the Flutter framework may reinsert theState
object into the tree, such as when a subtree containing thisState
object is moved to another location in the tree (this can be achieved usingGlobalKey
). If it is not reinserted, thedispose()
method will be called afterward.dispose(): Called when the
State
object is permanently removed from the tree, typically used to release resources.
The lifecycle of a StatefulWidget
is illustrated in Figure:
Note: When overriding methods in a StatefulWidget
, for methods marked with @mustCallSuper
in the parent class, you must call the parent class's method within the subclass method.
9.7 Accessing the State Object in the Widget Tree
Since the specific logic of a StatefulWidget
is encapsulated within its State
, there are many occasions when we need to access the State
object corresponding to a StatefulWidget
to call certain methods. For example, the state class ScaffoldState
associated with the Scaffold
component defines a method to open a SnackBar (the bottom prompt bar on the route page). We have two methods to retrieve the State
object of the parent StatefulWidget
in the child widget tree.
1. Accessing via Context
The context
object has a method findAncestorStateOfType()
, which can search upwards in the widget tree from the current node to find the State
object corresponding to the specified type of StatefulWidget
. Below is an example of how to implement opening a SnackBar:
class GetStateObjectRoute extends StatefulWidget { const GetStateObjectRoute({Key? key}) : super(key: key); @override State<GetStateObjectRoute> createState() => _GetStateObjectRouteState(); } class _GetStateObjectRouteState extends State<GetStateObjectRoute> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Accessing State Object in Child Tree"), ), body: Center( child: Column( children: [ Builder(builder: (context) { return ElevatedButton( onPressed: () { // Find the nearest Scaffold corresponding to the ScaffoldState object ScaffoldState _state = context.findAncestorStateOfType<ScaffoldState>()!; // Open the drawer menu _state.openDrawer(); }, child: Text('Open Drawer Menu 1'), ); }), ], ), ), drawer: Drawer(), ); } }
Generally, if the state of a StatefulWidget
is private (should not be exposed to the outside), then we should not directly access its State
object in our code. If the state of a StatefulWidget
is intended to be exposed (usually with some operational methods), we can directly access its State
object. However, the method to obtain the state of a StatefulWidget
via context.findAncestorStateOfType
is universal, and we cannot specify the privacy of a StatefulWidget
's state at the syntax level. Therefore, in Flutter development, there is a default convention: if the state of a StatefulWidget
is meant to be exposed, a static of
method should be provided in the StatefulWidget
to retrieve its State
object, allowing developers to access it directly through this method. If the State
should not be exposed, the of
method is not provided. This convention is commonly seen throughout the Flutter SDK. Thus, the Scaffold
in the example above also provides an of
method, which we can actually call directly:
Builder(builder: (context) { return ElevatedButton( onPressed: () { // Directly obtain the ScaffoldState through the of static method ScaffoldState _state = Scaffold.of(context); // Open the drawer menu _state.openDrawer(); }, child: Text('Open Drawer Menu 2'), ); }),
Similarly, if we want to display a SnackBar, we can call it using the following code:
Builder(builder: (context) { return ElevatedButton( onPressed: () { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("I am a SnackBar")), ); }, child: Text('Show SnackBar'), ); }),
After running the above example, clicking "Show SnackBar" will yield the result shown in Figure:
2. Accessing via GlobalKey
Flutter provides another general method for accessing the State
object—through a GlobalKey
. The process involves two steps:
Add a
GlobalKey
to the targetStatefulWidget
.
// Define a globalKey. Since GlobalKey needs to maintain global uniqueness, we use a static variable to store it. static GlobalKey<ScaffoldState> _globalKey = GlobalKey(); ... Scaffold( key: _globalKey, // Set the key ... )
Use the
GlobalKey
to access theState
object.
_globalKey.currentState.openDrawer();
A GlobalKey
is a mechanism provided by Flutter for referencing elements throughout the entire app. If a widget is assigned a GlobalKey
, we can obtain that widget object via globalKey.currentWidget
, the corresponding element object via globalKey.currentElement
, and if the current widget is a StatefulWidget
, we can access its corresponding state object via globalKey.currentState
.
Note: Using GlobalKey
incurs significant overhead; if there are other options available, it should be avoided whenever possible. Additionally, the same GlobalKey
must be unique within the entire widget tree and cannot be duplicated.
9.8 Creating Custom Widgets via RenderObject
StatelessWidget
and StatefulWidget
are used to compose other components, but they do not have their own corresponding RenderObject
. Many foundational components in Flutter's component library, such as Text
, Column
, Align
, are not implemented through StatelessWidget
or StatefulWidget
. It's like building blocks—StatelessWidget
and StatefulWidget
can arrange the blocks in different ways, but first, we need blocks, which are realized through custom RenderObject
. In fact, the most primitive way to define components in Flutter is by defining a RenderObject
, while StatelessWidget
and StatefulWidget
are just helper classes. Let’s demonstrate how to define a component via RenderObject
:
class CustomWidget extends LeafRenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) { // Create RenderObject return RenderCustomObject(); } @override void updateRenderObject(BuildContext context, RenderCustomObject renderObject) { // Update RenderObject super.updateRenderObject(context, renderObject); } } class RenderCustomObject extends RenderBox { @override void performLayout() { // Implement layout logic } @override void paint(PaintingContext context, Offset offset) { // Implement painting } }
If a component does not contain child components, we can directly inherit from LeafRenderObjectWidget
, which is a subclass of RenderObjectWidget
. RenderObjectWidget
itself inherits from Widget
. Let’s take a look at its implementation:
abstract class LeafRenderObjectWidget extends RenderObjectWidget { const LeafRenderObjectWidget({Key? key}) : super(key: key); @override LeafRenderObjectElement createElement() => LeafRenderObjectElement(this); }
It's straightforward: it implements the createElement
method, which creates an Element
object of type LeafRenderObjectElement
for the component. If the custom widget can contain child components, you can choose to inherit from SingleChildRenderObjectWidget
or MultiChildRenderObjectWidget
, depending on the number of children. They also implement the createElement()
method, returning different types of Element
objects.
Next, we override the createRenderObject
method, which is defined in RenderObjectWidget
. This method is called by the component's corresponding Element
(when constructing the render tree) to generate the render object. Our main task is to implement the render object class returned by createRenderObject
. In this case, it's RenderCustomObject
. The updateRenderObject
method is used to update the component's render object when the component tree's state changes without needing to recreate the RenderObject
.
The RenderCustomObject
class inherits from RenderBox
, and RenderBox
inherits from RenderObject
. In RenderCustomObject
, we need to implement logic for layout, painting, and event handling. The knowledge needed to implement these will be covered throughout this book, so don't worry; we will introduce it step by step in later chapters.
9.9 Overview of the Flutter SDK's Built-in Component Libraries
Flutter provides a rich and powerful set of foundational components. On top of the basic component library, Flutter also offers a Material-style component library (Android’s default visual style) and a Cupertino-style library (iOS visual style). To use the basic component library, you need to first import:
import 'package:flutter/widgets.dart';
Let’s introduce some commonly used components.
1. Basic Components
Text: This component allows you to create formatted text.
Row, Column: These are layout widgets with flexible spaces, allowing you to create flexible layouts in horizontal (
Row
) and vertical (Column
) directions. They are designed based on the Flexbox layout model used in web development.Stack: This replaces linear layouts (similar to
FrameLayout
in Android). Stack allows child widgets to overlap, and you can position them relative to the four edges of theStack
using thePositioned
widget. Stacks are designed based on the absolute positioning layout model used in web development.Container:
Container
allows you to create rectangular visual elements. AContainer
can be decorated with aBoxDecoration
, such as a background, a border, or a shadow.Container
can also have margins, padding, and constraints applied to its size. Additionally,Container
can be transformed in 3D space using matrices.
2. Material Components
Flutter provides a rich set of Material components that help you build applications that follow the Material Design guidelines. Material applications start with the MaterialApp
component, which creates several essential components at the root of the application, such as the Theme
component, which configures the app's theme. Using MaterialApp
is optional, but it's a good practice. In previous examples, we have already used several Material components, such as Scaffold
, AppBar
, and TextButton
. To use Material components, you need to first import:
import 'package:flutter/material.dart';
3. Cupertino Components
Flutter also provides a set of Cupertino-style components. Although not as extensive as the Material components, they are continually being improved. It’s worth noting that some components in the Material library can switch their visual styles based on the platform. For example, MaterialPageRoute
uses Android’s default page transition animation (from bottom to top) on Android and iOS’s default transition animation (from right to left) on iOS. Since we haven't shown examples of Cupertino components in the previous sections, let's implement a simple Cupertino-style page:
// Import the cupertino widget library import 'package:flutter/cupertino.dart'; class CupertinoTestRoute extends StatelessWidget { const CupertinoTestRoute({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return CupertinoPageScaffold( navigationBar: const CupertinoNavigationBar( middle: Text("Cupertino Demo"), ), child: Center( child: CupertinoButton( color: CupertinoColors.activeBlue, child: const Text("Press"), onPressed: () {}, ), ), ); } }
Below (Figure) is a screenshot of the page on an iPhone X:
9.10 Summary
Flutter's widget types are divided into StatefulWidget
and StatelessWidget
. Readers need to deeply understand the differences between them, as widgets will be the cornerstone of building Flutter applications.
Flutter provides a rich set of components, and in actual development, we can use them as needed without worrying about introducing too many component libraries that would make your app package too large. This is not web development—Dart only compiles the code you use during the build process. Since Material and Cupertino are both built on top of the basic component library, if you include one of them in your application, there's no need to import flutter/widgets.dart
again, as it’s already included internally.
Additionally, note that the examples in the later sections of this chapter will use some layout components, such as Scaffold
, Row
, and Column
. These components will be covered in detail in the "Layout Components" chapter later on, so there's no need to focus on them just yet.