Flutter学习 可滚动Widget 中

文章目录

5. AnimatedList

AnimatedList 和 ListView 功能差不多, 顾名思义,它在列表中插入节点或删除节点时会执行一些动画

它是一个 StatefulWidget ,对应的 State 是 AnimatedListState,添加、删除元素的方法是:

void insetItem(int index, { Duration duration = mDuration });
void removeItem(int index, AnimatedListRemovedItemBuilder builder, { Duration duration = mDuration });

5.1 实例代码

class _AnimatedListRouteState extends State<AnimatedListRoute> {
  var data = <String>[];
  int counter = 5;

  final globalKey = GlobalKey<AnimatedListState>();

  @override
  void initState() {
    for (var i = 0; i < counter; i++) {
      data.add("${i + 1}");
    }
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          AnimatedList(
              key: globalKey,
              initialItemCount: data.length,
              itemBuilder: (BuildContext context, int index,
                  Animation<double> animation) {
                // 添加列表项时会执行渐显动画
                return FadeTransition(
                    opacity: animation, child: buildItem(context, index));
              }),
          // 创建一个添加按钮
          buildAddBtn(),
        ],
      ),
    );
  }

  Widget buildAddBtn() {
    return Positioned(
      child: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          // 添加一个列表项
          data.add("${++counter}");
          // 告诉列表项有添加的列表项
          globalKey.currentState!.insertItem(data.length - 1);
        },
      ),
      bottom: 30,
      left: 0,
      right: 0,
    );
  }

  // 构建列表项
  Widget buildItem(context, index) {
    String char = data[index];
    return ListTile(
      key: ValueKey(char),
      title: Text(char),
      trailing: IconButton(
          icon: const Icon(Icons.delete),
          // 点击时进行删除
          onPressed: () => onDelete(context, index)),
    );
  }

  void onDelete(context, index) {
    setState(() {
      globalKey.currentState!.removeItem(index, (context, animation) {
        // 删除过程执行的是反向动画, animation.value 会从 1 变成0
        var item = buildItem(context, index);
        data.removeAt(index);
        return FadeTransition(
          opacity: CurvedAnimation(
            parent: animation,
            curve: const Interval(0.5, 1.0),
          ),
          // 不断缩小列表项的高度
          child: SizeTransition(
            sizeFactor: animation,
            axisAlignment: 0.0,
            child: item,
          ),
        );
      }, duration: const Duration(milliseconds: 200));
    });
  }
}

6. GridView

GridView 用于构建网格列表,构造函数如下:

class GridView extends BoxScrollView {
  GridView({
    Key? key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController? controller,
    bool? primary,
    ScrollPhysics? physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry? padding,
    required this.gridDelegate,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double? cacheExtent,
    List<Widget> children = const <Widget>[],
    ...
  }

GridView 也包含了大多数的 ListView 有的通用参数,比较重要的是 gridDelegate 这个属性,它接受一个 SliverGridDelegate,控制 GridView 子组件如何排列

  • SliverGridDelegate
    是一个抽象类, 定义了 GridView Layout相关接口,子类实现它来实现布局算法。 Flutter已经提供两个实现类,分别是:SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent

这两个用的应该会比较多,我们来使用并介绍它:

6.1 SliverGridDelegateWithFixedCrossAxisCount

横轴为固定数量子元素的布局算法,构造函数为:

SliverGridDelegateWithFixedCrossAxisCount({
    required this.crossAxisCount,
    this.mainAxisSpacing = 0.0,
    this.crossAxisSpacing = 0.0,
    this.childAspectRatio = 1.0,
    this.mainAxisExtent,
  }
  • crossAxisCount
    横轴子元素数量, 此属性确定后, 子元素在横轴的长度就确定了,即 ViewPort 横轴长度 / crossAxisCount
  • mainAxisSpacing
    主轴方向的间距
  • crossAxisSpacing
    横轴方向子元素的间距
  • childAspecRatio
    子元素在横轴长度和主轴长度的比例, 用于 crossAxisCount 指定后,子元素横轴长度就确定了,然后通过此参数值可以确定子元素在主轴的长度
  • mainAxisExtent
    主轴上每个子元素的具体长度

这里看一个例子:

 GridView(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          // 横轴三个子Widget
          crossAxisCount: 4,
          // 宽高比为 1:1
          childAspectRatio: 1.0),
      children: const [
        Icon(Icons.add),
        Icon(Icons.eleven_mp),
        Icon(Icons.ten_k),
        Icon(Icons.cake),
        Icon(Icons.beach_access),
        Icon(Icons.free_breakfast),
        Icon(Icons.all_inclusive),
      ],
    ))

Flutter学习 可滚动Widget 中

6.2 GridView.count

GridView.count 构造函数内部使用了 SliverGridDelegateWithFixedCrossAxisCount ,我们通过它可以快速的创建横轴固定数量子元素的 GridView, 上面的示例代码其实就等价于:

GridView.count(
      crossAxisCount: 4,
      childAspectRatio: 1.0,
      children: const [
        Icon(Icons.add),
        Icon(Icons.eleven_mp),
        Icon(Icons.ten_k),
        Icon(Icons.cake),
        Icon(Icons.beach_access),
        Icon(Icons.free_breakfast),
        Icon(Icons.all_inclusive),
      ],
    )

6.3 SliverGridDelegateWithMaxCrossAxisExtent

实现了横轴子元素为固定最大长度的布局算法,构造函数为:

  const SliverGridDelegateWithMaxCrossAxisExtent({
    required this.maxCrossAxisExtent,
    this.mainAxisSpacing = 0.0,
    this.crossAxisSpacing = 0.0,
    this.childAspectRatio = 1.0,
    this.mainAxisExtent,
  }

属性和之前所学基本一模一样,而 maxCrossAxisExtent 为子元素在横轴上的最大长度, 如果 ViewPort 的横轴长度是 450,那么当 maxCrossAxisExtent 的值在区间 [450/4, 450/3] 的话,子元素最终实际长度都是 112.5 而 childAspectRatio 是指子元素横轴和主轴的长度比

下面看一个例子:

 GridView(
        padding: EdgeInsets.zero,
        gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
            maxCrossAxisExtent: 120.0, childAspectRatio: 2.0 // 宽高比为2
            ),
        children: const [
          Icon(Icons.add),
          Icon(Icons.eleven_mp),
          Icon(Icons.ten_k),
          Icon(Icons.cake),
          Icon(Icons.beach_access),
          Icon(Icons.free_breakfast),
          Icon(Icons.all_inclusive),
        ],
      ),

Flutter学习 可滚动Widget 中

6.4 GridView.extent

上面的代码等价于:

GridView.extent(
        padding: EdgeInsets.zero,
        maxCrossAxisExtent: 120.0, childAspectRatio: 2.0,
        // 宽高比为2
        children: const [
          Icon(Icons.add),
          Icon(Icons.eleven_mp),
          Icon(Icons.ten_k),
          Icon(Icons.cake),
          Icon(Icons.beach_access),
          Icon(Icons.free_breakfast),
          Icon(Icons.all_inclusive),
        ],
      ),

6.5 GridView.builder

上面我们介绍 GridView 都需要一个 widget 数组作为其子元素,这些方式都会提前将所有子 widget 都构建好,所以只适用于子Widget数量比较少的时候,当使用较多的时候,和 ListView一样, 使用 GridView.builder 来构建子 Widget, GridView.builder 必须指定两个参数:

GridView.builder(
 ...
 required SliverGridDelegate gridDelegate, 
 required IndexedWidgetBuilder itemBuilder,
)

6.5.1 范例

class _GridViewRouteRouteState extends State<GridViewRoute> {
  // icon 数据源
  List<IconData> _icons = [];

  @override
  void initState() {
    super.initState();
    // 初始化数据
    _retrieveIcons();
  }

   // 模拟异步加载数据
  void _retrieveIcons() {
    Future.delayed(const Duration(milliseconds: 200)).then((value) {
      setState(() {
        _icons.addAll([
          Icons.add,
          Icons.eleven_mp,
          Icons.ten_k,
          Icons.cake,
          Icons.beach_access,
          Icons.free_breakfast,
          Icons.all_inclusive
        ]);
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GridView.builder(
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              // 每行4列
              crossAxisCount: 3,
              childAspectRatio: 1.0),
          itemCount: _icons.length,
          itemBuilder: (context, index) {
            if (index == _icons.length -1 && _icons.length < 200) {
              _retrieveIcons();
            }
            return Icon(_icons[index]);
          }),
    );
  }
}

Flutter学习 可滚动Widget 中

7. PageView 与 页面缓存

7.1 PageView

在Android中,如果需要实现页面的切换,可以使用 PageView,Flutter也有同名同作用的 Widget, 下面是它的构造函数:

  PageView({
    Key? key,
    this.scrollDirection = Axis.horizontal,
    this.reverse = false,
    PageController? controller,
    this.physics,
    this.pageSnapping = true,
    this.onPageChanged,
    List<Widget> children = const <Widget>[],
    this.dragStartBehavior = DragStartBehavior.start,
    this.allowImplicitScrolling = false,
    this.restorationId,
    this.clipBehavior = Clip.hardEdge,
    this.scrollBehavior,
    this.padEnds = true,
  }
  • pageSnapping
    每次滑动是否强制切换整个画面,如果为false,会根据实际的滑动距离显示页面
  • this.allowImplicitScrolling
    主要是配合辅助功能使用
  • padEnds
    下面会讲解

我们看一个 Tab 切换的实例,每个Tab都只显示一个数字,然后总共有6个Tab:

class _PageViewRouteState extends State<PageViewRoute> {
  @override
  Widget build(BuildContext context) {
    var children = <Widget>[];
    // 设置六个Tab
    for (int i = 0; i < 6; i++) {
      children.add(Page(
        text: "$i",
      ));
    }
    return Scaffold(body: PageView(children: children));
  }
}

class Page extends StatefulWidget {
  const Page({Key? key, required this.text}) : super(key: key);

  final String text;

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

class _PageState extends State<Page> {
  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(
        child: Text(
      widget.text,
      textScaleFactor: 5,
    ));
  }
}

接下来就可以正常滑动Tab了,实现还是比较简单的

7.2 页面缓存

在上面的示例中,每次页面的切换,可都会触发新 Page 页的 build,这说明 PageView 默认没有缓存功能,一旦某个Page画出页面就会被销毁

这是因为 PageView 没有透传 cacheExtent 给 Viewport,所以 Viewport 默认 cacheExtent为1, 但是却在 allowImplicitScrolling 为 true 时设置了预渲染区域, 此时将会设置缓存类型为 CacheExtentStyle.viewport ,则 cacheExtent 则表示缓存的长度是几个 Viewport 的宽度, cacheExtent 为1.0,则代表前后各缓存一页。
也就是说,将 PageView 的 allowImplicitScrolling 设置为 true 时,就会缓存前后两页的Page

问题的根源貌似是 在 PageView 中设置 cacheExtent 会和 iOS的辅助功能有冲突,没有更好的解决方法,Flutter就带着这个问题。但是国内基本不用考虑用辅助功能,所以想到的解决方案就是 拷贝一份PageView 的源码,然后透传 cacheExtent 即可。

当然,Flutter还提供了更通用的解决方案,就是缓存子项的解决方案

8. 可滚动组件子项缓存 KeepAlive

在 ListView 的构造函数中有一个 addAutomaticKeepAlives 属性没有介绍,如果为 true, ListView就会为其每一个子项添加一个 AutomaticKeepAlive 父组件。 虽然 PageView 的默认构造函数和 PageView.build 构造函数中没有该参数,但他们最终都会生成一个 SliverChildDelegate,这个组件会在每个列表子项构建完成时,为其添加一个 AutomaticKeepAlive 的父组件,下面来介绍一个这个父组件。

8.1 AutomaticKeepAlive

AutomaticKeepAlive 组件的主要作用是将列表项的 根RenderObject 的 keepAlive 按需自动标记为true或false。
就是 列表项的根 Widget, 这里将 Viewport+cacheExtent称为 加载区域

  1. 当 keepAlive 为false时,如果列表项滑出加载区域,列表组件会被销毁
  2. 当 keepAlive 为true时,当列表项滑出加载区域后,Viewport会将列表项组件缓存起来,当列表项进入加载区域时,Viewport先从缓存中查找是否已经缓存,如果有直接复用,如果没有重新创建列表项

而 AutomaticKeepAlive 这个标识位的设置,其实全靠我们开发者来控制的。

所以为了让 PageView 实现多页面的缓存,我们的思路就是让 PageView 来讲 AutomaticKeepAlive 置为true,Flutter 的做法就是让列表项组件 混入一个 AutomaticKeepAliveClientMixin,然后实现 wantKeepAlive() 就可以了,代码如下:

class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    ...
  }

  @override
  bool get wantKeepAlive => true; // 需要缓存
}

在实现了 wantKeepAlive 后,还必须要在 build 方法中调用一下 super.build(context),它会将 keepAlive 的信息通知出去。

需要注意的是,如果我们采用 PageView.cutom 构建页面时,没有给列表项包装 AutomaticKeepAlive 父组件,则上述方案不能正常工作。

9. TabBarView

TabBarView 是 Marterial 提供的 Tab 组件,通常和 TabBar 搭配使用

9.1 TabBarView

TabBarView 封装了 PageView,构造函数如下:

  const TabBarView({
    Key? key,
    required this.children, // Tab 页面
    this.controller, // TabController
    this.physics,
    this.dragStartBehavior = DragStartBehavior.start,
  })

这里的 TabController 是用来监听和控制 TabBarView 的页面切换,通常是和 TabBar 来联动的,如果没有指定会默认在组件树上查找最近一个使用的 DefaultTabController

9.2 TabBar

TabBar 的许多属性都是用来配置 指示器 和 label 的,我们来看下其构造函数:

  const TabBar({
    Key? key,
    // 具体的 Tab 数组,需要我们来创建
    required this.tabs,
    // TabController,用于和 TabBarView 联动
    this.controller,
    // 是否可以滑动
    this.isScrollable = false,
    this.padding,
    // 指示器颜色
    this.indicatorColor,
    this.automaticIndicatorColorAdjustment = true,
    // 指示器高度,默认为2
    this.indicatorWeight = 2.0,
    this.indicatorPadding = EdgeInsets.zero,
    // 指示器 Decoration
    this.indicator,
    // 指示器长度,两个可选值,一个是 Tab 长度,一个是 label 长度
    this.indicatorSize,
    this.labelColor,
    this.labelStyle,
    this.labelPadding,
    ...
  })

TabBar 和 TabBarView 是靠 TabController 进行联动的, 需要注意的是, TabBar 和 TabBarView 的孩子数量需要一致。

tab 是 TabBar 的孩子,可以是任意 Widget, 不过 Material 已经实现了默认的 Tab 组件给我们使用:

const Tab({
  Key? key,
  this.text, //文本
  this.icon, // 图标
  this.iconMargin = const EdgeInsets.only(bottom: 10.0),
  this.height,
  this.child, // 自定义 widget
})

其中 text 和 child 是互斥的

9.3 示例

代码如下:

class _TabBarViewRouteState extends State<TabBarViewRoute>
    with SingleTickerProviderStateMixin {
  final ScrollController _controller = ScrollController();

  late TabController _tabController;
  List tabs = ["吃的", "穿的", "住的", "行的"];

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: tabs.length, vsync: this);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: const Text("TabBarView"),
            bottom: TabBar(
              controller: _tabController,
              tabs: tabs.map((e) => Tab(text: e)).toList(),
            )),
        body: TabBarView(
          controller: _tabController,
          children: tabs.map((e) {
            return Container(
              alignment: Alignment.center,
              child: Text(e, textScaleFactor: 5),
            );
          }).toList(),
        ));
  }

  @override
  void dispose() {
    // 释放资源
    _tabController.dispose();
    super.dispose();
  }
}

Flutter学习 可滚动Widget 中
由于 TabController 又需要一个 TickerProvider (vsync 参数),所以我们又混入了 SingleTickerProviderStateMixin,由于 TabController 会执行动画,持有一些资源,所以我们在页面销毁时需要释放资源(dispose)

综上,我们发现创建 TabController 的过程还是比较复杂的,实战中,如果需要 TabBar 和 TabBarView 联动,通常会创建一个 DefaultTabController 作为它们的共同父级组件,这样它们在执行的时候就会从组件向上查找,都会使用我们指定的这个 Controller。代码如下:

    return DefaultTabController(
        length: tabs.length,
        child: Scaffold(
            appBar: AppBar(
                title: const Text("TabBarView"),
                bottom: TabBar(
                  controller: _tabController,
                  tabs: tabs.map((e) => Tab(text: e)).toList(),
                )),
            body: TabBarView(
              controller: _tabController,
              children: tabs.map((e) {
                return Container(
                  alignment: Alignment.center,
                  child: Text(e, textScaleFactor: 5),
                );
              }).toList(),
            )));
  }

这样我们就不需要手动去管理 Controller 的生命周期(无需手动释放),也不需要混合 SingleTickerProviderStateMixin 了。

10. CustomScrollView 和 Slivers

10.1 CustomScrollView

前面学习的 ListView 、PageView、 GridView 都是完整的可滚动组件,这是因为它们都包含了 Scrollable、 Viewport、Sliver 三大要素。

假如我们想要在一个页面中,同时包含多个可滚动组件,且使他们的效果能够统一起来,比如,想要将两个沿垂直方向滚动的 ListView 合成一个 ListView, 在第一个 ListView 滑动到底部的时候能够接上第二个 ListView, 先尝试下一下代码:

class _CustomViewRouteState extends State<CustomViewRoute> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(body: buildTwoListView());
  }

  Widget buildTwoListView() {
    var listView = ListView.builder(
        itemBuilder: (_, index) => ListTile(title: Text("$index")),
        itemCount: 20);

    return Column(
      children: [
        Expanded(child: listView,),
        const Divider(color: Colors.grey),
        Expanded(child: listView)
      ],
    );
  }

Flutter学习 可滚动Widget 中
页面中有两个 ListView,各占一半,虽然能够显示且滑动,但是每个 ListView 只会响应自己可视区域中的滑动,实现不了我们想要的效果。 之所以这样的原因是两个 ListView 都有自己独立 Scrollable、 Viewport、Sliver

所以,我们需要给他们创建共用的 Scrollable、 Viewport 对象,然后将两个 ListView 对应的 Sliver 添加到这个共用的 Viewport 对象中就可以实现想要的效果了。 但是实现起来无疑是很复杂的, 所以 Flutter 提供了一个 CustomScrollView 来帮助创建公共的 Scrollable 和 Viewport,然后接受一个 Sliver 数组,代码如下:

  Widget buildTwoListView() {
    var listView = SliverFixedExtentList(
        itemExtent: 50,
        delegate: SliverChildBuilderDelegate(
            (_, index) => ListTile(title: Text("$index")),
            childCount: 15));

    return CustomScrollView(
      slivers: [listView, listView],
    );
  }

其中 SliverFixedExtentList 是一个 Sliver,它可以生成高度相同的列表项,如果列表项高度相同,应该优先使用 SLiverFixedExtentList 或者 SliverPrototypeExtentList,如果不同再使用 SliverList
Flutter学习 可滚动Widget 中
这就达到我们想要的效果了。

10.2 Flutter 中常用的 Sliver

前面介绍了 SLiverFixedExtentList 是高度固定的列表,除此之外,还有其他的 Sliver , 如下图所示:
Flutter学习 可滚动Widget 中
上面这是都是和列表对应的 Sliver, 还有一些是用于对 Sliver 进行布局、装饰的组件,例如:
Flutter学习 可滚动Widget 中
Sliver 系列的组件会比较多,这里只需要去记住其特点即可。

10.2.1 示例

下面是官方的 Demo:

  @override
  Widget build(BuildContext context) {
    return Material(
        child: CustomScrollView(
      slivers: [
        // App Bar, 是一个导航栏
        SliverAppBar(
          // 滑动到顶端时会固定住
          pinned: true,
          expandedHeight: 250.0,
          flexibleSpace: FlexibleSpaceBar(
            title: const Text("Sliver Demo"),
            background: Image.asset("images/bobo.jpg", fit: BoxFit.cover),
          ),
        ),
        SliverPadding(
          padding: const EdgeInsets.all(10.0),
          sliver: SliverGrid(
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                // 两列显示
                crossAxisCount: 2,
                mainAxisSpacing: 10.0,
                crossAxisSpacing: 10.0,
                childAspectRatio: 3.0),
            delegate: SliverChildBuilderDelegate((context, index) {
              return Container(
                alignment: Alignment.center,
                // 渐变
                color: Colors.cyan[100 * (index % 9)],
                child: Text("grid item $index"),
              );
            }, childCount: 20),
          ),
        ),
        SliverFixedExtentList(
            delegate: SliverChildBuilderDelegate((context, index) {
              return Container(
                alignment: Alignment.center,
                color: Colors.lightBlue[100 * index % 9],
                child: Text("list item $index"),
              );
            }, childCount: 20),
            itemExtent: 50.0),
      ],
    ));
  }

效果为:
Flutter学习 可滚动Widget 中
Flutter学习 可滚动Widget 中

10.2.2 SliverToBoxAdapter

出现了:列表项必有的适配器!

在实际布局中,我们通常都要往 CustomScrollView 去添加自定义组件,但往往这些组件并非有 Sliver 版本,为此 Flutter 提供了一个适配器组件: SliverToBoxAdapter可以将 RenderBox 适配为 Sliver,比如我们想要在列表顶部添加一个可以横向滑动的 PageView, 可以使用 SliverToBoxAdapter

CustomScrollView(
        slivers: [
          SliverToBoxAdapter(
            child: SizedBox(
              height: 300.0,
              child: PageView(
                children: const [Text("1"), Text("2")],
              ),
            ),
          ),
          buildSliverFixedList(),
        ],
      ),

PageView 没有 Sliver 版本,所以使用了上面的代码中添加了这个适配器。

但是要注意的是, 如果将 PageView 替换成一个滑动方向和父组件 CustomScrollView 的ListView,则不会正常工作。 原因是: CustomScrollView 为所有 子Sliver 提供一个共享的 Scrollable, 然后统一处理指定滑动方向的滑动事件, 如果 Sliver 中引入了其他 Scrollable,就会产生滑动事件冲突。 最终效果是 ListView 内滑动只会对 ListView 起作用, Flutter 中的手势冲突,默认是子元素生效

CustomScrollView 引入一个滑动方向一样的子组件,则不能正常工作,为了解决这个问题,可以换成 NestedScrollView

10.2.3 SliverPersistentHeader

SliverPersistentHeader 的功能是滑动到 CustomScrollView 顶部时,将组件固定在顶部

可是之前已经学到 SliverAppBar 这个玩意了,其实 Flutter 设计这个组件的 初衷就是为了实现 SliverAppBar, 所以他们的属性和回调在 SlvierAppBar 中才会用到。

我们来看看其定义:

  const SliverPersistentHeader({
    Key? key,
    required this.delegate,
    this.pinned = false,
    this.floating = false,
  })
  • delegate
    用于构造 header 组件委托
  • floating
    pinned 为 false 时, 则 header 可以滑出可视区域 (Viewport),当用户再次向下滑动时,此时不管 header 已经被滑出了多远,它都会立即出现在可视区域顶部并被固定住, 直到继续下滑到 header 在列表中原有的位置时, header 才会重新回到原来的位置

大家可以看下官网得到 delegate 构建: 官网 SliverPersistentHeaderDelegate 封装实现实现如下效果:
Flutter学习 可滚动Widget 中

上一篇:android MediaRecorder录音,flutter加载本地图片批量配置


下一篇:2021年终总结,一个27岁程序员图文自述