跟🤡杰哥一起学Flutter (九、UI实战-加载状态组件封装💁‍♂️)

🐶 上节《进阶-Flutter异步编程速通》好像有点干,有读者私聊我说啃不太动,能不能写点应用实例助消化。

行叭,本节轻松一点,带着大伙来封装个用于显示 加载状态 的组件,依次通过 setState()FutureBuilde r 和 StreamBuilder 的方式实现一波~

1. setState() 实现

😀 先简单枚举下常规的加载状态:

dart 复制代码
/// 加载状态枚举
enum LoadingStatus {
  initial, // 初始状态
  content, // 显示内容
  loading, // 加载中
  empty, // 数据空
  disconnect, // 无网络
  error // 错误
}

接着是切换加载状态的核心逻辑:

控件构造方法传入一个加载状态参数 + 组件根据这个参数构造不同加载状态的Widget返回

父组件 setState() 会导致子组件重建,所以可以理解为 加载状态组件的状态是确定(不变) 的,所以继承StatelessWidget 而不是 StatefulWidget。而 加载状态组件 的布局诉求:

默认填满父控件的剩余空间,如果内容控件的视图的高度 > 父控件的剩余高度,支持滚动;

需要用到的几个组件:

  • LayoutBuilder :允许创建一个Widget,可以根据父控件的约束动态布局,它的 builder() 函数提供了当前BuildContext 和 父Widget传递的 BoxConstraints
  • SingleChildScrollView:支持滚动的组件;
  • ConstrainedBox:用于对子Widget应用额外的约束,设置最小宽度和最小高度,使其和父布局一样大;
  • IntrinsicHeight:让子组件的高度适应实际的大小;

弄清楚要用到哪些组件后,写出具体的实现代码:

dart 复制代码
/// 显示加载状态的组件
class LoadingStatusWidget extends StatelessWidget {
  final LoadingStatus? initStatus; // 初始加载状态,测试的时候用
  final Widget? contentWidget; // 显示内容面板的组件
  final Function? retryCallback; // 重试回调

  const LoadingStatusWidget({super.key, this.contentWidget, LoadingStatus? initStatus, this.retryCallback})
      : initStatus = initStatus ?? LoadingStatus.initial;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
      return SingleChildScrollView(
        child: ConstrainedBox(
          constraints: BoxConstraints(
            minWidth: constraints.maxWidth,
            minHeight: constraints.maxHeight,
          ), child: IntrinsicHeight(child: _generateStatusWidget(initStatus!)),
        ),
      );
    });
  }
}

然后是对应状态Widget动态生成的方法:

dart 复制代码
// 加载中
Widget _loadingStatusWidget() {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        SizedBox(
            width: 30,
            height: 30,
            child: CircularProgressIndicator(
              backgroundColor: Colors.grey.withAlpha(33),
              valueColor: const AlwaysStoppedAnimation(Colors.red),
              strokeWidth: 3,
            )),
        const SizedBox(height: 12),
        const Text("加载中...",
            style: TextStyle(
              fontSize: 14.0, // 字体大小
              color: Colors.grey, // 设置字体颜色
              decoration: TextDecoration.none, // 设置不显示下划线
            ))
      ],
    );
  }

// 数据为空
Widget _emptyStatusWidget() {
    return const Column(mainAxisAlignment: MainAxisAlignment.center, children: [
      Icon(LoadingStatusIcons.loadingEmpty, size: 100, color: MyColors.leiMuBlue),
      SizedBox(height: 12),
      Text("数据为空",
          style: TextStyle(
            fontSize: 14.0, // 字体大小
            color: Colors.grey, // 设置字体颜色
            decoration: TextDecoration.none, // 设置不显示下划线
          ))
    ]);
  }

// 网络不可用
Widget _disconnectStatusWidget() {
  return Column(mainAxisAlignment: MainAxisAlignment.center, children: [
    const Icon(LoadingStatusIcons.loadingDisconnect, size: 100, color: MyColors.leiMuBlue),
    const SizedBox(height: 12),
    const Text("网络不可用",
        style: TextStyle(
          fontSize: 14.0,
          color: Colors.grey,
          decoration: TextDecoration.none,
        )),
    const SizedBox(height: 12),
    GestureDetector(
        onTap: () {
          widget.retryCallback?.call();
        },
        child: const Text("重试",
            style: TextStyle(
              fontSize: 14.0,
              color: Color(0XFF4A90E2),
              decoration: TextDecoration.none,
            )))
  ]);
}

// 加载失败
Widget _errorStatusWidget() {
  return Column(mainAxisAlignment: MainAxisAlignment.center, children: [
    const Icon(LoadingStatusIcons.loadingError, size: 100, color: MyColors.leiMuBlue),
    const SizedBox(height: 12),
    const Text("加载失败...",
        style: TextStyle(
          fontSize: 14.0,
          color: Colors.grey,
          decoration: TextDecoration.none,
        )),
    const SizedBox(height: 12),
    GestureDetector(
      onTap: () {
        widget.retryCallback?.call();
      },
      child: const Text("重试",
          style: TextStyle(fontSize: 14.0, color: Color(0XFF4A90E2), decoration: TextDecoration.none)),
    )
  ]);
}

再接着用 GridView 展示下不同加载状态的效果图:

dart 复制代码
// 使用GridView展示不同加载状态的Widget效果
Widget testLoadingStatusWidget() {
  var enumList = LoadingStatus.values;
  return Container(
      alignment: Alignment.center,
      color: Colors.white,
      child: GridView.builder(
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3, // 每行两个
              childAspectRatio: 0.5 // 宽高比例
              ),
          itemCount: enumList.length,
          itemBuilder: (BuildContext context, int index) {
            return Center(
                child: LoadingStatusWidget(
                    initStatus: enumList[index],
                    contentWidget: enumList[index] != LoadingStatus.content ? null : const Text("显示内容")));
          }));
}

运行看看效果:

Tips :加载缺省页图标来源:iconfont/雨子,如何自定义字体图标可以查阅:《七、项目实战-UI部分🤷‍♀️》

最后,写一个异步请求网络如何切换加载状态的代码示例:

dart 复制代码
/// 首页
class IndexPage extends StatefulWidget {
  const IndexPage({super.key});

  @override
  State<StatefulWidget> createState() => _IndexPageState();
}

class _IndexPageState extends State<IndexPage> {
  // ① 需要定义一个记录加载状态的变量
  var _loadingStatus = LoadingStatus.initial;
  String _responseResult = '';

  void _loadRequest() {
    // 显示加载中
    _updateLoadingStatus(LoadingStatus.loading);
    Dio().get("https://www.wanandroid.com/article/list/1/json").then((response) async {
      _responseResult = "${response.data}";
      // 显示内容
      _updateLoadingStatus(LoadingStatus.content);
    }).catchError((e) {
      LogUtil.e("$e");
      _responseResult = e.toString();
      if (e is DioException) {
        if (e.error is SocketException) {
          // 网络不可用
          _updateLoadingStatus(LoadingStatus.disconnect);
        } else {
          // 其它异常
          _updateLoadingStatus(LoadingStatus.error);
        }
      } else {
        // 其它异常
        _updateLoadingStatus(LoadingStatus.error);
      }
    });
  }

  // 更新加载状态的通用方法
  void _updateLoadingStatus(LoadingStatus status) {
    setState(() {
      _loadingStatus = status;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        color: Colors.white,
        child: SafeArea(
            child: Column(children: [
          const SizedBox(height: 10),
          MaterialButton(
            onPressed: _loadRequest,
            color: MyColors.leiMuBlue,
            textColor: Colors.white,
            padding: const EdgeInsets.symmetric(vertical: 16.0),
            child: const Text('请求网络'),
          ),
          const SizedBox(height: 20),
          Expanded(
              child: LoadingStatusWidget(
                  initStatus: _loadingStatus,
                  contentWidget: Text(_responseResult,
                      style: const TextStyle(
                        fontSize: 12.0, // 字体大小
                        color: Colors.grey, // 设置字体颜色
                        decoration: TextDecoration.none, // 设置不显示下划线
                      )),
                  retryCallback: _loadRequest)),
        ])));
  }
}

运行看看效果 (有网点击请求,打开飞行模式断网再请求):

👏 不错,实现了我们想要的效果,但是这个组件用起来有些麻烦,每次使用需要:

  • ① 定义一个LoadingStatus类型的变量来保存当前所处的加载状态;
  • ② 需要定义一个更新加载状态并调用 setState() 的方法;
  • ③ 在异步任务的适当时机调用②中定义更新加载状态的方法;

能用,但也不太好用,接着用我们上节学到的 FutureBuilder 来封装下看看~

2. FutureBuilder 实现

FutureBuilder 是 Flutter 提供的 可以根据异步操作结果自动更新UI的组件,它的构造函数中的参数如下:

  • future必须,代表 FutureBuilder 需要监听的 Future,通常是IO或网络请求等耗时操作;
  • initialData:可选,提供一个初始值给后面builder回调函数,后者可以调用 snapshot.data 获取这个值;
  • builder必须,包含一个BuildContext 和 AsyncSnapshot 参数的函数,定义了根据不同的future状态构建不同的UI;

看着复杂,其实用起来很简单,future 设置一个异步任务,builder 中对异步任务的进行状态进行判断,返回对应状态的Widget即可。直接给出代码实现:

dart 复制代码
class FBLoadingStatusWidget extends StatelessWidget {
  final Future<void>? asyncTask;
  final Widget Function(dynamic) contentWidget; // 显示内容面板的组件
  final Function? retryCallback; // 重试回调

  const FBLoadingStatusWidget({super.key, required this.asyncTask, this.retryCallback, required this.contentWidget});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
      return SingleChildScrollView(
        child: ConstrainedBox(
          constraints: BoxConstraints(
            minWidth: constraints.maxWidth,
            minHeight: constraints.maxHeight,
          ),
          child: IntrinsicHeight(child: _generateStatusWidget()),
        ),
      );
    });
  }

  // 生成显示加载状态的组件
  Widget _generateStatusWidget() {
    return FutureBuilder(
        future: asyncTask,
        builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
          switch (snapshot.connectionState) {
            // 这两个状态很少发生,一般只走 waiting 和 done
            // FutureBuilder 构建时如果指定了future,会立即开始等待future的执行,通常直接进入 waiting状态 而不会走 none状态
            // active状态 只能用于具有中间值的 StreamBuilder
            case ConnectionState.none:
            case ConnectionState.active:
              return Container();
            // 异步任务执行中,但未完成,这个时候适合显示Loading
            case ConnectionState.waiting:
              return _loadingStatusWidget();
            // 异步任务执行完毕,可能是执行成功,也可能是执行失败,需要做具体判断
            case ConnectionState.done:
              if (snapshot.hasError) {
                if(snapshot.error is DioException) {
                  if((snapshot.error as DioException).error is SocketException) {
                    return _disconnectStatusWidget();
                  } else {
                    return _errorStatusWidget();
                  }
                } else {
                  return _errorStatusWidget();
                }
              } else {
                return snapshot.hasData ? contentWidget(snapshot.data) : _emptyStatusWidget();
              }
          }
        });
  }
}

调用处代码:

dart 复制代码
// 待执行的异步任务
Future _loadRequestFB() {
  return Dio().get("https://www.wanandroid.com/article/list/1/json");
}


@override
Widget build(BuildContext context) {
  return Container(
      color: Colors.white,
      child: SafeArea(
          child: Column(children: [
        const SizedBox(height: 10),
        MaterialButton(
          onPressed: _loadRequestFB,
          color: MyColors.leiMuBlue,
          textColor: Colors.white,
          padding: const EdgeInsets.symmetric(vertical: 16.0),
          child: const Text('请求网络'),
        ),
        const SizedBox(height: 20),
        Expanded(
            child: FBLoadingStatusWidget(
                asyncTask: _loadRequestFB(),
                contentWidget: (data) {
                  // 这里直接把异步任务的数据显示出来了,实际情况可以做类型强转然后再处理
                  return Text("$data",
                      style: const TextStyle(
                        fontSize: 12.0, // 字体大小
                        color: Colors.grey, // 设置字体颜色
                        decoration: TextDecoration.none, // 设置不显示下划线
                      ));
                }))
      ])));
}

运行效果和上面的 setState() 效果一致,然后有个问题:

FutureBuilder 构建时如果指定了future,会自动执行异步任务

😑 在有些场景,我们希望 在需要的时候才加载,其中一种解决方法:

定义一个Future类型的变量,初始值为null,FutureBuilder的future参数设置为这个变量,接着定义一个调用setState()的方法,在其中更新这个变量的值为具体的异步任务。

修改后的部分代码如下:

dart 复制代码
Future? _future;

void _loadRequestFB() {
  setState(() {
    _future = Dio().get("https://www.wanandroid.com/article/list/1/json");
  });
}

@override
Widget build(BuildContext context) {
     //...
	   MaterialButton(
        onPressed: _loadRequestFB,
        //...
     Expanded(
              child: FBLoadingStatusWidget(
                  asyncTask: _future,
    //...
}

👏还是比较简单的,组件使用时需要:

  • 定义一个可空的Future类型的变量_future并传递给FutureBuilder组件;
  • 定义一个更新_future的方法,在其中调用setState() 更新_future的值,触发FutureBuilder刷新UI;

🤔 不需要像 setState() 实现方式那样关注异步任务的执行状态去手动更新UI,只需关注异步任务的更新。原理其实还是setState(),只是转移到了FutureBuilder内部,不信可以看看这部分的源码:

😃 FutureBuilder的封装其实够用了,接着再试试用StreamBuilder也写下~

3. StreamBuilder 实现

StreamBuilderFutureBuilder 用法有点类似,就构造参数有些不同:

  • stream:接受一个Stream对象,用于产生异步数据;
  • builder:根据Stream的snapshot来返回不同的UI组件;

stream参数,使用 Stream 的静态方法 fromFuture() 将Future转换为一个Stream流,然后其它代码都不用动~

dart 复制代码
Widget _generateStatusWidget() {
  if (asyncTask == null) {
    return Container();
  } else {
    return StreamBuilder(
        stream: Stream.fromFuture(asyncTask!),
        builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
          //...跟上面的FutureBuilder一样的代码
        }
      }
  }

😶 运行效果,和上面的 setState() 效果一致,用法和 FutureBuilder 基本一样。不过写法并没有体验到Stream流的特性,接着改下代码,使得变得更加灵活~

先定义一个Model作为流中的数据进行传递:

dart 复制代码
class LoadingStatusModel {
  final LoadingStatus status; // 加载状态
  final dynamic data; // 数据

  LoadingStatusModel(this.status, {this.data});
}

接着继承StatefulWidget,定义一个StreamController的变量,在initState()处初始化,在dispose()处close(),在build()处根据异步任务的执行状态往Stream发送不同的加载状态信息,StreamBuilder的builder属性处,根据不同的加载状态信息,返回对应的Widget:

dart 复制代码
class SBLoadingStatusWidget extends StatefulWidget {
  final Future<dynamic>? asyncTask;
  final Widget Function(dynamic) contentWidget; // 显示内容面板的组件
  final Function? retryCallback; // 重试回调

  const SBLoadingStatusWidget({super.key, this.asyncTask, required this.contentWidget, this.retryCallback});

  @override
  State<StatefulWidget> createState() => _SBLoadingStatusWidgetState();
}

class _SBLoadingStatusWidgetState extends State<SBLoadingStatusWidget> {
  late StreamController<LoadingStatusModel> _streamController;

  @override
  void initState() {
    super.initState();
    _streamController = StreamController();
  }

  @override
  void dispose() {
    super.dispose();
    _streamController.close();
  }

  @override
  Widget build(BuildContext context) {
    // 根据异步任务的不同状态,往Stream发送不同的加载状态信息
    if (widget.asyncTask == null) {
      _streamController.add(LoadingStatusModel(LoadingStatus.initial));
    } else {
      _streamController.add(LoadingStatusModel(LoadingStatus.loading));
      widget.asyncTask!.then((value) {
        _streamController.add(LoadingStatusModel(LoadingStatus.content, data: value));
      }).catchError((error) {
        if (error is DioException) {
          if ((error).error is SocketException) {
            _streamController.add(LoadingStatusModel(LoadingStatus.disconnect));
          } else {
            _streamController.add(LoadingStatusModel(LoadingStatus.error, data: error));
          }
        } else {
          _streamController.add(LoadingStatusModel(LoadingStatus.error, data: error));
        }
      });
    }
    return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
      return SingleChildScrollView(
        child: ConstrainedBox(
          constraints: BoxConstraints(
            minWidth: constraints.maxWidth,
            minHeight: constraints.maxHeight,
          ),
          child: IntrinsicHeight(
              child: StreamBuilder(
                  stream: _streamController.stream,
                  builder: (BuildContext context, AsyncSnapshot<LoadingStatusModel> snapshot) {
                    // 每次往Stream里发信息时都会走 active 状态
                    // 根据不同的加载状态返回对应的Widget
                    if (snapshot.connectionState == ConnectionState.active) {
                      switch (snapshot.data!.status) {
                        case LoadingStatus.initial:
                          return Container();
                        case LoadingStatus.content:
                          return widget.contentWidget(snapshot.data!.data);
                        case LoadingStatus.loading:
                          return _loadingStatusWidget();
                        case LoadingStatus.empty:
                          return _emptyStatusWidget();
                        case LoadingStatus.disconnect:
                          return _disconnectStatusWidget();
                        case LoadingStatus.error:
                          return _errorStatusWidget();
                      }
                    } else {
                      return Container();
                    }
                  })),
        ),
      );
    });
  }  
}

有需要的话,这个StreamController还可以由外部传入,父容器直接往Stream里塞消息,连 setState() 都不用~

以上就是本节的全部内容,🤭 哪种写法你更喜欢呢?有更好的封装建议欢迎评论区指出,感谢~

相关推荐
王哈哈^_^1 小时前
【数据集】【YOLO】【目标检测】交通事故识别数据集 8939 张,YOLO道路事故目标检测实战训练教程!
前端·人工智能·深度学习·yolo·目标检测·计算机视觉·pyqt
cs_dn_Jie1 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic2 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿2 小时前
webWorker基本用法
前端·javascript·vue.js
cy玩具3 小时前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
qq_390161773 小时前
防抖函数--应用场景及示例
前端·javascript
John.liu_Test4 小时前
js下载excel示例demo
前端·javascript·excel
Yaml44 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事4 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶4 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json