likes
comments
collection
share

【Flutter】基于 StatefulWidget 实现的自定义的多选组与单选组

作者站长头像
站长
· 阅读数 21

自定义CheckBox与RadioButton以及其要点

前言

难道 Flutter 自带的 Widget 没有 CheckBox 吗?难道就非得要自己造一个?

还真得这样,得满足设计和产品的需求啊。

  1. UI 设计要求:

如果您的应用需要使用特定的图标、样式或动画效果,Flutter 自带的 Checkbox 可能无法满足这些需求。自定义组件可以灵活地进行 UI 样式调整,符合设计规范。

  1. 复选框组的管理:

CustomCheckBox 组件可以封装多个选项,并且对选中项进行管理。这使得在复杂的场景中(比如动态生成的复选框组)更易于处理。

  1. 功能扩展:

您可能需要额外的功能,比如选中项的回调、全选/全不选功能、状态恢复等。自定义组件可以更好地处理这种特定的逻辑。

并且不管是 CheckBox 多选组,和 Radio 的单选组,还是其他的基础控件,例如文本,图片等,我都会封装一层,一是为了适配自己的项目,二是为了统一管理。方便后期全局的实现多主题或者其他需求。而不需要每一个地方去修改 Text Image 等需求,毕竟 Fultter 并不好像 Android 一样可以插桩去自动修改一些配置。

那么话说回来,我们的项目中有各种的多选和单选的场景,并且风格统一,我们就可以基于产品实现自定义的布局。

一、实现自定义布局

真正来说布局可能是很简单,就是一个图片加上一个文本组成

Row(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        MyAssetImage(path, width: 20.5, height: 20.5),
        SizedBox(width: 10),
        MyTextView(
          text.tr,
          textColor: ColorConstants.white,
          fontSize: 14,
          isFontRegular: true,
        ),
      ],
    )

如果想要做出组的形式,我们需要使用 Warp 来包裹这些子组件,达到自动换行的效果,并且根据提供的选项动态生成数据:

Wrap(
      spacing: 8.0,
      runSpacing: 8.0,
      children: widget.options.map((option) {
        return _buildRadioWithIconAndText(
          path: option == _selectedOption ? Assets.cptAuthLoginRadioChecked : Assets.cptAuthLoginRadioUncheck,
          text: option,
          value: option == _selectedOption,
          onChanged: (value) {
            setState(() {
              _selectedOption = option;
              int selectedIndex = widget.options.indexOf(option);
              widget.onOptionSelected(selectedIndex, option);
            });
          },
        );
      }).toList(),
    )

可以看到我内部是做了复显的逻辑,那么问题来了,我们是否需要考虑使用 StatefulWidget 还是 StatelessWidget 呢?

二、动态的数据与复选中

  1. StatelessWidget:用于构建不需要维护状态的组件。它的构建是基于其构造函数中的参数,且在生命周期内不支持任何状态的更新或持久化。每次需要更新 UI 时,都会重建整个 Widget。

  2. StatefulWidget:用于构建需要维护状态的组件。它有一个与之关联的 State 对象,该对象可以在生命周期内保存状态,并通过 setState 方法更新 UI。当需要在 Widget 重新构建时,didUpdateWidget 方法会被调用

其实在现代开发中,一个页面是 StatefulWidget 还是 StatelessWidget 都能完成对应功能,都能做对应的刷新,不同的状态管理插件使用不同的封装方式,可以直接刷新页面。

但是一些内部的子布局实现我们还是需要判断使用 StatefulWidget 还是 StatelessWidget 的,主要还是在你是否需要对应的生命周期回调。

比如我们的此场景,当页面或父容器刷新的时候,我们的子布局如果想要根据状态刷新本身的状态或布局,那么我们需要用到 didUpdateWidget 重新整理对应的状态和布局,那么我们是需要使用 StatefulWidget 来完成的。

比如在上面我们的 build 方法中我们用到的状态是 options 数据:

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8.0,
      runSpacing: 8.0,
      children: widget.options.map((option) {
        return _buildRadioWithIconAndText(
          path: option == _selectedOption ? Assets.cptAuthLoginRadioChecked : Assets.cptAuthLoginRadioUncheck,
          text: option,
          value: option == _selectedOption,
          onChanged: (value) {
            setState(() {
              _selectedOption = option;
              int selectedIndex = widget.options.indexOf(option);
              widget.onOptionSelected(selectedIndex, option);
            });
          },
        );
      }).toList(),
    );
  }

那么当我们页面中的网络数据回来之后我们刷新了选项,此时 build 重新加载了,我们的页面是可以正常的显示选项了,但是复显效果就没了,为什么?因为我们在刷新的时候没有处理 selectedOption 的数据。

所以我们需要在 didUpdateWidget 中处理对应的逻辑:

class _CustomRadioCheckState extends State<CustomRadioCheck> {
  String? _selectedOption;

  @override
  void initState() {
    super.initState();
    _initializeSelectedOption();
  }

  void _initializeSelectedOption() {
    if (widget.selectedPosition != null && widget.selectedPosition! >= 0 && widget.selectedPosition! < widget.options.length) {
      _selectedOption = widget.options[widget.selectedPosition!];
    } else {
      _selectedOption = widget.options.isNotEmpty ? widget.options[0] : null;
    }
  }

  @override
  void didUpdateWidget(CustomRadioCheck oldWidget) {
    super.didUpdateWidget(oldWidget);
    // 如果 selectedPosition 发生变化,重新初始化选中项
    if (oldWidget.selectedPosition != widget.selectedPosition) {
      _initializeSelectedOption();
    }
  }
  ...
}

此时我们就能完成对应的逻辑,进入页面,展示空的选项,加载请求,拿到数据展示选项并选中已选中的选项。

三、完整代码与示例

其实单选与多选的区别,在布局和数据都是相同的逻辑,只是在选择的时候我们做了不同的处理,他们的逻辑大致是相同的,这里给出完整的代码:

CustomRadioCheck:

class CustomRadioCheck extends StatefulWidget {
  final List<String> options;
  int? selectedPosition;
  final Function(int index, String text) onOptionSelected;

  CustomRadioCheck({
    required this.options,
    required this.onOptionSelected,
    this.selectedPosition = 0,
  });

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

class _CustomRadioCheckState extends State<CustomRadioCheck> {
  String? _selectedOption;

  @override
  void initState() {
    super.initState();
    _initializeSelectedOption();
  }

  void _initializeSelectedOption() {
    if (widget.selectedPosition != null && widget.selectedPosition! >= 0 && widget.selectedPosition! < widget.options.length) {
      _selectedOption = widget.options[widget.selectedPosition!];
    } else {
      _selectedOption = widget.options.isNotEmpty ? widget.options[0] : null;
    }
  }

  @override
  void didUpdateWidget(CustomRadioCheck oldWidget) {
    super.didUpdateWidget(oldWidget);
    // 如果 selectedPosition 发生变化,重新初始化选中项
    if (oldWidget.selectedPosition != widget.selectedPosition) {
      _initializeSelectedOption();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8.0,
      runSpacing: 8.0,
      children: widget.options.map((option) {
        return _buildRadioWithIconAndText(
          path: option == _selectedOption ? Assets.cptAuthLoginRadioChecked : Assets.cptAuthLoginRadioUncheck,
          text: option,
          value: option == _selectedOption,
          onChanged: (value) {
            setState(() {
              _selectedOption = option;
              int selectedIndex = widget.options.indexOf(option);
              widget.onOptionSelected(selectedIndex, option);
            });
          },
        );
      }).toList(),
    );
  }

  Widget _buildRadioWithIconAndText({
    required String path,
    required String text,
    required bool value,
    required Function(bool) onChanged,
  }) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        MyAssetImage(path, width: 22, height: 22),
        SizedBox(width: 10),
        MyTextView(
          text.tr,
          textColor: ColorConstants.white,
          fontSize: 14,
          isFontRegular: true,
        ),
      ],
    ).marginOnly(right: 20, bottom: 5).onTap(() {
      onChanged(true);
    });
  }
}

CustomCheckBox:

class CustomCheckBox extends StatefulWidget {
  final List<String> options;
  final Function(List<int> selectedIndexes) onOptionsSelected; // 选中项的索引回调
  final List<String> selectedOptions; // 已选中的选项列表

  CustomCheckBox({
    required this.options,
    required this.onOptionsSelected,
    required this.selectedOptions,
  });

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

class _CustomCheckBoxState extends State<CustomCheckBox> {
  late List<String> _selectedOptions;
  late List<int> _selectedIndexes;

  @override
  void initState() {
    super.initState();
    _initializeSelectedOptions();
  }

  void _initializeSelectedOptions() {
    _selectedOptions = List.from(widget.selectedOptions);
    _selectedIndexes =
        widget.options.asMap().entries.where((entry) => _selectedOptions.contains(entry.value)).map((entry) => entry.key).toList(); // 根据选项匹配初始化索引
  }

  @override 
  void didUpdateWidget(CustomCheckBox oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.selectedOptions != widget.selectedOptions) {
      _initializeSelectedOptions();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 15.0,
      runSpacing: 15.0,
      children: widget.options.asMap().entries.map((entry) {
        int index = entry.key;
        String option = entry.value;
        return _buildCheckBoxWithIconAndText(
          path: _selectedIndexes.contains(index) ? Assets.baseServiceCheckBoxChecked : Assets.baseServiceCheckBoxUncheck,
          text: option,
          value: _selectedIndexes.contains(index),
          onChanged: (isChecked) {
            setState(() {
              if (isChecked) {
                if (!_selectedOptions.contains(option)) {
                  _selectedOptions.add(option);
                  _selectedIndexes.add(index);
                }
              } else {
                _selectedOptions.remove(option);
                _selectedIndexes.remove(index);
              }
              widget.onOptionsSelected(_selectedIndexes);
            });
          },
        );
      }).toList(),
    );
  }

  Widget _buildCheckBoxWithIconAndText({
    required String path,
    required String text,
    required bool value,
    required Function(bool) onChanged,
  }) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        MyAssetImage(path, width: 20.5, height: 20.5),
        SizedBox(width: 10),
        MyTextView(
          text.tr,
          textColor: ColorConstants.white,
          fontSize: 14,
          isFontRegular: true,
        ),
      ],
    ).onTap(() {
      onChanged(!value); // 点击时切换选中状态
    });
  }
}

内部有一些封装的控件和扩展,你完全可以自己实现,或者查看我的项目【传送门】来参考实现。因为这不是本文的重点,我就不介绍了。

如何使用呢,也是很简单:

【Flutter】基于 StatefulWidget 实现的自定义的多选组与单选组

【Flutter】基于 StatefulWidget 实现的自定义的多选组与单选组

在我们进入页面的时候 state.indexEntity 是 null, 所以是空的选项,当我们请求了接口之后,给 state.indexEntity 赋值,并刷新页面之后就会展示对应的选项和复显。

不管你是用什么状态管理工具,Getx?Bloc 都是一样的操作逻辑。

选项和复显逻辑如下:

【Flutter】基于 StatefulWidget 实现的自定义的多选组与单选组

在我们这种表单很多的页面,还是很有必要这么做的,那么你怎么看呢?

总结

本文我们简单的回顾了 StatefulWidget 和 StatelessElement 的区别,以及 didUpdateWidget 的触发时机和需要的状态处理,继而实现了选项与复显的结合。

在开发中对应重复出现的一些控件,常用的组合最好是自行进行封装,一是为了UI和产品的需求,二也是为了后期的扩展。

本文的代码比较简单,相对比较基础,并且全部的代码也已经在文中展出,有需要可以参考我的 Demo 实现【传送门】

那么今天的分享就到这里啦,当然如果你有其他的更多的更好的实现方式,也希望大家能评论区交流一起学习进步。如果我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,同学们都可以指出修正。

如果感觉本文对你有一点的启发和帮助,还望你能点赞支持一下,你的支持对我真的很重要。

Ok,这一期就此完结。

【Flutter】基于 StatefulWidget 实现的自定义的多选组与单选组

转载自:https://juejin.cn/post/7397285224349351974
评论
请登录