Flutter了解之入门篇6(可滚动组件)

目录
  1. ListView
  2. SingleChildScrollView
  3. GridView(二维网格列表)

Flutter官方并没有对Widget进行分类,对其分类主要是为了对Widget进行功能区分。

当组件超过显示窗口时,Flutter会提示Overflow错误。为此,Flutter提供了多种可滚动组件用于显示列表和长布局。

/*
Flutter有两种布局模型:
    1. 基于RenderBox的盒模型布局。
    2. 基于RenderSliver (Sliver) 按需加载列表布局。
*/
主纵轴
  滚动方向称为主轴,非滚动方向称为纵轴。

可滚动组件的组成部分
  1. Scrollable (继承自StatefulWidget) 
    处理滑动手势,确定滑动偏移,滑动偏移变化时构建Viewport 。
  2. Viewport
    渲染当前视口中需要显示的Sliver。
    父组件为Scrollable。
  3. Sliver
    对子组件进行构建和布局。
    父组件为Viewport。

具体过程
  1. Scrollable监听到用户滑动行为后,根据最新的滑动偏移构建Viewport 。
  2. Viewport将当前视口信息和配置信息通过SliverConstraints传递给Sliver。
  3. Sliver中对子组件(RenderBox)按需进行构建和布局,然后确认自身的位置、绘制等信息,保存在geometry(SliverGeometry类型的对象)中。

基于Sliver的延迟构建模型
  通常可滚动组件的子组件非常多、占用高度非常大,如果一次性将子组件全部构建将会非常昂贵。为此,Flutter提出Sliver(薄片)概念,如果一个可滚动组件支持Sliver模型,则可以分成许多Sliver,且只有当Sliver出现在视口中时才去构建它。
  支持:ListView、GridView。不支持:SingleChildScrollView。

公共属性(最终会透传给Scrollable和Viewport)
  1. scrollDirection
    滚动方向。
    Axis.vertica垂直方向(默认),Axis.horizontal水平方向。
  2. reverse
    是否按照阅读方向相反的方向滑动。
    决定可滚动组件的初始滚动位置是在“头”还是“尾”,取false时初始滚动位置在“头”,反之则在“尾”。
  3. primary
    是否使用widget树中默认的PrimaryScrollController;
    当滑动方向为垂直方向且没有指定controller时,primary默认为true。
  4. padding
    内边距
  5. controller(ScrollController类型)
    控制滚动位置和监听滚动事件。
    当子树中的可滚动组件没有显式指定controller且primary属性值为true时(默认就为true),可滚动组件会使用Widget树中默认的PrimaryScrollController。这种机制的好处是父组件可以控制子树中可滚动组件的滚动行为,例如,Scaffold正是使用这种机制在iOS中实现了点击导航栏回到顶部的功能。
  6. physics(ScrollPhysics类型)
    决定可滚动组件如何响应用户操作。比如用户滑动完抬起手指后,继续执行动画;或者滑动到边界时,如何显示。NeverScrollableScrollPhysics():禁止滚动。
    Flutter默认会根据各平台分别使用不同的ScrollPhysics对象,应用不同的显示效果,如当滑动到边界时,继续拖动的话,在iOS上会出现弹性效果,而在Android上会出现微光效果。如果想在所有平台下使用同一种效果,可以显式指定:
      1. ClampingScrollPhysics:Android下微光效果。
      2. BouncingScrollPhysics:iOS下弹性效果。


cacheExtent
    预渲染的高度(下图中顶部和底部灰色的区域)。
    如果RenderBox进入这个区域,即使它未显示在屏幕上,也要先进行构建,预渲染是为了后面进入Viewport时更流畅。
    默认值是250,在构建可滚动列表时可以指定这个值(最终会传给 Viewport)。
ListView
  1. Scrollable、Viewport、Sliver
Scrollable({
  this.axisDirection = AxisDirection.down,  // 滚动方向
  this.controller,
  this.physics,
  // 滑动时Scrollable会调用此回调构建新的Viewport,同时传递一个ViewportOffset类型的offset参数(描述Viewport该显示哪一部分)。
  // 重新构建Viewport(本身也是Widget,只是配置信息)不是一个昂贵的操作,Viewport变化时对应的RenderViewport会更新信息,并不会随着Widget进行重新构建。
  @required this.viewportBuilder, 
})
Viewport({
  Key? key,
  this.axisDirection = AxisDirection.down,
  this.crossAxisDirection,
  this.anchor = 0.0,
  // 滚动偏移。Scrollabel构建Viewport 时传入(描述了Viewport该显示哪一部分)。
  required ViewportOffset offset, 
  // 类型为Key,表示从什么地方开始绘制,默认是第一个元素
  this.center,
  this.cacheExtent, // 预渲染区域
  // pixel:cacheExtent值为预渲染区域的具体像素长度
  // viewport:cacheExtent值是一个乘数,预渲染区域的像素长度=cacheExtent*viewport。
  this.cacheExtentStyle = CacheExtentStyle.pixel, 
  this.clipBehavior = Clip.hardEdge,
  List<Widget> slivers = const <Widget>[], // 需要显示的 Sliver 列表
})
Sliver对应的渲染对象类型是RenderSliver。
RenderSliver和RenderBox的相同点是都继承自RenderObject类,不同点是在布局时约束信息不同。RenderBox在布局时父组件传递给它的约束信息是BoxConstraints(最大最小宽高约束);而 RenderSliver在布局时父组件传递给它的约束是SliverConstraints。
  1. Scrollbar (Material风格的滚动条)

使用:作为可滚动组件的任意一个父组件即可。

Scrollbar(
  child: SingleChildScrollView(
    ...
  ),
);
Scrollbar在iOS平台会自动切换为CupertinoScrollbar(iOS风格)。
Scrollbar和CupertinoScrollbar都是通过监听滚动通知来确定滚动条位置的。
  1. ScrollController(间接继承自Listenable)

可滚动组件都有一个controller属性(控制和监听滚动)

ScrollController({
  double initialScrollOffset = 0.0, // 初始滚动位置
  this.keepScrollOffset = true,// 是否保存滚动位置
  ...
})
监听滚动事件
  controller.addListener(()=>print(controller.offset))

常用的属性和方法:
1. offset
  可滚动组件当前的滚动位置。
2. jumpTo(double offset)、animateTo(double offset,...)
  用于跳转到指定的位置,不同之处在于,后者在跳转时会执行一个动画。

示例

创建一个ListView,判断当前位置是否超过1000像素,如果超过则在屏幕右下角显示一个“返回顶部”的按钮,该按钮点击后可以使ListView恢复到初始位置;如果没有超过1000像素,则隐藏“返回顶部”按钮。

class ScrollControllerTestRoute extends StatefulWidget {
  @override
  ScrollControllerTestRouteState createState() {
    return new ScrollControllerTestRouteState();
  }
}
class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
  ScrollController _controller = new ScrollController();
  bool showToTopBtn = false; // 是否显示“返回到顶部”按钮
  @override
  void initState() {
    super.initState();
    // 监听滚动事件,打印滚动位置
    _controller.addListener(() {
      print(_controller.offset); //打印滚动位置
      if (_controller.offset < 1000 && showToTopBtn) {
        setState(() {
          showToTopBtn = false;
        });
      } else if (_controller.offset >= 1000 && showToTopBtn == false) {
        setState(() {
          showToTopBtn = true;
        });
      }
    });
  }
  @override
  void dispose() {
    // 为了避免内存泄露,需要调用_controller.dispose
    _controller.dispose();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("滚动控制")),
      body: Scrollbar(
        child: ListView.builder(
            itemCount: 100,
            itemExtent: 50.0, // 列表项高度固定时,显式指定高度是一个好习惯(性能消耗小)
            controller: _controller,
            itemBuilder: (context, index) {
              return ListTile(title: Text("$index"),);
            }
        ),
      ),
      floatingActionButton: !showToTopBtn ? null : FloatingActionButton(
          child: Icon(Icons.arrow_upward),
          onPressed: () {
            //返回到顶部时执行动画
            _controller.animateTo(.0,
                duration: Duration(milliseconds: 200),
                curve: Curves.ease
            );
          }
      ),
    );
  }
}


滚动位置恢复

PageStorage是一个用于保存页面(路由)相关数据的功能型组件,它拥有一个存储桶,子树中的Widget可以通过指定不同的PageStorageKey来存储各自的数据或状态。

每次滚动结束,可滚动组件都会将滚动位置offset存储到PageStorage中,当可滚动组件重新创建时再恢复。
  ScrollController.keepScrollOffset为false,则滚动位置将不会被存储,可滚动组件重新创建时会使用ScrollController.initialScrollOffset;
  ScrollController.keepScrollOffset为true时,可滚动组件在第一次创建时,会滚动到initialScrollOffset处,因为这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,忽略initialScrollOffset。

当一个路由中包含多个可滚动组件时,如果发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这时可以通过显式指定不同的PageStorageKey来分别跟踪不同的可滚动组件的位置,如:
  ListView(key: PageStorageKey(1), ... );
  ListView(key: PageStorageKey(2), ... );

注意:一个路由中包含多个可滚动组件时,如果要分别跟踪它们的滚动位置,并非一定就得给他们分别提供PageStorageKey。这是因为Scrollable本身是一个StatefulWidget,它的状态中也会保存当前滚动位置,所以,只要可滚动组件本身没有被从树上detach掉,那么其State就不会销毁,滚动位置就不会丢失。只有当Widget发生结构变化,导致可滚动组件的State销毁或重新构建时才会丢失状态,这种情况就需要显式指定PageStorageKey,通过PageStorage来存储滚动位置,一个典型的场景是在使用TabBarView时,在Tab发生切换时,Tab页中的可滚动组件的State就会销毁,这时如果想恢复滚动位置就需要指定PageStorageKey。

ScrollPosition

真正保存滑动位置信息的对象。
  offset只是一个便捷属性:double get offset => position.pixels;

一个ScrollController对象可以同时被多个可滚动组件使用,ScrollController会为每一个可滚动组件创建一个ScrollPosition对象,并保存在positions属性中(List<ScrollPosition>)。
  controller.positions.elementAt(0).pixels
  controller.positions.elementAt(1).pixels
  controller.positions.length 被几个可滚动组件使用

ScrollController的animateTo() 和 jumpTo(),内部最终都会调用ScrollPosition的同名方法(真正来控制跳转滚动位置)。

ScrollController控制原理

ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition oldPosition);
void attach(ScrollPosition position) ;
void detach(ScrollPosition position) ;

当ScrollController和可滚动组件关联时,可滚动组件首先会调用ScrollController的createScrollPosition()方法来创建一个ScrollPosition来存储滚动位置信息,接着,可滚动组件会调用attach()方法,将创建的ScrollPosition添加到ScrollController的positions属性中,这一步称为“注册位置”,只有注册后animateTo() 和 jumpTo()才可以被调用。

当可滚动组件销毁时,会调用ScrollController的detach()方法,将其ScrollPosition对象从ScrollController的positions属性中移除,这一步称为“注销位置”,注销后animateTo() 和 jumpTo() 将不能再被调用。

注意:ScrollController的animateTo() 和 jumpTo()内部会调用【所有】ScrollPosition的同名方法。

滚动监听

Flutter Widget树中子Widget可以通过发送通知(Notification)与父(包括祖先)Widget通信。父级组件可以通过NotificationListener组件来监听自己关注的通知,这种通信方式类似于Web开发中浏览器的事件冒泡。

可滚动组件在滚动时会发送ScrollNotification类型的通知,ScrollBar正是通过监听滚动通知来实现的。通过NotificationListener监听滚动事件和通过ScrollController有两个主要的不同:
    1. 通过NotificationListener可以在从可滚动组件到widget树根之间任意位置都能监听。而ScrollController只能和具体的可滚动组件关联后才可以。
    2. 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置。

在接收到滚动事件时,参数类型为ScrollNotification,它包括一个metrics属性,它的类型是ScrollMetrics,该属性包含当前ViewPort及滚动位置等信息:
    1. pixels:当前滚动位置。
    2. maxScrollExtent:最大可滚动长度。
    3. extentBefore:滑出ViewPort顶部的长度;此示例中相当于顶部滑出屏幕上方的列表长度。
    4. extentInside:ViewPort内部长度;此示例中屏幕显示的列表部分的长度。
    5. extentAfter:列表中未滑入ViewPort部分的长度;此示例中列表底部未显示到屏幕范围部分的长度。
    6. atEdge:是否滑到了可滚动组件的边界。

示例

import 'package:flutter/material.dart';
class ScrollNotificationTestRoute extends StatefulWidget {
  @override
  _ScrollNotificationTestRouteState createState() =>
      new _ScrollNotificationTestRouteState();
}
class _ScrollNotificationTestRouteState
    extends State<ScrollNotificationTestRoute> {
  String _progress = "0%"; // 保存进度百分比
  @override
  Widget build(BuildContext context) {
    return Scrollbar( // 进度条
      // 监听滚动通知
      child: NotificationListener<ScrollNotification>(
        onNotification: (ScrollNotification notification) {
          double progress = notification.metrics.pixels /
              notification.metrics.maxScrollExtent;
          // 重新构建
          setState(() {
            _progress = "${(progress * 100).toInt()}%";
          });
          print("BottomEdge: ${notification.metrics.extentAfter == 0}");
          //return true; // 放开此行注释后,进度条将失效
        },
        child: Stack(
          alignment: Alignment.center,
          children: <Widget>[
            ListView.builder(
                itemCount: 100,
                itemExtent: 50.0,
                itemBuilder: (context, index) {
                  return ListTile(title: Text("$index"));
                }
            ),
            CircleAvatar(  //显示进度百分比
              radius: 30.0,
              child: Text(_progress),
              backgroundColor: Colors.black54,
            )
          ],
        ),
      ),
    );
  }
}

1. ListView (建议指定itemExtent或prototypeItem)

沿一个方向线性排列所有子组件。支持基于Sliver的延迟构建模型。

/*
  1. ListView中的列表项组件是RenderBox,并不是Sliver。
  2. 一个ListView中只有一个Sliver(对列表项进行按需加载),默认是SliverList,如果指定了itemExtent,则为SliverFixedExtentList;如果prototypeItem属性不为空,则为SliverPrototypeExtentList。
  3. 可以通过ListView.custom自定义列表项生成模型,它需要实现一个SliverChildDelegate用来给ListView生成列表项组件。
  4. 可滚动组件的构造函数如果需要一个列表项Builder则支持基于Sliver的懒加载模型的,反之则不支持。
  5. ListView高度边界无法确定时会异常
*/
ListView({
  // 可滚动widget公共参数
  Axis scrollDirection = Axis.vertical,    
  bool reverse = false,
  ScrollController controller,
  bool primary,
  ScrollPhysics physics,
  EdgeInsetsGeometry padding,    

  // ListView各个构造函数的共同参数  
  double itemExtent,
  Widget? prototypeItem, // 列表项原型
  bool shrinkWrap = false,
  bool addAutomaticKeepAlives = true,
  bool addRepaintBoundaries = true,
  double cacheExtent,

  // 子widget列表
  // 这种方式适合只有少量的子组件数量已知且比较少的情况,反之则应该使用ListView.builder 按需动态构建列表项。
  List<Widget> children = const <Widget>[],
})

说明:
1. itemExtent
  如果不为null,则表示滚动方向上子组件的长度。
  指定后滚动系统可以提前知道列表的长度,而无需每次构建子组件时都去再计算,会更加高效。
2. prototypeItem(列表项原型)
  如果所有列表项长度相同但不知道具体多少,可以指定一个列表项prototypeItem,可滚动组件会在layout时计算一次它延主轴方向的长度,和指定itemExtent一样。
  注意:itemExtent和prototypeItem互斥,不能同时指定。
3. shrinkWrap
  是否根据子组件的总长度来设置ListView的长度。
  默认false ,ListView的会在滚动方向尽可能多的占用空间。当ListView在一个无边界(滚动方向上)的容器中时,shrinkWrap必须为true。
4. addAutomaticKeepAlives
  是否将列表项(子组件)包裹在AutomaticKeepAlive 组件中;
  如果设置为true(默认为true,在懒加载列表中会为每一个列表项添加AutomaticKeepAlive父组件),在列表项滑出视口时不会被GC(垃圾回收),它会使用KeepAliveNotification来保存其状态。
  如果列表项自己维护其KeepAlive状态,那么此参数必须置为false。
5. addRepaintBoundaries
  是否将列表项(子组件)包裹在RepaintBoundary组件中。默认为true。
  将列表项包裹在RepaintBoundary中可以在滚动时避免列表项重绘,但是当列表项重绘的开销非常小时,不添加RepaintBoundary反而会更高效。
  如果列表项自己维护其KeepAlive状态,那么此参数必须置为false

示例

ListView(
  children: [
    imgSection,
    titleSection,
    buttonSection,
    textSection,
  ],
),
ListView(
  padding: EdgeInsets.all(10),
  children: [
    ListTitle(
      title:Text('hello'),
      subTitle:('world'),
    ),
    ListTitle(
      leading:Icon(Icons.settings,color:Colors.yellow,size:30),
      trailing:Image.network("http://.../1.png"),
      title:Text(
          'hello',
           style: TextStyle(
              fontSize: 24,
           ),
      ),
      subTitle:('world'),
    ),
  ],
),
  1. 默认构造函数

有一个children参数,子组件很少时使用。不支持基于Sliver的懒加载模型。
通过此方式创建的ListView和使用SingleChildScrollView+Column的方式没有本质的区别。

示例

ListView(
  shrinkWrap: true, 
  padding: const EdgeInsets.all(20.0),
  children: <Widget>[
    const Text('I\'m dedicating every day to you'),
    const Text('Domestic life was never quite my style'),
    const Text('When you smile, you knock me out, I fall apart'),
    const Text('And I thought I was so smart'),
  ],
);

示例2

import 'package:flutter/material.dart';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final title = 'Basic List';
    return new MaterialApp(
      title: title,
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text(title),
        ),
        body: new ListView(
          children: <Widget>[
            new ListTile(
              leading: new Icon(Icons.map),
              title: new Text('Map'),
            ),
            new ListTile(
              leading: new Icon(Icons.photo),
              title: new Text('Album'),
            ),
            new ListTile(
              leading: new Icon(Icons.phone),
              title: new Text('Phone'),
            ),
          ],
        ),
      ),
    );
  }
}

示例3(水平滚动)

import 'package:flutter/material.dart';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final title = 'Horizontal List';
    return new MaterialApp(
      title: title,
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text(title),
        ),
        body: new Container(
          margin: new EdgeInsets.symmetric(vertical: 20.0),
          height: 200.0,
          child: new ListView(
            scrollDirection: Axis.horizontal,    // 水平滚动
            children: <Widget>[
              new Container(
                width: 260.0,
                color: Colors.red,
              ),
              new Container(
                width: 260.0,
                color: Colors.blue,
              ),
            ],
          ),
        ),
      ),
    );
  }
}
  1. ListView.builder 构造函数

适合列表项比较多或不确定时。支持基于Sliver的懒加载模型的。

ListView.builder({
  ...
  // 列表项的构建器,返回值为一个widget。当滚动到对应index位置时会调用。
  @required IndexedWidgetBuilder itemBuilder,
  // 列表项的数量,如果为null,则为无限列表。
  int itemCount,
})

示例

ListView.builder(
    itemCount: 100,
    itemExtent: 50.0, // 高度为50.0
    itemBuilder: (BuildContext context, int index) {
      return ListTile(title: Text("$index"));
    }
);

示例2

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
  runApp(new MyApp(
    items: new List<String>.generate(10000, (i) => "Item $i"),
  ));
}
class MyApp extends StatelessWidget {
  final List<String> items;  // 数据源
  MyApp({Key key, @required this.items}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    final title = 'Long List';
    return new MaterialApp(
      title: title,
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text(title),
        ),
        body: new ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            return new ListTile(
              title: new Text('${items[index]}'),
            );
          },
        ),
      ),
    );
  }
}

示例3(不同类型的item)

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
  runApp(new MyApp(
    items: new List<ListItem>.generate(
      1000,
      (i) => i % 6 == 0
          ? new HeadingItem("Heading $i")
          : new MessageItem("Sender $i", "Message body $i"),
    ),
  ));
}
class MyApp extends StatelessWidget {
  final List<ListItem> items;
  MyApp({Key key, @required this.items}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    final title = 'Mixed List';
    return new MaterialApp(
      title: title,
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text(title),
        ),
        body: new ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            final item = items[index];
            if (item is HeadingItem) {
              return new ListTile(
                title: new Text(
                  item.heading,
                  style: Theme.of(context).textTheme.headline,
                ),
              );
            } else if (item is MessageItem) {
              return new ListTile(
                title: new Text(item.sender),
                subtitle: new Text(item.body),
              );
            }
          },
        ),
      ),
    );
  }
}
abstract class ListItem {}
class HeadingItem implements ListItem {
  final String heading;
  HeadingItem(this.heading);
}
class MessageItem implements ListItem {
  final String sender;
  final String body;
  MessageItem(this.sender, this.body);
}
  1. ListView.separated

比ListView.builder多了一个separatorBuilder参数(在生成的列表项之间添加分割组件)。

示例(奇数行添加一条蓝色下划线,偶数行添加一条绿色下划线)

class ListView3 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Widget divider1=Divider(color: Colors.blue,);
    Widget divider2=Divider(color: Colors.green);
    return ListView.separated(
        itemCount: 100,
        itemBuilder: (BuildContext context, int index) {
          return ListTile(title: Text("$index"));
        },
        separatorBuilder: (BuildContext context, int index) {  // 分割器构造器
          return index%2==0?divider1:divider2;
        },
    );
  }
}

示例2

从数据源异步分批拉取一些数据,然后用ListView展示,当滑动到列表末尾时,判断是否需要再去拉取数据,如果是,则去拉取,拉取过程中在表尾显示一个loading,拉取成功后将数据插入列表;如果不需要再去拉取,则在表尾提示"没有更多"

class InfiniteListView extends StatefulWidget {
  @override
  _InfiniteListViewState createState() => new _InfiniteListViewState();
}
class _InfiniteListViewState extends State<InfiniteListView> {
  static const loadingTag = "##loading##"; //表尾标记
  var _words = <String>[loadingTag];
  @override
  void initState() {
    super.initState();
    _retrieveData();
  }
  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemCount: _words.length,
      itemBuilder: (context, index) {
        // 如果到了表尾
        if (_words[index] == loadingTag) {
          //不足100条,继续获取数据
          if (_words.length - 1 < 100) {
            // 获取数据
            _retrieveData();
            // 加载时显示loading
            return Container(
              padding: const EdgeInsets.all(16.0),
              alignment: Alignment.center,
              child: SizedBox(
                  width: 24.0,
                  height: 24.0,
                  child: CircularProgressIndicator(strokeWidth: 2.0)
              ),
            );
          } else {
            // 已经加载了100条数据,不再获取数据。
            return Container(
                alignment: Alignment.center,
                padding: EdgeInsets.all(16.0),
                child: Text("没有更多了", style: TextStyle(color: Colors.grey),)
            );
          }
        }
        // 显示单词列表项
        return ListTile(title: Text(_words[index]));
      },
      separatorBuilder: (context, index) => Divider(height: .0),
    );
  }
  void _retrieveData() {
    Future.delayed(Duration(seconds: 2)).then((e) {
      setState(() {
        // 重新构建列表
        _words.insertAll(_words.length - 1,
          // 每次生成20个单词
          generateWordPairs().take(20).map((e) => e.asPascalCase).toList()
          );
      });
    });
  }
}

  1. 添加固定的列表头
不太好的写法:
@override
Widget build(BuildContext context) {
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    SizedBox(
      // Material设计规范中状态栏、导航栏、ListTile高度分别为24、56、56 。避免底部留白
      height: MediaQuery.of(context).size.height-24-56-56,
      child: ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
      }),
  )
  ]);
}
这种方法太不好,如果页面布局发生变化,比如表头布局调整导致表头高度改变,那么剩余空间的高度就得重新计算。修正:
// 自动拉伸ListView以填充屏幕剩余空间
@override
Widget build(BuildContext context) {
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    Expanded(
      child: ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
      }),
    ),
  ]);
}
  1. AutomaticKeepAlive组件
将列表项的根RenderObject的keepAlive按需自动标记为true或false。
列表组件的Viewport区域+cacheExtent预渲染区域 称为加载区域 :
    1. 当 keepAlive 标记为 false 时,如果列表项滑出加载区域时,列表组件将会被销毁。
    2. 当 keepAlive 标记为 true 时,当列表项滑出加载区域后,Viewport 会将列表组件缓存起来;当列表项进入加载区域时,Viewport 从先从缓存中查找是否已经缓存,如果有则直接复用,如果没有则重新创建列表项。

子组件想改变是否需要缓存的状态时就向KeepAliveNotification通知,AutomaticKeepAlive收到消息后会去更改keepAlive的状态(从true变为false时,需要释放缓存)。
  1. 优化ListView
1. 
列表项较多或不确定(上拉加载更多)时不要使用默认的构造函数,应该使用ListView.builder
2. 
禁用addAutomaticKeepAlives(缺点:滑动过快时可能会出现短暂白屏)。
禁用addRepaintBoundaries,当列表元素布局较简单时可提高流畅度。
3.
列表中不可变子组件使用const修饰。 // children: [const ListImage()],
4.
指定itemExtent值(当可以提前知道时)。
  1. AnimatedList(在列表中插入或删除节点时执行一个动画)
AnimatedList(StatefulWidget类型)对应的State类型为AnimatedListState(包含了添加和删除元素的方法):
  void insertItem(int index, { Duration duration = _kDuration });
  void removeItem(int index, AnimatedListRemovedItemBuilder builder, { Duration duration = _kDuration }) ;

要使用上面的添加和删除方法则需要创建GlobalKey并赋值给AnimatedList的key,通过key.currentState获取到AnimatedListState对象来调用。
  final globalKey = GlobalKey<AnimatedListState>();
  AnimatedList(key: globalKey, ...)
  globalKey.currentState.insertItem(data.length - 1);

在插入和删除数据时,应该是先修改列表数据,然后调用 AnimatedListState 的insertItem 和 removeItem 方法,而不能直接操作完数据后刷新界面。

示例(AnimatedList)

点击底部 + 按钮时向列表追加一个列表项;点击每个列表项后面的删除按钮时,删除该列表项,添加和删除时分别执行指定的动画(渐显、渐隐+收缩)。

class AnimatedListRoute extends StatefulWidget {
  const AnimatedListRoute({Key? key}) : super(key: key);
  @override
  _AnimatedListRouteState createState() => _AnimatedListRouteState();
}
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 Stack(
      children: [
        // 与ListView的itemBuilder相比多了一个animation参数
        // typedef AnimatedListItemBuilder = Widget Function(BuildContext context, int index,Animation<double> animation);
        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: Icon(Icons.add),
        onPressed: () {
          // 添加一个列表项
          data.add('${++counter}');
          // 告诉列表项有新添加的列表项
          globalKey.currentState!.insertItem(data.length - 1);
          print('添加 $counter');
        },
      ),
      bottom: 30,
      left: 0,
      right: 0,
    );
  }
  // 构建列表项
  Widget buildItem(context, index) {
    String char = data[index];
    return ListTile(
      // 数字不会重复,所以作为Key
      key: ValueKey(char),
      title: Text(char),
      trailing: IconButton(
        icon: 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);
          print('删除 ${data[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: Duration(milliseconds: 200), // 动画时间为 200 ms
      );
    });
  }
}

示例

AnimatedList显示与ListModel保持同步的卡片列表。当新的item被添加到ListModel或从ListModel中删除时,相应的卡片在UI上也会被添加或删除,并伴有动画效果。
点击一个item选择它,再次点击它会取消选择。点击’+’插入选定的item,点击’ - ‘删除选定的item。 tap处理器会从ListModel<E>中添加或删除items,ListModel<E>是List<E>的简单封装 ,用于保持和AnimatedList的同步。 列表模型为其动画列表提供了一个GlobalKey。它使用该键来调用由AnimatedListState定义的insertItem和removeItem方法。

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class AnimatedListSample extends StatefulWidget {
  @override
  _AnimatedListSampleState createState() => new _AnimatedListSampleState();
}
class _AnimatedListSampleState extends State<AnimatedListSample> {
  // 由于AnimatedList的所有控制都是在AnimatedState中进行的。在构建AnimatedList时给key属性赋值GlobalKey,就可以通过key.currentState获取到AnimatedListState对象。
  final GlobalKey<AnimatedListState> _listKey = new GlobalKey<AnimatedListState>();
  ListModel<int> _list;
  int _selectedItem;
  int _nextItem; 
  @override
  void initState() {
    super.initState();
    _list = new ListModel<int>(
      listKey: _listKey,
      initialItems: <int>[0, 1, 2],
      removedItemBuilder: _buildRemovedItem,
    );
    _nextItem = 3;
  }
  // 构建列表项(没有被移除的)
  Widget _buildItem(BuildContext context, int index, Animation<double> animation) {
    return new CardItem(
      animation: animation,
      item: _list[index],
      selected: _selectedItem == _list[index],
      onTap: () {
        setState(() {
          _selectedItem = _selectedItem == _list[index] ? null : _list[index];
        });
      },
    );
  }
  // 
  Widget _buildRemovedItem(int item, BuildContext context, Animation<double> animation) {
    return new CardItem(
      animation: animation,
      item: item,
      selected: false,
    );
  }
  // 插入
  void _insert() {
    final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem);
    _list.insert(index, _nextItem++);
  }
  // 移除选中
  void _remove() {
    if (_selectedItem != null) {
      _list.removeAt(_list.indexOf(_selectedItem));
      setState(() {
        _selectedItem = null;
      });
    }
  }
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new Scaffold(
        appBar: new AppBar(
          title: const Text('AnimatedList'),
          actions: <Widget>[
            new IconButton(
              icon: const Icon(Icons.add_circle),
              onPressed: _insert,
              tooltip: 'insert a new item',
            ),
            new IconButton(
              icon: const Icon(Icons.remove_circle),
              onPressed: _remove,
              tooltip: 'remove the selected item',
            ),
          ],
        ),
        body: new Padding(
          padding: const EdgeInsets.all(16.0),
          child: new AnimatedList(
            key: _listKey,
            initialItemCount: _list.length,
            itemBuilder: _buildItem,
          ),
        ),
      ),
    );
  }
}
// 
class ListModel<E> {
  ListModel({
    @required this.listKey,
    @required this.removedItemBuilder,
    Iterable<E> initialItems,
  }) : assert(listKey != null),
       assert(removedItemBuilder != null),
       _items = new List<E>.from(initialItems ?? <E>[]);
  final GlobalKey<AnimatedListState> listKey;
  final dynamic removedItemBuilder;
  final List<E> _items;
  AnimatedListState get _animatedList => listKey.currentState;
  void insert(int index, E item) {
    _items.insert(index, item);
    // insertItem 方法没有 builder 参数,它直接将新插入的元素传给 AnimatedList 的 builder 方法来插入新的元素,这样能够保持和列表新增元素的动效一致。
    _animatedList.insertItem(index);
  }
  E removeAt(int index) {
    final E removedItem = _items.removeAt(index);
    if (removedItem != null) {
      // 传入参数:移除元素的下标 和 一个构建移除元素的方法builder。之所以要这个方法是因为元素实际从列表马上移除的,为了在动画过渡时间内还能够看到被移除的元素,需要通过这种方式来构建一个被移除的元素来感觉是动画删除的。这里也可以使用 animation 参数自定义动画效果。 
      _animatedList.removeItem(index, (BuildContext context, Animation<double> animation) {
        return removedItemBuilder(removedItem, context, animation);
      });
    }
    return removedItem;
  }
  int get length => _items.length;
  E operator [](int index) => _items[index];
  int indexOf(E item) => _items.indexOf(item);
}
// 列表项
class CardItem extends StatelessWidget {
  const CardItem({
    Key key,
    @required this.animation,
    this.onTap,
    @required this.item,
    this.selected: false
  }) : assert(animation != null),
       assert(item != null && item >= 0),
       assert(selected != null),
       super(key: key);
  final Animation<double> animation;
  final VoidCallback onTap;
  final int item;
  final bool selected;
  @override
  Widget build(BuildContext context) {
    TextStyle textStyle = Theme.of(context).textTheme.display1;
    if (selected)
      textStyle = textStyle.copyWith(color: Colors.lightGreenAccent[400]);
    return new Padding(
      padding: const EdgeInsets.all(2.0),
      child: new SizeTransition(
        axis: Axis.vertical,
        sizeFactor: animation,
        child: new GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: onTap,
          child: new SizedBox(
            height: 128.0,
            child: new Card(
              color: Colors.primaries[item % Colors.primaries.length],
              child: new Center(
                child: new Text('Item $item', style: textStyle),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
void main() {
  runApp(new AnimatedListSample());
}

2. SingleChildScrollView (只能接收一个子组件)

内容不会超过屏幕太多时使用,不支持基于Sliver的延迟实例化模型。

  SingleChildScrollView({
    // 公共参数
    Key? key,
    this.scrollDirection = Axis.vertical,  // 
    this.reverse = false,  // 
    this.padding,  //
    bool? primary,  // 
    this.physics,  // 
    this.controller,  // 
    this.child,  //
    // 
    this.dragStartBehavior = DragStartBehavior.start,
    this.clipBehavior = Clip.hardEdge,
    this.restorationId,
  }) 

示例(将大写字母A-Z沿垂直方向显示)

class SingleChildScrollViewTestRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    return Scrollbar( // 显示进度条
      child: SingleChildScrollView(
        padding: EdgeInsets.all(16.0),
        child: Center(
          child: Column( 
            // 动态创建一个List<Widget>  
            children: str.split("") 
                // 每一个字母都用一个Text显示,字体为原来的两倍
                .map((c) => Text(c, textScaleFactor: 2.0,)) 
                .toList(),
          ),
        ),
      ),
    );
  }
}
运行结果

3. GridView(二维网格列表)

GridView({
  Axis scrollDirection = Axis.vertical,    // 滚动方向
  bool reverse = false,
  ScrollController controller,
  bool primary,
  ScrollPhysics physics,
  bool shrinkWrap = false,
  EdgeInsetsGeometry padding,  // 内边距
  // 控制子组件如何排列
  // SliverGridDelegate定义了GridView Layout相关接口,子类需要通过实现它们来实现具体的布局算法。
  // SliverGridDelegateWithFixedCrossAxisCount和SliverGridDelegateWithMaxCrossAxisExtent。
  @required SliverGridDelegate gridDelegate, 
  bool addAutomaticKeepAlives = true,
  bool addRepaintBoundaries = true,
  double cacheExtent,
  List<Widget> children = const <Widget>[],    // 子列表
})
和ListView的大多数参数都是相同的。
  1. SliverGridDelegateWithFixedCrossAxisCount、GridView.count

横轴为固定数量子元素。

// GridView.count构造函数内部使用了SliverGridDelegateWithFixedCrossAxisCount。
SliverGridDelegateWithFixedCrossAxisCount({
/*
  // 横轴子元素的数量。
  // 子元素在横轴的长度=ViewPort横轴长度/crossAxisCount。
  // 子元素的大小是通过crossAxisCount和childAspectRatio两个参数共同决定的。这里的子元素指的是子组件的最大显示空间,确保子组件的实际大小不要超出子元素的空间。
*/
  @required double crossAxisCount, 
   // 主轴方向的间距
  double mainAxisSpacing = 0.0,
  // 横轴方向的间距。
  double crossAxisSpacing = 0.0,
  // 子元素在横轴长度和主轴长度的比例。
  double childAspectRatio = 1.0,
})

示例

GridView(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 3, // 横轴三个子widget
      childAspectRatio: 1.0 // 宽高比为1
  ),
  children:<Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast)
  ]
);
上面的示例代码等价于(GridView.count):
GridView.count( 
  crossAxisCount: 3,
  childAspectRatio: 1.0,
  children: <Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast),
  ],
);

示例(GridView.count)

import 'package:flutter/material.dart';
void main() {
  runApp(new MyApp());
}
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final title = 'Grid List';
    return new MaterialApp(
      title: title,
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text(title),
        ),
        body: new GridView.count(
          crossAxisCount: 2,
          children: new List.generate(100, (index) {
            return new Center(
              child: new Text(
                'Item $index',
                style: Theme.of(context).textTheme.headline,
              ),
            );
          }),
        ),
      ),
    );
  }
}
  1. SliverGridDelegateWithMaxCrossAxisExtent、GridView.extent

横轴子元素为固定最大长度。

// GridView.extent构造函数内部使用了SliverGridDelegateWithMaxCrossAxisExtent。
SliverGridDelegateWithMaxCrossAxisExtent({
  double maxCrossAxisExtent,    // 子元素在横轴上的最大长度
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})

示例

GridView(
  padding: EdgeInsets.zero,
  gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
      maxCrossAxisExtent: 120.0,
      childAspectRatio: 2.0 // 宽高比为2
  ),
  children: <Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast),
  ],
);
上面的示例代码等价于:
GridView.extent(
   maxCrossAxisExtent: 120.0,
   childAspectRatio: 2.0,
   children: <Widget>[
     Icon(Icons.ac_unit),
     Icon(Icons.airport_shuttle),
     Icon(Icons.all_inclusive),
     Icon(Icons.beach_access),
     Icon(Icons.cake),
     Icon(Icons.free_breakfast),
   ],
 );
  1. GridView.builder

通过GridView.builder来动态创建子widget。

GridView.builder(
 ...
 @required SliverGridDelegate gridDelegate, 
 @required IndexedWidgetBuilder itemBuilder,  // 子widget构建器
)

示例

从一个异步数据源(如网络)分批获取一些Icon,然后用GridView来展示

class InfiniteGridView extends StatefulWidget {
  @override
  _InfiniteGridViewState createState() => new _InfiniteGridViewState();
}
class _InfiniteGridViewState extends State<InfiniteGridView> {
  List<IconData> _icons = []; //保存Icon数据
  @override
  void initState() {
    // 初始化数据  
    _retrieveIcons();
  }
  @override
  Widget build(BuildContext context) {
    return GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            // mainAxisSpacing: 10.0,
            // crossAxisSpacing: 10.0,
            crossAxisCount: 3, // 每行三列
            childAspectRatio: 1.0 // 显示区域宽高相等
        ),
        itemCount: _icons.length,
        itemBuilder: (context, index) {
          // 如果显示到最后一个并且Icon总数小于200时继续获取数据
          if (index == _icons.length - 1 && _icons.length < 200) {
            _retrieveIcons();
          }
          return Icon(_icons[index]);
        }
    );
  }
  void _retrieveIcons() {
    Future.delayed(Duration(milliseconds: 200)).then((e) {
      setState(() {
        _icons.addAll([
          Icons.ac_unit,
          Icons.airport_shuttle,
          Icons.all_inclusive,
          Icons.beach_access, Icons.cake,
          Icons.free_breakfast
        ]);
      });
    });
  }
}
flutter_staggered_grid_view包可实现这种布局
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容