[译]Flutter - 掌握ListView

2,672 阅读8分钟

原文在这里

介绍

如果你了解Android或者iOS的开发,你会喜欢Flutter ListView的简洁。本文中,我们就是用几个简单的例子来实现一些很常用的情景。

首先,来看看ListView的几种类型。之后介绍如何处理每个item的style。最后,如何添加和删除item。

准备工作

我(作者)假设你已经把Flutter的开发环境都搭建好了。而且你也对Flutter有基本的了解。如果不是,那么以下的连接可以帮助你:

我在使用的是Android Studio,如果你用的是其他的IDE也OK。

开始

新建一个叫做flutter_listview的项目。

打开main.dart文件,使用下面的代码替换掉之前的:

import 'package:flutter/material.dart';

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

    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          title: 'ListViews',
          theme: ThemeData(
            primarySwatch: Colors.teal,
          ),
          home: Scaffold(
            appBar: AppBar(title: Text('ListViews')),
            body: BodyLayout(),
          ),
        );
      }
    }

    class BodyLayout extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return _myListView(context);
      }
    }

    // replace this function with the code in the examples
    Widget _myListView(BuildContext context) {
      return ListView();
    }

注意最后的_myListView方法,这里的代码就是我们后面要替换掉的。

ListView的基本类型

静态ListView

如果你有一列数据,而且不会发生太大的更改,那么静态ListView就是最好的选择了。尤其是对于设置这样的页面来说最合适不过。

替换_myListView的代码:

Widget _myListView(BuildContext context) {
      return ListView(
        children: <Widget>[
          ListTile(
            title: Text('Sun'),
          ),
          ListTile(
            title: Text('Moon'),
          ),
          ListTile(
            title: Text('Star'),
          ),
        ],
      );
    }

运行代码,会是这个样子的。(虽然hot reload一般没什么问题,不过偶尔还是需要用hot restart甚至关掉重新运行才行)。

代码的三层关系就是ListView的children是一个包含了三个ListTile的数组。ListTile是定义好的,专门处理ListView的item的布局的。我们上面的例子里面只包含了一个title属性。下面的例子会包含一些样式。

如果要给ListView添加分割线,那么可以使用ListTile.divideTiles

Widget _myListView(BuildContext context) {
    return ListView(
        children: ListTile.divideTiles(
          context: context,
          tiles: [
            ListTile(
              title: Text('Sun'),
            ),
            ListTile(
              title: Text('Moon'),
            ),
            ListTile(
              title: Text('Star'),
            ),
          ],
        ).toList(),
      );
}

仔细看,你就会发现分割线已经在了。

动态ListView

静态ListView的所有元素都一起和ListView创建好了。这对于很少数据的处理是可以的。下面就来介绍一下处理很多数据的时候使用的ListView.builder()。这个方法只会处理要在屏幕上显示的数据,就和Android的RecyclerView很类似,不过用起来更简单。

使用以下的代码替换_myListView方法:

Widget _myListView(BuildContext context) {

      // backing data
      final europeanCountries = ['Albania', 'Andorra', 'Armenia', 'Austria', 
        'Azerbaijan', 'Belarus', 'Belgium', 'Bosnia and Herzegovina', 'Bulgaria',
        'Croatia', 'Cyprus', 'Czech Republic', 'Denmark', 'Estonia', 'Finland',
        'France', 'Georgia', 'Germany', 'Greece', 'Hungary', 'Iceland', 'Ireland',
        'Italy', 'Kazakhstan', 'Kosovo', 'Latvia', 'Liechtenstein', 'Lithuania',
        'Luxembourg', 'Macedonia', 'Malta', 'Moldova', 'Monaco', 'Montenegro',
        'Netherlands', 'Norway', 'Poland', 'Portugal', 'Romania', 'Russia',
        'San Marino', 'Serbia', 'Slovakia', 'Slovenia', 'Spain', 'Sweden', 
        'Switzerland', 'Turkey', 'Ukraine', 'United Kingdom', 'Vatican City'];

      return ListView.builder(
        itemCount: europeanCountries.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(europeanCountries[index]),
          );
        },
      );

    }

运行之后:

itemCount会告诉ListView有多少数据要显示,itemBuilder来动态的处理每一个要显示在ListView上的数据。这个方法的参数contextBuildContext类型的,另一个参数index则告诉用户第几个数据要显示在屏幕上了。

无限ListView

很多人都有过在Android或者iOS上构建无限滚动ListView的痛苦经历。Flutter也让这个更加简单。只要删除itemCount就可以。我们改造一下代码,让每一个ListTile显示出当前的index值。

    Widget _myListView(BuildContext context) {
        return ListView.builder(
            itemBuilder: (context, index) {
                return ListTile(
                    title: Text('row $index'),
                );
            },
        );
    }

你可以一直滚动,不会有终点。

如果你要显示分割先,只需要ListView.separated构造方法。

    Widget _myListView(BuildContext context) {
      return ListView.separated(
        itemCount: 1000,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('row $index'),
          );
        },
        separatorBuilder: (context, index) {
          return Divider();
        },
      );
    }

ListView里再次显示除了一条模糊不清的分割线。如果要修改的话可以使用Divider来更改分割线的高度颜色等参数。

横向ListView

也很容易可以新建一个横向滚动的ListView。只需要给定scrollDirection是横向的。不过还需要搭配一点定制的布局。

   Widget _myListView(BuildContext context) {
      return ListView.builder(
        scrollDirection: Axis.horizontal,
        itemBuilder: (context, index) {
          return Container(
            margin: const EdgeInsets.symmetric(horizontal: 1.0),
            color: Colors.tealAccent,
            child: Text('$index'),
          );
        },
      );
    }

样式

我们上面已经了解了所有的ListView类型。但是都不好看。Flutter提供了很多的选项可以让ListView好看。

定制ListTile

ListTile基本可以覆盖常规使用的全部定制内容。比如副标题,图片和icon等。

    Widget _myListView(BuildContext context) {
      return ListView(
        children: <Widget>[
          ListTile(
            leading: Icon(Icons.wb_sunny),
            title: Text('Sun'),
          ),
          ListTile(
            leading: Icon(Icons.brightness_3),
            title: Text('Moon'),
          ),
          ListTile(
            leading: Icon(Icons.star),
            title: Text('Star'),
          ),
        ],
      );
    }

leading是用来在ListTile的开始添加icon或者图片的

对应的还有tailing属性

   ListTile(
      leading: Icon(Icons.wb_sunny),
      title: Text('Sun'),
      trailing: Icon(Icons.keyboard_arrow_right),
    ),

tailing的箭头图标让人们以为可以点击。其实还不能点击。我们来看看如何响应用户的点击。也很简单。替换_myListView()方法的代码:

    Widget _myListView(BuildContext context) {
      return ListView(
        children: <Widget>[
          ListTile(
            leading: CircleAvatar(
              backgroundImage: AssetImage('assets/sun.jpg'),
            ),
            title: Text('Sun'),
          ),
          ListTile(
            leading: CircleAvatar(
              backgroundImage: AssetImage('assets/moon.jpg'),
            ),
            title: Text('Moon'),
          ),
          ListTile(
            leading: CircleAvatar(
              backgroundImage: AssetImage('assets/stars.jpg'),
            ),
            title: Text('Star'),
          ),
        ],
      );
    }

现在还不能用,我们先添加一些图片。

这里也可以使用NetworkImage(imageUrl)代替AssetImage(path)。暂时先用AssetImage,这样内容都在app里面了。在项目更目录下新建一个assets目录,把下面的图片都加进去。

pubspec.yaml文件注册这个目录

flutter:
    assets:
        - assets/

重新运行app(停止了再运行),会看到这样的界面:

最后再来看看副标题:

    ListTile(
      leading: CircleAvatar(
        backgroundImage: AssetImage('assets/sun.jpg'),
      ),
      title: Text('Sun'),
      subtitle: Text('93 million miles away'), //           <-- subtitle
    ),

运行结果:

卡片(Card)

Card是让你的列表看起来酷炫最简单的方法了。只需要让Card包裹ListTile。使用下面的代码替换_myListView方法

    Widget _myListView(BuildContext context) {

      final titles = ['bike', 'boat', 'bus', 'car',
      'railway', 'run', 'subway', 'transit', 'walk'];

      final icons = [Icons.directions_bike, Icons.directions_boat,
      Icons.directions_bus, Icons.directions_car, Icons.directions_railway,
      Icons.directions_run, Icons.directions_subway, Icons.directions_transit,
      Icons.directions_walk];

      return ListView.builder(
        itemCount: titles.length,
        itemBuilder: (context, index) {
          return Card( //                           <-- Card widget
            child: ListTile(
              leading: Icon(icons[index]),
              title: Text(titles[index]),
            ),
          );
        },
      );
    }

你可以修改elevation属性来修改阴影,也可以试一下shapemargin看看有什么效果。

定制列表条目

如果一个ListTile不能满足你的要求,你完全可以定制自己的。ListView需要的只不过是一组组件(widget)。任何组件都可以。我最近处理的每个条目多列的需求可以拿来做一个例子。

    Widget _myListView(BuildContext context) {

      // the Expanded widget lets the columns share the space
      Widget column = Expanded(
        child: Column(
          // align the text to the left instead of centered
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text('Title', style: TextStyle(fontSize: 16),),
            Text('subtitle'),
          ],
        ),
      );

      return ListView.builder(
        itemBuilder: (context, index) {
          return Card(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: Row(
                children: <Widget>[
                  column,
                  column,
                ],
              ),
            ),
          );
        },
      );
    }

触摸检测

如果你想要ListTile,只需要添加onTap或者onLongPress回调。

替换_myListViw方法代码:

    Widget _myListView(BuildContext context) {
      return ListView(
        children: <Widget>[
          ListTile(
            title: Text('Sun'),
            trailing: Icon(Icons.keyboard_arrow_right),
            onTap: () {
              print('Sun');
            },
          ),
          ListTile(
            title: Text('Moon'),
            trailing: Icon(Icons.keyboard_arrow_right),
            onTap: () {
              print('Moon');
            },
          ),
          ListTile(
            title: Text('Star'),
            trailing: Icon(Icons.keyboard_arrow_right),
            onTap: () {
              print('Star');
            },
          ),
        ],
      );
    }

有了onTap方法,我们就可以响应用户的点击了。这里我们print一些字符串。

在实际开发中,更有可能是点击了一行就跳转到别的页面了。可以参考响应用户输入

如果你也没有使用ListTile,而是使用了自己定制的一套组件。那么最好是做一个重构,比如本利就把他们放在一个InkWell的定制组件里了。

     return ListView.builder(
        itemBuilder: (context, index) {
          return Card(
            child: InkWell(
              onTap: () {
                print('tapped');
              },
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Row(
                  children: <Widget>[
                    column,
                    column,
                  ],
                ),
              ),
            ),
          );
        },
      );

当然如何重构的选项很多,上栗也不是唯一的标准。

更新数据

添加、删除ListView的行

很容易可以在ListView里更新数据。只需要把ListView放在一个StatefulWidget里,并在需要更新的时候调用setState方法。

比如下面的例子里有一个BodyLayout_myListViw()

    class BodyLayout extends StatefulWidget {
      @override
      BodyLayoutState createState() {
        return new BodyLayoutState();
      }
    }

    class BodyLayoutState extends State<BodyLayout> {

      List<String> titles = ['Sun', 'Moon', 'Star'];

      @override
      Widget build(BuildContext context) {
        return _myListView();
      }

      Widget _myListView() {
        return ListView.builder(
          itemCount: titles.length,
          itemBuilder: (context, index) {
            final item = titles[index];
            return Card(
              child: ListTile(
                title: Text(item),

                onTap: () { //                                  <-- onTap
                  setState(() {
                    titles.insert(index, 'Planet');
                  });
                },

                onLongPress: () { //                            <-- onLongPress
                  setState(() {
                    titles.removeAt(index);
                  });
                },

              ),
            );
          },
        );
      }
    }

点击一行,就在那一行的index上添加一行,长按就删除一行。

在AnimatedList里添加、删除行

BodyLayoutState的代码替换为下面的内容:

    class BodyLayoutState extends State<BodyLayout> {

      // The GlobalKey keeps track of the visible state of the list items
      // while they are being animated.
      final GlobalKey<AnimatedListState> _listKey = GlobalKey();

      // backing data
      List<String> _data = ['Sun', 'Moon', 'Star'];

      @override
      Widget build(BuildContext context) {
        return Column(
          children: <Widget>[
            SizedBox(
              height: 300,
              child: AnimatedList(
                // Give the Animated list the global key
                key: _listKey,
                initialItemCount: _data.length,
                // Similar to ListView itemBuilder, but AnimatedList has
                // an additional animation parameter.
                itemBuilder: (context, index, animation) {
                  // Breaking the row widget out as a method so that we can
                  // share it with the _removeSingleItem() method.
                  return _buildItem(_data[index], animation);
                },
              ),
            ),
            RaisedButton(
              child: Text('Insert item', style: TextStyle(fontSize: 20)),
              onPressed: () {
                _insertSingleItem();
              },
            ),
            RaisedButton(
              child: Text('Remove item', style: TextStyle(fontSize: 20)),
              onPressed: () {
                _removeSingleItem();
              },
            )
          ],
        );
      }

      // This is the animated row with the Card.
      Widget _buildItem(String item, Animation animation) {
        return SizeTransition(
          sizeFactor: animation,
          child: Card(
            child: ListTile(
              title: Text(
                item,
                style: TextStyle(fontSize: 20),
              ),
            ),
          ),
        );
      }

      void _insertSingleItem() {
        String newItem = "Planet";
        // Arbitrary location for demonstration purposes
        int insertIndex = 2;
        // Add the item to the data list.
        _data.insert(insertIndex, newItem);
        // Add the item visually to the AnimatedList.
        _listKey.currentState.insertItem(insertIndex);
      }

      void _removeSingleItem() {
        int removeIndex = 2;
        // Remove item from data list but keep copy to give to the animation.
        String removedItem = _data.removeAt(removeIndex);
        // This builder is just for showing the row while it is still
        // animating away. The item is already gone from the data list.
        AnimatedListRemovedItemBuilder builder = (context, animation) {
          return _buildItem(removedItem, animation);
        };
        // Remove the item visually from the AnimatedList.
        _listKey.currentState.removeItem(removeIndex, builder);
      }
    }

在代码的注释中添加了很多说明。可以总结为一下几点

  • AnimatedList需要用到GlobalKey。每次动画的时候都需要更新AnimatedList用到的数据和GlobalKey。
  • 行组件是stateless的。如果是有状态的,那么就需要安排一个Key给他们。这样可以让Flutter快速的发现哪里发生了更新。这个来自Flutter团队的视频可以帮你了解更多。
  • 本例我是用了SizedTransition动画,文档里还有更多的可以用。

最后

我们已经了解了ListView的方方面面。你已经可以自己写一个满足自己需要的了。

代码在这里