比 Flutter ListView 更灵活的布局方式

大家好,我是 17。

在 Flutter 中,涉及到滚动布局的时候,很多同学会大量使用 ListView

ListView 的局限

没错,在实现效果的方面 ListView 确实能做到大多数,但是有些情况下会很别扭,性能也不好。你可能遇到过下面的设计:

banner 和下面的列表是一起滚动的。如果用 ListView ,你一定可以马上写出代码:

ListView.builder(
    itemBuilder: (context, index) {
      if (index == 0) {
        return Container(
          height: 100,
          color: Colors.blue,
          child: Text('banner'),
        );
      } else {
       return ListTile(title: Text('${index - 1}'));
      }
    },
    itemCount: 100
  )

上面的代码会有一个问题,banner 的高度和下面列表的高度不一样,导致无法使用定高列表,造成性能下降。需要 if else,如果有多个 banner,if else 也要多个,那就相当复杂了。

还有一个问题,在你没有设置任何边距的情况下,ListView 和上面的 Widget 可能会一段空白。

你需要这样去除空白。

ListView.builder(
        padding: EdgeInsets.zero,

为什么会有空白呢?这是因为 ListView 继承自 BoxScrollView,它的主要贡献就是加了这个空白!

这个空白的值是多少呢?就是取的 mediaQuery 的 padding。因为浏海屏的出现,ios 中,上面和下面会有一部分不适合显示主要内容,所以就有了这个安全 padding。BoxScrollView 在设计的时候也考虑到了这一点,于是就默认加了这个 padding。但实际上,如果 listView 不是在最顶部,反而是帮了倒忙。

ListView 最理想的使用场景是展示的 item 都一样高,但多数情况下,item 是不一样高的。ListView 出现的目的是为了方便使用,但却是牺牲了灵活性。它只能有一个 SliverChild,这会导致 itemBuilder 函数逻辑的复杂和性能的下降。

更灵活的布局方式

其实我们可以直接从 ScrollView 继承,根据实际情况定制需要的组件。说到定制你可能会觉得一定很复杂,实际上是非常简单的,而且因为我们是根据业务量身定做的组件,所以用起来会特别顺手。

要用 ScrollView 实现上面的设计,只需要下面的代码:

class MyListView extends ScrollView {
  const MyListView(
      {Key? key,
      this.banner,
      required this.itemBuilder,
      required this.itemExtent,
      required this.itemCount})
      : super(key: key);
  final Widget? banner;
  final IndexedWidgetBuilder itemBuilder;
  final double itemExtent;
  final int itemCount;

  @override
  List<Widget> buildSlivers(BuildContext context) {
    List<Widget> list = [];
    if (banner != null) {
      list.add(SliverToBoxAdapter(child: banner!));
    }
    list.add(SliverFixedExtentList(
      delegate: SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
      itemExtent: itemExtent,
    ));
    return list;
  }
}

很简单吧。实际上,我们只是 override buildSlivers 方法,生成一个 list。SliverToBoxAdapter 可以看作是一个转换器,把普通的 Widget 转换为 Sliver Widget。虽然 buildSlivers 的返回值是 List<Widget> ,但实际上,Widget 应该是 Sliver Widget,否则无法滚动。

MyListView 使用起来也很方便,代码更简洁,没有了讨厌的 if else 了。

MyListView(
     banner: Container(color: Colors.green, height: 100),
     itemExtent: 20,
     itemCount: 100,
     itemBuilder: (context, index) => Text('$index'),
)

现在 banner 和 item 的逻辑是分开的,代码更加清晰,也更好维护。把 banner 这个高度不一样的 widget 分开后,剩下的 item 高度都是一样的,本例中,我们设置固定高度 itemExtent: 20,每个 item 的高度都是 20,在 buildSlivers 中用 itemExtent 做为参数,用 SliverFixedExtentList 生成定高列表,性能得到大大提高。

老板说,把第 10 条数据显示在第一的位置

这个需求还是很常见的,在某个时刻,需要把某条数据显示在第一的位置。如果用 ListView 实现起来不容易,你可能想要调整数据的位置,但需求是数据的位置不变,只是想让 ViewPort 滚动到 第 10 条数据的位置。你可能还想到了用 ListView 的 controller 来控制滚动位置,尝试一下可以知道并不方便实现,或者实现了也不方便维护。

直接用 ScrollView 就很简单了。 ScrollView 有一个参数可以直接实现在这样的功能,这个参数就是 center。你可能很奇怪,ListView 是从 BoxScrollView 继承,BoxScrollView 是从 ScrollView 继承,但是在 ListView 中没有发现这个参数啊?为了方便使用,BoxScrollView 只有一个 Sliver Child,center 参数没有了用武之地,在 ListView 中找不到这个参数也就不奇怪了。

实现功能

先看下效果,不使用 center 参数,banner 在第一个位置显示。

使用 center 参数后,第 10 条数据,自动显示在第一个位置。

下面是完整代码,贴到 main.dart 就能运行

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(body: MyWidget()),
    );
  }
}

class MyWidget extends StatefulWidget {
  const MyWidget({super.key});

  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
        child: MyListView(
            banner: Container(
              color: Colors.blue[100],
              alignment: Alignment.center,
              height: 100,
              child: const Text(
                'IAM17 Flutter 天天更新',
              ),
            ),
            itemBuilder: (context, index) {
              return ListTile(
                title: Text('$index'),
              );
            },
            center: const ValueKey(9),
            itemExtent: 20,
            itemCount: 100));
  }
}

class MyListView extends ScrollView {
  const MyListView(
      {Key? key,
      this.banner,
      required this.itemBuilder,
      required this.itemExtent,
      required this.itemCount,
      Key? center})
      : super(key: key, center: center);
  final Widget? banner;
  final IndexedWidgetBuilder itemBuilder;
  final double itemExtent;
  final int itemCount;

  @override
  List<Widget> buildSlivers(BuildContext context) {
    List<Widget> list = [];
    if (banner != null) {
      list.add(SliverToBoxAdapter(child: banner!));
    }
    if (center == null) {
      list.add(SliverFixedExtentList(
        delegate:
            SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
        itemExtent: itemExtent,
      ));
    } else {
      for (var i = 0; i < itemCount; i++) {
        list.add(SliverToBoxAdapter(
          key: ValueKey(i),
          child: itemBuilder(context, i),
        ));
      }
    }
    return list;
  }
}

当 center 不为 null 的时候,放弃使用 SliverFixedExtentList,因为 SliverFixedExtentList 并没有 center 参数,所以只好把 child 一个一个加到 list 中。这样会损失一些性能,但能快速实现需求,还是值得的。

center 参数是如何影响位置的?

在 ViewPort 的构造函数中有一个 assert,如果 center 不为空,那么在 slivers 中必须要找到 key 为 center 的 child。

Viewport({
    ...
    this.center,
    ...
  }) : 
       assert(center == null || slivers.where((Widget child) => child.key == center).length == 1),

最终是给 ViewPort 对应的 renderObject 的 center 赋值。

代码位置 : flutter/lib/src/widgets/viewport.dart

void _updateCenter() {
    // TODO(ianh): cache the keys to make this faster
    final Viewport viewport = widget as Viewport;
    if (viewport.center != null) {
      int elementIndex = 0;
      for (final Element e in children) {
        if (e.widget.key == viewport.center) {
          renderObject.center = e.renderObject as RenderSliver?;
          break;
        }
        elementIndex++;
      }
      assert(elementIndex < children.length);
      _centerSlotIndex = elementIndex;
    } else if (children.isNotEmpty) {
      renderObject.center = children.first.renderObject as RenderSliver?;
      _centerSlotIndex = 0;
    } else {
      renderObject.center = null;
      _centerSlotIndex = null;
    }
  }

总之,就是通过 key 找到对应的 Sliver Widget,对应到 renderObject,实现 center 的功能。

简单有简单的代价,复杂有复杂的理由。通过这个简单的案例说明,我们应该自己动手定制适合自己项目的 ”ListView“!通过简单的封装,就能让我们的代码更简洁,更容易维护,性能也会更好。

更多关于滚动的参数介绍可以看这篇 flutter 滚动的基石 Scrollable

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容