Flutter 基础 | 自定义控件 StatelessWidget & StatefulWidget

简介: Flutter 基础 | 自定义控件 StatelessWidget & StatefulWidget

当系统组件不能满足需求时才自定义控件?在 Flutter 中这句话可能不一定成立。这一篇就解释一下为啥 Flutter 中有事没事就应该自定义一个控件。


自定义无状态控件


状态不会发生变化的控件称为无状态控件StatelessWidget。它的状态在构建的时候已经确定,并且永远不会发生变化,即系统永远不会重新构建无状态控件。


Flutter 的控件是高度嵌套的,刚从 Android 转过来的时候,整个人是懵的,控件居中都需要嵌套一层:


Center(
  child: Text('xxx'),
)


其中Center是一个控件,Text也是一个控件。


在 Android 原生的世界里面,用 ConstraintLayout 可以把一个界面的嵌套层级降为 0,同样的界面到了 Flutter 中,六七层嵌套起步,这么个嵌套法,界面不会卡吗?


从体感上来说,好像嵌套层多并未影响到绘制性能,以后的篇章会分析背后的原理。但这样的嵌套对阅读代码来说就已经非常不友好了。


image.png


这个底导栏在原生 Android 中可以是一个 ConstraintLayout,其中包含了平级的 3 个 ImageView 和 3 个 TextView。但在 Flutter 中,它是这样实现的:


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,
                    ),
                  ),
                ),
              ],
            )
          ],
        ),
      ),
    );
  }
}


看着末尾那一层层递进的括号,我快要疯掉。。。


因为 Flutter 是用横向+纵向的布局方式来理解这个界面的,首先是横向容器Row,它包含三个纵向容器Column,每个 Column 中又包含一个文字和一张图片。


所以“改善布局代码的可读性”在 Flutter 中是件头等大事。


为此 AndroidStudio 的插件也提供了快捷入口,鼠标右键控件,依次选择Refactor ▸ Extract ▸ Extract Flutter Widget…


对上述代码中的第一个Column进行重构,取名为BottomCallItem,IDE 会自动生成如下代码:


image.png

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,
            ),
          ),
        ),
      ],
    );
  }
}


IDE 会默认将控件抽象为无状态控件StatelessWidget。无状态控件会包含一个构造方法和build()方法。build() 方法描述的是如何构建控件,通常这里是一些系统控件的组合。BottomCallItem 就是用垂直线性布局包裹一张图片和一段文字。


用这种方式,原本的代码就可以简化如下:


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()
          ],
        ),
      ),
    );
  }
}


所以抽象出无状态控件通常是为了减少嵌套层次,增加代码可读性。


自定义有状态控件


image.png


让我们再进一步,底导栏中的按钮通常有选中/未选中状态。这种状态会发生变化的控件在 Flutter 中叫StatefulWidget


在 AndroidStudio 中一键就能把一个 StatelessWidget 转化成 StatefulWidget。


选中 StatelessWidget 类名,按Alt + Enter,点击Convert to StatefulWidget,就完成了一键转化。


将 BottomCallItem 重命名为 BottomBar,因为这次要自定义的控件是整个底导栏:


// 自定义底导栏
class BottomBar extends StatefulWidget {
  const BottomBar({
    Key? key,
  }) : super(key: key);
  // 构建与底导栏绑定的状态
  @override
  _BottomBarState createState() => _BottomBarState();
}
// 与 BottomBar 绑定的状态类
class _BottomBarState extends State<BottomBar> {
  // 在状态类中构建自定义控件
  @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,
            ),
          ),
        ),
      ],
    );
  }
}


IDE 自动新增了一个状态类_BottomBarState继承自State,绘制控件的状态信息将会存储在其中,这些信息会发生变化,以触发重新构建控件,即重新调用build()方法。


当控件被插入到绘制树时,StatefulWidget.createState()会被调用以构建与控件绑定的状态实例。与BottomBar绑定的是_BottomBarState实例。


添加不可变状态


不可变状态意味着当控件实例被构建之后就不会发生变化的参数。


对于底导栏来说就是其中包含的按钮数据,将按钮数据抽象为一个实体类:


class Item {
  String name = ""; // 按钮名称
  IconData? icon; // 按钮图标
  Item(this.name, this.icon); // 构造方法
}


BottomBar在构造时应传入一组Item实例:


class BottomBar extends StatefulWidget {
  final List<Item> items; // 所有 StatefulWidget 的属性必须是final的
  BottomBar({
    Key? key,
    required this.items, // 构造时传入一组按钮
  }) : super(key: key);
  @override
  _BottomBarState createState() => _BottomBarState();
}


required关键词表示参数items在构造时是必须的。构造方法中this.items这种语法表示传入的实参直接赋值给成员items。关于 Dart 的语法知识可以点击Flutter 基础 | Dart 语法


BottomBar 布局构建逻辑在_BottomBarState.build()中实现:



class _BottomBarState extends State<BottomBar> {
  @override
  Widget build(BuildContext context) {
    // 底导栏控件的容器是一个横向的线性布局
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        // 遍历 BarBottom 中的 items 数据,逐个构建按钮
        for (var item in widget.items)
          // 单个按钮是一个纵向线性布局
          Column(
            mainAxisSize: MainAxisSize.min,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // 单个按钮包含一个图标和一个文字控件
              Icon(item.icon, color: Colors.blue),
              Text(
                item.name,
                style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.w400,
                  color:  Colors.blue,
                ),
              )
            ],
          ),
      ],
    );
  }
}


Flutter 声明式的布局代码带来的一个好处就是:布局中可以嵌入逻辑,这让动态构建布局变得轻而易举。在 Android 原生世界里,布局和逻辑是完全切割的,布局在 .xml 中,逻辑在 .java(.kt) 中。


底导栏的按钮数量是动态的,会随着传入的 items 列表长度而变。所以得动态地构建。

State的子类可以通过widget方便地访问到绑定控件的实例,而items又是控件的成员变量。通过遍历 items 实现动态构建,每次遍历都会构建一个纵向的线性布局,它包含两个子控件:图标+文字,并且用Item中的数据填充它们。


然后就可以像这样创建 BottomBar 的实例了:


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


添加可变状态


虽然 BottomBar 声明为有状态控件,但直到现在它还没有状态变化。唯一和他绑定的数据items也是可不变的 final 类型,即控件的整个生命周期中不会发生变化。


为了让 BottomBar 能够有选中高亮,未选中置灰的效果,得为它增加可变状态。


对于 BottomBar 来说,得实现一个子控件之间的单选效果,即一个选中的控件高亮,其他的置灰。于是乎决定使用一个 Map 保存每个子控件的选中状态:


class _BottomBarState extends State<BottomBar> {
  // 保存每个控件选中状态的 map
  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;
    }
  }
}


可变状态通常以State类的成员出现。State实例被构建之后,系统提供了State.initState(),以实现一次性的初始化。


通过遍历按钮列表为每个按钮选中状态赋初始值,以按钮名为键,以按钮是否选中的布尔值为值构建 Map。默认选中第一个按钮。


将选中状态和界面构建结合起来:


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, 
                  // 如果选中则呈现蓝色否则灰色
                  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,
                ),
              )
            ],
          ),
      ],
    );
  }
}


运行代码,就可以展示如下界面:


image.png


下一步得让每个按钮响应点击事件,并且让高亮和点击联动。


Flutter 中为控件增加点击事件是通过包一层GestureDetector实现的:


class _BottomBarState extends State<BottomBar> {
  ...
  @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;
              });
            },
            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,
                  ),
                )
              ],
            ),
          )
      ],
    );
  }
}


当按钮被单击时,调用State.setState()方法,该方法的参数是VoidCallback类型的:


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


VoidCallback 是一个没有输入和输出的回调方法,通常在这个回调中更新状态。


当前场景是在该回调中遍历 Map,先将所有按钮置为未选中,然后再将被点击的那个置为选中。


调用了setState()就是告诉系统:该控件状态发生变化,系统将触发一次重绘,即调用build()方法,而构建控件的逻辑又依赖于状态数据_selectMap,就这样界面重绘出了不同的样子。


最后需要在 State 生命周期结束的时候清理状态:


class _BottomBarState extends State<BottomBar> {
  var _selectMap = {};
  @override
  void dispose() {
    super.dispose();
    _selectMap.clear();
  }
  ...
}


State.dispose()是 State 对象生命周期的终点,被 dispose 之后,它就处于unmounted状态,表现为State.mounted值为 false,再调用setState()就会报错。


添加选中回调


友好的底导栏控件应该提供一个回调来告诉上层那个按钮被选中了。这回调也是一种状态,而且是不可变状态,所以将他添加到BottomBar中:


class BottomBar extends StatefulWidget {
  final List<Item> items;
  // 声明选中回调
  final OnTabSelect? onTabSelect;
  BottomBar({
    Key? key,
    required this.items,
    this.onTabSelect, // 在构造方法中传入回调
  }) : super(key: key);
  @override
  _BottomBarState createState() => _BottomBarState();
}
// 将函数类型重命名
typedef OnTabSelect = void Function(int value);


typedef关键词将一个函数类型重命名为OnTabSelectvoid Function(int value)表示函数接受一个 int 类型的实参但没有返回值。


然后在_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;
              });
              // 在点击事件响应逻辑中引用回调
              if (widget.onTabSelect != null) {
                // 将选中按钮的索引值传递出去
                widget.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,
                  ),
                )
              ],
            ),
          )
      ],
    );
  }
}


最后就可以像这样使用底导栏了:


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


等等~,不是说界面展示和业务逻辑(数据)要分离吗?_selectMap即是业务数据,为了和界面隔离,它不是该出现在ViewModel中吗?然后界面通过观察它实现刷新。


没错,但当前场景不需要这样小题大作,Flutter 把类似_selectMap的数据称为Ephemeral state,即转瞬即逝的状态。App 的其他组件不需要了解_selectMap的变化,它的变化只会在底导栏中发生,它的生命周期和底导栏完全同步,即使用户离开后再次返回时重新构建它也没什么不好的体验。用 Flutter 的话说,就是 Ephemeral state 不需要状态管理。


下一篇接着分享需要状态管理的 App state。


推荐阅读





目录
相关文章
|
4月前
|
存储 JavaScript 开发者
Flutter应用开发:掌握StatefulWidget的实用技巧
Flutter应用开发:掌握StatefulWidget的实用技巧
64 0
|
7月前
Flutter StatefulWidget传递数据,多级控件传递数据
Flutter StatefulWidget传递数据,多级控件传递数据 在Flutter中,StatefulWidget可以通过构造函数将数据传递给其子控件,这种方式适用于一些简单的场景。但是,当存在多级嵌套控件时,将数据从祖先传递到后代可能会变得困难。在这种情况下,可以使用Flutter提供的InheritedWidget类来传递数据。
117 0
|
存储 Dart 监控
Flutter(二)之有状态的StatefulWidget
在开发中,某些Widget情况下我们展示的数据并不是一层不变的: 比如Flutter默认程序中的计数器案例,点击了+号按钮后,显示的数字需要+1; 比如在开发中,我们会进行下拉刷新、上拉加载更多,这时数据也会发生变化; 而StatelessWidget通常用来展示哪些数据固定不变的,如果数据会发生改变,我们使用StatefulWidget;
341 1
Flutter(二)之有状态的StatefulWidget
【Flutter】自定义 Flutter 组件 ( 创建自定义 StatelessWidget、StatefulWidget 组件 | 调用自定义组件 )(二)
【Flutter】自定义 Flutter 组件 ( 创建自定义 StatelessWidget、StatefulWidget 组件 | 调用自定义组件 )(二)
635 0
【Flutter】自定义 Flutter 组件 ( 创建自定义 StatelessWidget、StatefulWidget 组件 | 调用自定义组件 )(二)
|
Dart 开发者
【Flutter】StatefulWidget 组件 ( Image 组件 | TextField 组件 )
【Flutter】StatefulWidget 组件 ( Image 组件 | TextField 组件 )
130 0
【Flutter】StatefulWidget 组件 ( Image 组件 | TextField 组件 )
|
索引
【Flutter】StatefulWidget 组件 ( FloatingActionButton 组件 | RefreshIndicator 组件 )(二)
【Flutter】StatefulWidget 组件 ( FloatingActionButton 组件 | RefreshIndicator 组件 )(二)
119 0
【Flutter】StatefulWidget 组件 ( FloatingActionButton 组件 | RefreshIndicator 组件 )(二)
|
Dart 开发者 索引
【Flutter】StatefulWidget 组件 ( FloatingActionButton 组件 | RefreshIndicator 组件 )(三)
【Flutter】StatefulWidget 组件 ( FloatingActionButton 组件 | RefreshIndicator 组件 )(三)
109 0
【Flutter】StatefulWidget 组件 ( FloatingActionButton 组件 | RefreshIndicator 组件 )(三)
【Flutter】自定义 Flutter 组件 ( 创建自定义 StatelessWidget、StatefulWidget 组件 | 调用自定义组件 )(一)
【Flutter】自定义 Flutter 组件 ( 创建自定义 StatelessWidget、StatefulWidget 组件 | 调用自定义组件 )(一)
587 0
|
Dart 开发者
【Flutter】StatelessWidget 组件 ( CloseButton 组件 | BackButton 组件 | Chip 组件 )(二)
【Flutter】StatelessWidget 组件 ( CloseButton 组件 | BackButton 组件 | Chip 组件 )(二)
139 0
【Flutter】StatelessWidget 组件 ( CloseButton 组件 | BackButton 组件 | Chip 组件 )(二)
【Flutter】StatelessWidget 组件 ( CloseButton 组件 | BackButton 组件 | Chip 组件 )(一)
【Flutter】StatelessWidget 组件 ( CloseButton 组件 | BackButton 组件 | Chip 组件 )(一)
134 0
【Flutter】StatelessWidget 组件 ( CloseButton 组件 | BackButton 组件 | Chip 组件 )(一)