Custom controls when system components do not meet requirements? This statement may not necessarily be true in Flutter. This article explains why a custom control should be created for Flutter when something is going on.

Custom stateless controls

A control whose state does not change is called a stateless control StatelessWidget. Its state is determined at build time and never changes.

The controls on Flutter are highly nested. At the beginning of the transition from Android, the user felt confused and had to have a nested layer in the center of the controls:

Center(
  child: Text('xxx'),Copy the code

Where Center is a control and Text is also a control.

In the native Android world, ConstraintLayout reduces the nesting level of an interface to zero. The same interface starts with a Flutter with six or seven layers of nesting.

From the perspective of motion, it seems that the number of nesting layers does not affect the drawing performance. The principle behind this will be discussed in a future chapter. But such nesting is already very unfriendly to reading code.

This guide bar can be a ConstraintLayout in native Android, which consists of three flat ImageViews and three textViews. But in Flutter, it works like this:

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Welcome to Flutter',

      home: Scaffold(
        appBar: AppBar(
          title: const Text('Welcome to Flutter'),
        ),
        body: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(Icons.call, color: Colors.blue),
                Container(
                  margin: const EdgeInsets.only(top: 8),
                  child: Text(
                    "CALL",
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w400,
                      color: Colors.blue,
                    ),
                  ),
                ),
              ],
            ),
            Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(Icons.near_me, color: Colors.blue),
                Container(
                  margin: const EdgeInsets.only(top: 8),
                  child: Text(
                    "ROUTE",
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w400,
                      color: Colors.blue,
                    ),
                  ),
                ),
              ],
            ),
            Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(Icons.share, color: Colors.blue),
                Container(
                  margin: const EdgeInsets.only(top: 8),
                  child: Text(
                    "SHARE",
                    style: TextStyle(
                      fontSize: 12, fontWeight: FontWeight.w400, color: Colors.blue, ), ), ), ], ) ], ), ), ); }}Copy the code

Looking at the successive brackets at the end, I was going crazy…

Because Flutter uses horizontal + vertical layout to understand the interface, the first is horizontal container Row, which contains three vertical container columns, each containing a text and an image.

So “improving readability of layout code” is a top priority in Flutter.

For this purpose, the AndroidStudio plugin also provides shortcuts. Right-click the control and choose Refactor ▸ Extract ▸ Extract Flutter widgets… .

Refactor the first Column in the above code, called BottomCallItem, and the IDE automatically generates the following code:

class BottomCallItem extends StatelessWidget {
  const BottomCallItem({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.call, color: Colors.blue),
        Container(
          margin: const EdgeInsets.only(top: 8),
          child: Text(
            "CALL",
            style: TextStyle(
              fontSize: 12, fontWeight: FontWeight.w400, color: Colors.blue, ), ), ), ], ); }}Copy the code

The IDE abstracts the control as a stateless control StatelessWidget by default. Stateless controls contain a constructor and a build() method. The build() method describes how controls are built, usually a combination of system controls. BottomCallItem is a vertical linear layout that wraps a picture and a paragraph of text.

In this way, the original code can be simplified as follows:

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Welcome to Flutter',

      home: Scaffold(
        appBar: AppBar(
          title: const Text('Welcome to Flutter'), ), body: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ BottomCallItem(), BottomRouteItem(), BottomShareItem() ], ), ), ); }}Copy the code

So stateless controls are usually abstracted to reduce nesting levels and increase code readability.

Custom stateful controls

Let’s take this one step further. Buttons in the bottom bar are usually checked/unchecked. The control whose state changes is called a StatefulWidget in Flutter.

A StatelessWidget can be converted into a StatefulWidget in AndroidStudio with one click.

Select the StatelessWidget class name, press Alt + Enter, and click Convert to StatefulWidget to complete the one-click conversion.

Rename BottomCallItem to BottomBar, because the control you want to customize this time is the entire BottomBar:

// Customize the bottom bar
class BottomBar extends StatefulWidget {
  const BottomBar({
    Key? key,
  }) : super(key: key);

  // Build the state bound to the bottom bar
  @override
  _BottomBarState createState() => _BottomBarState();
}

// State class bound to BottomBar
class _BottomBarState extends State<BottomBar> {
  // Build custom controls in the state class
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.call, color: Colors.blue),
        Container(
          margin: const EdgeInsets.only(top: 8),
          child: Text(
            "CALL",
            style: TextStyle(
              fontSize: 12, fontWeight: FontWeight.w400, color: Colors.blue, ), ), ), ], ); }}Copy the code

IDE automatically adds a State class _BottomBarState inherited from State, where the State information of the drawn control will be stored, and this information will change over the life of the control.

When the control is inserted into the drawing trees, StatefulWidget. CreateState () is called to build and control the state of the binding instance. Binding to BottomBar is an instance of _BottomBarState.

Add immutable state

Immutable state means that the parameters do not change after the control instance is built.

In the case of the bottom bar, this is the button data contained therein, abstracting the button data into an entity class:

class Item {
  String name = ""; // The button name
  IconData? icon; // Button icon

  Item(this.name, this.icon); // constructor
}
Copy the code

BottomBar should pass in a set of Item instances when constructing:

class BottomBar extends StatefulWidget {
  final List<Item> items; // All StatefulWidget properties must be final

  BottomBar({
    Key? key,
    required this.items, // Pass in a set of buttons
  }) : super(key: key);

  @override
  _BottomBarState createState() => _BottomBarState();
}
Copy the code

The required keyword indicates that the parameter items is required at construction time. The this.items syntax in the constructor means that the passed argument is assigned directly to the member items. About the grammar knowledge can click on Flutter basis of Dart | Dart syntax.

The BottomBar layout build logic is implemented in _bottombarstate.build () :

class _BottomBarState extends State<BottomBar> {

  @override
  Widget build(BuildContext context) {
    // The container of the bottom bar controls is a horizontal linear layout
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        // Iterate through the items data in BarBottom, building buttons one by one
        for (var item in widget.items)
          // The single button is a vertical linear layout
          Column(
            mainAxisSize: MainAxisSize.min,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // A single button contains an icon and a text control
              Icon(item.icon, color: Colors.blue),
              Text(
                item.name,
                style: TextStyle(
                  fontSize: 12, fontWeight: FontWeight.w400, color: Colors.blue, ), ) ], ), ], ); }}Copy the code

One of the benefits of Flutter declarative layout code is that logic can be embedded into the layout, making it easy to build layouts dynamically. In the Native Android world, layout and logic are completely separate, with layout in.xml and logic in.java(.kt).

The number of buttons in the bottom bar is dynamic and changes with the length of the items list passed in. So you have to build dynamically.

A subclass of State can easily access an instance of a bound control through a widget, and Items is a member variable of the control. Build dynamically by walking through items, and each walk builds a vertical linear layout that contains two child controls: icon + text, and populates them with data from items.

You can then create an instance of BottomBar like this:

BottomBar(
    items: [
        Item('CALL', Icons.call), 
        Item('ROUTE', Icons.near_me), 
        Item('SHARE', Icons.share)
    ]
);
Copy the code

Add mutable State

Although BottomBar has declared itself a stateful control, it has not changed state until now. The only data items bound to the control is final, meaning it does not change throughout the control’s life cycle.

In order for BottomBar to have selected highlighting and unselected graying, you need to add a variable state to it.

For BottomBar, you implement a radio effect between child controls, with one selected control highlighted and the others grayed out. So we decided to use a Map to hold the selected state of each child control:

class _BottomBarState extends State<BottomBar> {
  // Save a map of the selected state of each control
  var _selectMap = {};

  @override
  void initState() {
    super.initState();
    // Initialize mutable state
    for (var i = 0; i < widget.items.length; i++) {
      _selectMap[widget.items[i].name] = i == 0 ? true : false; }}}Copy the code

Mutable State usually occurs as a member of the State class. After the State instance is built, the system provides state.initState () for one-time initialization.

Assign an initial value to each button selected state by iterating through the list of buttons, build a Map with the button name key and a Boolean value for whether the button is selected or not. The first button is selected by default.

Combine selected state with interface construction:

class _BottomBarState extends State<BottomBar> {
  var _selectMap = {};
  
  @override
  void initState() {
    super.initState();
    for (var i = 0; i < widget.items.length; i++) {
      _selectMap[widget.items[i].name] = i == 0 ? true : false; }}@override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        for (var item in widget.items)
          Column(
            mainAxisSize: MainAxisSize.min,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(
                  item.icon, 
                  // If selected, the color is blue otherwise gray
                  color: _selectMap[item.name] ? Colors.blue : Colors.grey),
              Text(
                item.name,
                style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.w400,
                  // If selected, the color is blue otherwise graycolor: _selectMap[item.name] ? Colors.blue : Colors.grey, ), ) ], ), ], ); }}Copy the code

Run the code to display the following interface:

The next step is to make each button respond to the click event and make the highlight work with the click.

Adding click events to Flutter controls is implemented using a layer of GestureDetector:

class _BottomBarState extends State<BottomBar> {...@override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        for (var item in widget.items)
          GestureDetector(
            // Click response logic
            onTap: () {
              setState(() {
                // Leave all buttons unchecked
                for (var i = 0; i < widget.items.length; i++) {
                  _selectMap[widget.items[i].name] = false;
                }
                // Make the click button selected
                _selectMap[item.name] = true;
              });
            },
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(item.icon, color: _selectMap[item.name] ? Colors.blue : Colors.grey),
                Text(
                  item.name,
                  style: TextStyle(
                    fontSize: 12, fontWeight: FontWeight.w400, color: _selectMap[item.name] ? Colors.blue : Colors.grey, ), ) ], ), ) ], ); }}Copy the code

When the button is clicked, the state.setState () method is called, which takes arguments of type VoidCallback:

abstract class State<T extends StatefulWidget> with Diagnosticable {
    void setState(VoidCallback fn) {...}
}

typedef VoidCallback = void Function(a);Copy the code

VoidCallback is a callback method that has no input or output, and typically updates the status in this callback.

The current scenario iterates through the Map in this callback, making all buttons unselected and then the one that was clicked selected.

Calling setState() tells the system that if the state of the control changes, the system will trigger a redraw, calling the build() method. The logic to build the control relies on the state data _selectMap, and the interface is redrawn differently.

Finally, the State needs to be cleaned up at the end of the State lifecycle:

class _BottomBarState extends State<BottomBar> {
  var _selectMap = {};

  @override
  void dispose() {
    super.dispose(); _selectMap.clear(); }... }Copy the code

State.dispose() is the end of the life cycle of the State object. After it is disposed, it is in unmounted State and its value is false. SetState () will return an error.

Add selected callback

A friendly bottom bar control should provide a callback to tell the upper layer that the button is selected. This call is also a state, and an immutable state, so add it to BottomBar:

class BottomBar extends StatefulWidget {
  final List<Item> items;
  // Declare the callback selected
  final OnTabSelect? onTabSelect;

  BottomBar({
    Key? key,
    required this.items,
    this.onTabSelect, // Pass the callback in the constructor
  }) : super(key: key);

  @override
  _BottomBarState createState() => _BottomBarState();
}

// Rename the function type
typedef OnTabSelect = void Function(int value);
Copy the code

Use the typedef keyword to rename a Function type to OnTabSelect. Void Function(int value) means that the Function takes an argument of type int but returns no value.

Then reference the callback in _BottomBarState:

class _BottomBarState extends State<BottomBar> {
  var _selectMap = {};

  @override
  void initState() {
    super.initState();
    for (var i = 0; i < widget.items.length; i++) {
      _selectMap[widget.items[i].name] = i == 0 ? true : false; }}@override
  void dispose() {
    super.dispose();
    _selectMap.clear();
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        for (var item in widget.items)
          GestureDetector(
            onTap: () {
              setState(() {
                for (var i = 0; i < widget.items.length; i++) {
                  _selectMap[widget.items[i].name] = false;
                }
                _selectMap[item.name] = true;
              });
              // Reference the callback in the click event response logic
              if(widget.onTabSelect ! =null) {
                // Pass the index value of the selected buttonwidget.onTabSelect! (widget.items.indexOf(item)); } }, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(item.icon, color: _selectMap[item.name] ? Colors.blue : Colors.grey), Text( item.name, style: TextStyle( fontSize:12, fontWeight: FontWeight.w400, color: _selectMap[item.name] ? Colors.blue : Colors.grey, ), ) ], ), ) ], ); }}Copy the code

Finally, you can use the bottom bar like this:

BottomBar(
  items: [
      Item('CALL', Icons.call), 
      Item('ROUTE', Icons.near_me), 
      Item('SHARE', Icons.share)
  ],
  onTabSelect: (index) {
    print('$index'); });Copy the code

Wait, isn’t there a separation of interface presentation and business logic (data)? _selectMap is business data. Shouldn’t it be in the ViewModel to isolate it from the interface? The interface then refreshes by observing it.

That’s right, but the current scenario doesn’t need such fuss. Flutter refers to data like _selectMap as Ephemeral state. The rest of the App doesn’t need to know that the _selectMap changes, it changes only in the bottom bar, and its life cycle is completely synchronized with the bottom bar, even if the user leaves and comes back and rebuilds it, it’s not a bad experience. In Flutter parlance, the Ephemeral state does not require state management.

The next article will continue to share App States that require state management.