第5章 分 页 呈 现 显示数据是大部分应用程序的主要功能。当业务数据量较大时如何高效地加载与渲染页面,以及当用户滑动屏幕翻页时怎样平滑且快速地加载新的数据是本章重点讨论的内容。 5.1列表和网格 5.1.1ListView ListView组件是Flutter框架中最常用的支持滚动的列表组件。它的基础用法非常简单,只需通过children参数传入一系列组件,ListView就能将它们依次摆放,并支持用户滑动屏幕的手势,或在有鼠标的设备上支持鼠标滚轮, 自动实现滚动功能,代码如下: ListView( children: [ Text("1"), Text("2"), Text("3"), ], ) 运行效果与使用Column组件类似,垂直方向依次摆放3个Text组件,但支持滚动。 1. 动态加载 在安卓原生开发中,RecyclerView是一个可以高效地展示大量数据的列表控件。在iOS原生开发中,UITableView控件也能起到同样的作用。它们的共同特点是在程序运行时不会立即加载列表中的全部数据,而是通过测量屏幕高度及需要显示的数据的高度,计算出满满一屏幕究竟可以显示几行数据,并只加载那部分数据。当用户向下滚动屏幕时,它会动态加载新的数据,并同时将移出屏幕的数据回收,以节约计算机资源。例如,某通讯录页面需要显示共200条通信记录,但测量后得出当前设备的屏幕高度最多只能显示12条,这些有动态加载机制的控件会选择只加载屏幕上必须显示的12条及若干额外条目作为缓冲(如额外加载3条),这样一共只需加载15条记录,其余的185条记录暂不作处理。随着用户向下滚动屏幕,最顶部的记录会逐渐移出屏幕的可见区域,这时用来显示它们的控件会被回收(recycle)。系统可将回收后的控件翻新,将旧内容改成即将出现的新内容,再从屏幕底部插入。这样程序运行时,用户可以自由滚动到列表的任意位置,但其背后始终只需大约15个控件互相交替,回收及循环,动态实现全部资料的显示。安卓原生开发中的RecyclerView也因此得名,其中recycler就是回收者的意思。 在Flutter框架中,使用ListView组件的默认构造函数会使其立即初始化children列表,从而无法发挥动态加载的全部优势,因此,ListView组件的默认构造函数只建议在children数量较少时使用。 一般情况下,长列表推荐使用ListView.builder()命名构造函数。此时,children参数将不可使用,取而代之的是itemBuilder回传函数。该回传函数有上下文(context)和位置索引(index)参数,开发者需要根据这2个参数,尤其是位置索引,返回一个供ListView渲染的组件。 例如,可对列表中的每个位置生成一个Text组件,文本内容为位置索引,代码如下: ListView.builder( itemBuilder: (context, index) { return Text("这是一个Text组件,索引: $index"); }, ) 图51利用ListView动态生成子组件 运行效果如图51所示。 1) 列表长度 默认情况下,动态创建的ListView列表的内容长度无限,即用户可以一直向下滑动屏幕,永远不会触碰到底边。例如当用户即将浏览到第1000个元素的时候,ListView便会自动开始加载第1001个元素,即调用itemBuilder函数并将1001个元素传入index,再将该函数返回的那个组件作为第1001个元素渲染到列表的合适位置。 如需限制列表长度,则可在使用ListView.builder()时通过itemCount参数传入一个不小于0的整数。如传入itemCount:20,则ListView只会渲染20个元素。它在调用itemBuilder回传函数时,传入的位置索引只可能是0~19。 当用户已经浏览到列表末尾但依然向下滚动屏幕时,ListView会自动产生触边反馈效果。在iOS上呈现的是过度滚动后自动弹回的动画效果,在安卓上呈现的是波形色块的效果,这些都是相应平台中非常自然且常见的效果,用户应该不会感到陌生。 2) 分割线 ListView列表中元素之间若需要分割线,则可以借助ListView.separated()构造函数轻松实现。这个构造函数的用法与ListView.builder()大同小异,主要区别有2点: 首先是除了itemBuilder回传函数外还多加了一个separatorBuilder回传,用于在元素之间插入分割线,其次是itemCount不可为空,代码如下: ListView.separated( itemCount: 3, separatorBuilder: (context, index) { return Center( child: Text("--- 这是索引为$index的分割线 ---"), ); }, itemBuilder: (context, index) { return Container( height: 50, color: Colors.grey, alignment: Alignment.center, child: Text("这是索引为$index的元素"), ); }, ) 图52在ListView元素之间插入分割线 ListView会首先根据itemCount属性确定该列表共3个元素,并在调用itemBuilder函数构建3个元素的基础上,额外调用separatorBuilder函数在元素之间插入2次分割线,如图52所示。 Flutter框架中有一个名为Divider的组件,可用于渲染Material风格的分割线。若需使用,则可以直接在separatorBuilder方法中回传“return Divider();”完成分割线的建造。如需自定义样式,读者也可参考第11章“风格组件”中有关Divider的介绍。 2. 子组件尺寸 一般情况下ListView列表中的元素(子组件)的尺寸都是交给每个子组件自行决定的,因此不同元素之间尺寸也可相差甚远。例如,可构建一个长度为10的列表,其中每当索引为奇数时,返回一个灰色且较高的Container容器,其余索引则直接返回Text组件,代码如下: ListView.builder( itemCount: 10, itemBuilder: (context, index) { if (index.is Odd) { return Container( height: 60, color: Colors.grey, alignment: Alignment.center, child: Text("索引: $index"), ); } else { return Text("这是索引为$index的元素"); } }, ) 图53ListView中子组件元素 可以不等高 运行效果如图53所示。 这种不约束子组件尺寸的布局思路在大部分情况下是一种既灵活又方便的设计方案。实战中Flutter的性能也足以轻松应付用户滑动屏幕时连续地调用itemBuilder实时创建并渲染组件,但这么做也有缺点: 例如ListView无法预知未加载的元素尺寸,继而无法确定所有元素的总尺寸,这样有时会导致右侧的滚动条(稍后介绍)无法准确显示滚动进度。又或当程序支持大幅跳转时,不固定每个元素的高度可能会导致性能问题。 在这些特殊情形下,选择牺牲元素尺寸的多样性可换来性能的提升。实战中可通过ListView的itemExtent属性强制固定每个元素的尺寸,如在上例中增加itemExtent: 80 即可保证列表中的每个元素在列表滚动的主轴方向必须占用80逻辑像素。换言之,如果ListView是竖着滚动的,则每个元素的高度必为80单位,如果ListView是横着滚动的,则每个元素的宽度均为80单位。 固定子组件的主轴尺寸可在大幅跳转时提升性能。例如某程序可通过滚动控制器(稍后介绍)实现一键跳转10000逻辑像素的功能。若ListView无法提前确定每个元素的高度,则跳转时它必须依次加载这些元素并完成布局测量,这样才可得知跳转10000逻辑像素后应该落在第几个元素上,然而,若每个元素的高度是固定的,如80逻辑像素,则只需简单计算便可得知一共需跳过10000÷80=125个元素,跳转后应显示第126个元素,这样就不必逐个加载被跳过的元素了。 3. 空白页面 空白页面是当下非常流行的一种界面设计。例如,当通讯录列表为空时,程序可显示“暂时还没有联系人,单击此处添加”,或者当垃圾邮件列表为空时,程序可显示“太棒了,没有垃圾邮件!”等特制图形或文案。这么做通常会比直接渲染一个空空的列表更友好些。 ListView组件本身没有直接支持空白页的功能,但借助Flutter灵活的框架,只需要在合适的时机将整个ListView组件替换成另一套显示空白页面的组件(如Image或者Text组件),就可以轻松实现这个需求了。 4. 内部状态保持 在ListView组件对元素的动态加载与资源回收机制的作用下,移出屏幕的元素会被摧毁。当用户往回翻页并再次浏览到该元素时,这个元素又会被重新加载。如果元素内部存有状态,并且在摧毁时没有注意保存,则重新加载时可能会丢失之前的状态。 这里通过一个实例来举例说明。首先定义一个含有30个元素的ListView列表,每个元素都是自定义的Counter组件,即一个简单的计数器,拥有内部状态。完整代码如下: //第5章/list_view_state.dart import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar( title: Text("ListView演示"), ), body: ListView.builder( itemCount: 30, itemExtent: 80, itemBuilder: (context, index) { return Center(child: Counter(index)); }, ), ), ); } } class Counter extends StatefulWidget { final int index; Counter(this.index); @override _CounterState createState() => _CounterState(); } class _CounterState extends State<Counter> { int _count = 0; @override Widget build(BuildContext context) { return ElevatedButton( child: Text("第${widget.index + 1}个计数器: $_count"), onPressed: () => setState(() => _count++), ); } } 图54ListView元素拥有 内部状态 当用户单击按钮时,对应的计数器就会自增。例如程序运行后,第1个按钮被单击了3下,它就会显示数字3,其余按钮保持显示0的状态,如图54所示。 由于一般手机屏幕并不能同时显示全部30个元素,用户此时可滑动屏幕查看更多内容。当第1个显示着数字3的计数器被移出屏幕时,对应的Counter组件会被摧毁,因此其内部状态(count变量)也会丢失。具体表现为当用户先向下滚动屏幕,再向上滚动屏幕回来时,会意外发现第1个计数器已被重置,显示着数字0而不是之前的数字3了。 遇到类似情况时,就有必要对ListView内部元素进行状态保持。 1) 状态提升 当列表的子组件的内部状态会意外丢失时,最直接的解决办法是采纳前端网页React框架中著名的Lift State Up(状态提升)思路,把列表中每个子组件的状态都提升到列表之上,这样当子组件被摧毁重制时状态就不会丢失了。 例如之前的计数器例子,可以把计数器的内部状态提取到外部,将30个计数器的数值一并保存为一个外部数组,这样Counter组件需要显示的数字由外部数组传入,就不会发生数值丢失的情况了。 2) KeepAlive 实战中并不是所有情况都适合采用状态提升的思路。当程序的设计架构导致确实有保留元素的内部状态的必要时,也可采用KeepAlive(保持活跃)的方式使内部状态不丢失。 ListView组件中的addAutomaticKeepAlives参数可自动为子组件添加KeepAlive功能,而且默认值已经是true,因此不需要额外设置,但开发者应确保子组件本身必须同时支持该功能。上例中,计数器组件的状态类需要做一定改写。 比较简单的改写方法是先将CounterState类添加AutomaticKeepAliveClientMixin融合类,接着在其build方法中调用父级方法,最后添加继承wantKeepAlive(是否需要保持活跃)返回值为true。修改后的代码如下: class _CounterState extends State<Counter> with AutomaticKeepAliveClientMixin //1. 添加融合类 { int _count = 0; @override Widget build(BuildContext context) { super.build(context); //2. 调用父级方法 return ElevatedButton( child: Text("第${widget.index + 1}个计数器: $_count"), onPressed: () => setState(() => _count++), ); } @override bool get wantKeepAlive => true; //3. 声明需要保持状态 } 这样修改后的Counter组件就不会在移出屏幕时丢失内部数据了,即实现了保持内部状态的效果。如需进一步优化,则可在声明是否需要保持状态时,仅保持非0状态的计数器,代码如下: @override bool get wantKeepAlive => _count != 0; //3. 仅当计数器不为0时需保持状态 这样若用户尚未开始使用某个计数器,则它显示的数字本来就是0,也就没有必要保存了。 5. 滚动控制器 ListView组件可以通过controller参数接收一个ScrollController类的滚动控制器。开发者可以通过它更直接地掌控当前列表的状态,以及控制列表滚动。例如当用户单击程序顶部的导航栏时,可以触发跳转至列表顶部的操作,或者在程序刚开始运行时,设置列表默认的起始位置等。使用完毕后,应在适宜的时机调用ScrollController的dispose方法释放资源。 1) 初始化 滚动控制器的初始化代码不建议放在build方法中,否则Flutter每次重绘都会初始化一个新的控制器。实战中一般在State(组件状态类)中定义私有变量_controller,并初始化为一个新的ScrollController控制器,之后再将其通过controller属性传给ListView组件,代码如下: class _MyHomePageState extends State<MyHomePage> { ScrollController _controller = ScrollController();//初始化 @override void dispose() { super.dispose(); _controller.dispose(); //回收资源 } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: ListView.builder( controller: _controller, //在ListView组件中使用控制器 itemBuilder: (_, index) => Text("$index"), ), ); } } 2) jumpTo 当滚动控制器通过controller参数传给ListView组件后,开发者就可以通过它直接操作列表的滚动状态。其中最简单的一种操作就是jumpTo(跳转至)方法: 传入一个小数类型的值,列表就会跳转到这个位置。例如可制作一个按钮,当用户单击按钮时,通过调用jumpTo(0.0) 将列表跳转至0.0逻辑像素的位置,即回到列表顶部,代码如下: ElevatedButton( child: Text("跳转至顶部"), onPressed: () => _controller.jumpTo(0.0), ) 值得一提的是,如果这里故意传入负数,如-5.0或者-10.0等,列表则会跳转至顶部后过量滚动,产生触顶动画并自动纠正至0.0。这与用户平时用手指迅速滚动列表并触边时的视觉效果一致,在iOS上呈现的是过量滚动后自动弹回的动画效果,而在安卓上呈现的是波形色块的效果,因此实战中传入负数会使整个“快速跳转至顶部”的功能看上去更加自然,读者不妨一试。 3) animateTo 除了跳转外,滚动控制器还支持animateTo(动画至)方法。这与跳转的最终效果类似,但不是瞬间完成,代码如下: ElevatedButton( child: Text("回到顶部"), onPressed: () => _controller.animateTo( 0.0, duration: Duration(milliseconds: 300), curve: Curves.easeOut, ), ) 以上代码定义了一个凸起按钮,单击之后ListView将开始动画滚动至0.0的位置,滚动速度逐渐缓慢,总耗时300ms。其中duration(时长)和curve(动画曲线)都是Flutter框架中与动画相关的常见属性,对此不熟悉的读者可参考第7章“过渡动画”中的相关内容。 4) offset 开发者若需要得到当前列表的位置,则可以通过访问controller.offset属性获取。这里的返回值是逻辑像素,例如列表中每个元素的高度均为100单位,那么当offset返回值50时,则表示现在列表刚开始滚动,它的第1个元素有一半已经移出屏幕了。再例如,若offset返回值2048,则表示列表的顶部显示的是第21~22个元素的位置。 除了offset外,控制器还支持controller.position属性,用来提供更详细的信息,如滚动物理等,有兴趣的读者可自行查阅相关文档。其实上文介绍的controller.offset属性就是controller.position.pixels的语法糖,用于方便大家查询最常用的列表位置信息。 5) 事件监听 滚动控制器可以通过addListener方法添加一个或多个回传函数,并在滚动值发生变化时调用。例如可在列表发生滚动时,打印出当前位置,代码如下: _controller.addListener(() { print("现在的位置: ${_controller.offset}"); }); 这样每当列表滚动时就会源源不断地打印出当前offset的值,直到滚动完毕,整个列表缓缓停下,并最终彻底静止后才会停止打印。 6. 其他属性 1) scrollDirection 一般情况下ListView组件以垂直方向进行滚动,视觉效果类似于一个可支持滚动的Column组件。通过scrollDirection属性,ListView也可以变成水平方向滚动,就如同Row组件一样。 若想改为水平方向滚动,则只需传入scrollDirection: Axis.horizontal。对应的垂直方向值为Axis.vertical,或者直接删掉该参数,即可采用默认的垂直方向。 2) reverse ListView组件可通过reverse: true开启“倒序”模式。在默认垂直方向滚动的列表中开启倒序模式会使最后一个元素显示在列表的最顶部,而第1个元素显示在最底部。当使用jumpTo等方法跳转至0.0,即列表初始位置时,列表也会跳转至底部,因此仍然会跳转到第1个元素。若元素不够占满整个视窗的高度,倒序的列表则由下自上摆放完全部元素后,会在上方留白,这也与正序列表恰好相反。 若ListView组件被scrollDirection属性设置为水平方向滚动,则默认顺序是由设备的阅读顺序决定的。例如,在阿拉伯文的系统上,默认水平方向是由右到左滚动,这与汉语或英文的设备默认方向不同。无论默认方向如何,这里的“倒序”都会将其反转。 3) padding 列表外部的留白可以通过在ListView组件的父级添加Padding组件实现,而列表内部留白则可以用padding属性设置。两者的主要区别在于,当列表的元素比较多(超出视窗范围)时,外部留白会将屏幕上的ListView组件当作一个整体,始终保持它与其他组件(或与屏幕边框)的留白,但内部留白则是将ListView组件内的所有元素当作一个整体,始终保持元素与ListView的边框的距离。 为了演示,这里将一个ListView组件嵌入一个灰色Container中,以方便观察它的尺寸和位置。接着借助Padding组件将ListView的四周外部留白48单位,再同时使用padding属性将其四周内部留白72单位,代码如下: Padding( padding: EdgeInsets.all(48),//外部留白48单位 child: Container( color: Colors.grey, //灰色背景 child: ListView.builder( padding: EdgeInsets.all(72), //四周内部留白72单位 itemCount: 100, itemBuilder: (_, index) { return Text("这是列表的第$index项"); }, ), ), ) 运行效果如图55所示。首先灰色的ListView与屏幕边的48单位留白是由父级Padding组件造成的。其次ListView与其内部元素之间的72单位留白则是由padding属性导致的,这里可以观察到左边、右边及上边的留白,却没有底部留白。这是由于padding属性设置的内部留白会将全部元素当作一个整体。这里由于屏幕高度的限制,共100项的ListView目前只显示了31项,因此底部没有出现留白。可想而知,当用户滚动到最后一个元素时便可观察到底部的留白了。 值得一提的是,ListView组件的padding属性默认值不是0,而是会根据设备的不同,自动避让当前设备屏幕上的缺陷区域(如某款苹果手机的“刘海儿”等位置)。若对此默认行为不满意,则可以通过手动传入EdgeInsets.zero直接将四周设置为固定的0留白。 这里还有一个小技巧: 在实战中,若程序布局采用了FloatingActionButton设计,即右下角出现一个悬浮的按钮,则很可能会导致列表的最后一个元素被悬浮按钮遮住。这时可利用padding属性为底部增加留白,这样列表中的最后一个元素会与ListView的底边保持距离,空出悬浮按钮的位置,方便用户浏览最后一个元素,运行效果如图56所示。 图55ListView组件内部与外部留白的对比 图56ListView组件的内部底边留白 对渲染悬浮按钮不熟悉的读者可参考第11章“风格组件”中有关FloatingActionButton组件和Scaffold组件的简介。另外,对Padding组件不熟悉的读者也可以参考第6章“进阶布局”中关于Padding组件的详细介绍。 4) shrinkWrap 这里shrinkWrap在英文里是“真空包装”的意思,指的是用塑料薄膜将物体严实包裹后再把其中的空气抽掉,一般用于为物体运输途中减小体积或为食品保鲜。在ListView组件中,shrinkWrap是指将列表主轴方向的尺寸压缩至全部子组件尺寸的总和,使其尽量少占空间。 默认情况下ListView组件不采用shrinkWrap,因此在主轴方向会占满父级组件允许的最大尺寸,例如竖着滚动的列表如果没有其他约束,就会占满屏幕高度。若传入true启用真空包装,则ListView的高度会变为children高度之和,因此当元素较少时可被方便地嵌套在Column组件中,代码如下: Column( children: [ Text("列表之前"), Container( color: Colors.grey[400], child: ListView( padding: EdgeInsets.all(8.0), shrinkWrap: true, children: [ Text("#1. 达拉崩吧斑得贝迪卜多比鲁翁"), Text("#2. 昆图库塔卡提考特苏瓦西拉松"), ], ), ), Text("列表之后"), ], ) 图57被shrinkWrap后的列表 运行效果如图57所示。 启用shrinkWrap的ListView列表不得不放弃动态加载,即便使用了ListView.builder构造函数,它也会立刻将所有的元素全部加载,以计算尺寸总和,因此启用shrinkWrap是一项非常消耗计算资源的操作。若列表中的元素较多(甚至无限多),则真空包装需要非常多的时间才能将列表中的每个元素加载并布局,这样会使程序出现卡顿甚至无响应。 实战中很少需要启用shrinkWrap。如上例ListView中元素较少(只有2项),可考虑直接将它们并入Column组件的children中。若ListView元素较多,则更常见的办法是借助Expanded组件嵌入Column中。这样可保持ListView的动态加载和滚动,最终运行效果也与真空包装不同。关于Column等Flex组件中的Expanded的用法和原理,读者可以参考第6章“进阶布局”中的相关内容。 5) physics 滚动物理,可以设置列表滚动时的物理样式。例如,当用户将列表滚动至边界后继续滚动,在iOS上会出现过量滚动后自动弹回的动画效果,而在安卓上呈现的是波形色块的效果。这些行为属于physics的一部分。 由于Flutter引擎是通过直接在设备进行像素级别的绘制来渲染画面的,所以这些列表滚动及触边的动画效果等并不是调用系统的API完成的,因此均不受设备的原生操作系统局限。例如,传入physics: BouncingScrollPhysics()可使所有设备表现出iOS默认的触边回弹效果,而传入ClampingScrollPhysics()则可以使所有设备表现出安卓的效果。 另外,传入NeverScrollableScrollPhysics()可禁止列表的滚动,这样即便列表元素超过视窗范围,用户也不能滑动手指滚动屏幕,但开发者仍可使用controller读取和控制列表的位置,以及完成跳转等操作。与之相对的AlwaysScrollableScrollPhysics()则确保列表永远可以滚动。 如有必要,读者还可以继承ScrollPhysics类,创建符合项目需求的滚动物理。例如这里新建一个滚动物理,叫作AutoScrollPhysics,其功能是自动使列表向下滚动,速度为固定的每秒200逻辑像素,且不受用户的手势影响,永不停止。完整代码如下: //第5章/list_view_physics_auto.dart import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar( title: Text("Auto Scroll Physics"), ), body: ListView.builder( physics: AutoScrollPhysics(), itemBuilder: (_, index) => Text("$index"), ), ), ); } } class AutoScrollPhysics extends ScrollPhysics { @override ScrollPhysics applyTo(ScrollPhysics? ancestor) => AutoScrollPhysics(); @override bool shouldAcceptUserOffset(ScrollMetrics position) => false; @override Simulation createBallisticSimulation(position, velocity) => AutoScrollSimulation(); } class AutoScrollSimulation extends Simulation { static const velocity = 200.0; @override double x(double time) => velocity * time; @override double dx(double time) => velocity; @override bool isDone(double time) => false; } 6) cacheExtent 在ListView动态加载与回收元素时,除了屏幕上可见的子组件外,ListView还会在视窗范围外(如垂直滚动列表的上方和下方)额外加载几个元素作为缓冲。这里cacheExtent属性定义了缓冲区的长度。例如设置 cacheExtent: 1000,则表示在当前屏幕可见的第1个元素之前,以及可见的最后一个元素之后,分别插入长度为1000逻辑像素的缓冲区。例如当前视窗高度为800逻辑像素,这样就一共产生了1000+800+1000共计2800逻辑像素的缓冲区,所有缓冲区内的组件都会被加载。这就意味着,若每个元素的高度为100单位,虽然手机屏幕一次只能显示8~9个元素,但始终都有近30个元素被加载到内存中,时刻准备着被渲染至屏幕上。 实战中很少需要手动设置这个属性,一般直接使用默认值250即可。 7) semanticChildCount semanticChildCount是语义标签属性,属于辅助功能的一部分,用来协助第三方软件为有障碍人士提供无障碍功能。例如盲人一般会通过某些软件将屏幕上的内容朗读出来,而这里的语义标签就可以帮助屏幕朗读软件,以提供更友好的用户体验。 当朗读软件遇到列表时可能会朗读“列表共12项内容”等语句,但列表中有些元素也许是装饰性的分割线或者标题等,不应该被认为是列表内容,因此可以用semanticChildCount提供实际有意义的元素的数量,如semanticChildCount: 8表示列表里只有8项真正有意义的内容。使用该属性时需注意列表元素总量不能为无限,且semanticChildCount不能超过itemCount的数量。 7. 扩展到Sliver ListView的本质是一个只有一个SliverList的CustomScrollView,因此当ListView无法满足某些复杂的需求时,例如当需要列表与网格(稍后介绍)联合滚动,或当程序顶部的导航条也需要参与滚动时,可考虑直接使用CustomScrollView。如有需求,读者可参考本书第13章“滚动布局”的内容。 5.1.2ListWheelScrollView 转轮列表,ListWheelScrollView是一个将子组件放在一个转轮上并可以呈现三维显示效果的列表组件。其基础用法与ListView组件相似,通过children参数接收一系列组件,将它们依次三维变换后摆放,并支持用户滑动屏幕手势。不同的是这个组件的itemExtent参数必传,因此所有列表中的元素在主轴方向必须是统一尺寸,不支持大小不一的子组件,代码如下: ListWheelScrollView( itemExtent: 100, children: [ for (int i = 0; i < 5; i++) Container(color: Colors.grey), ], ) 这里通过for循环,向children传入了5个灰色的Container组件,并通过itemExtent属性将列表内每个元素的高度都设置为100单位。程序运行时,默认显示第1个元素,且将其放大并居中,效果如图58(左图)所示。当用户开始滚动列表,如滚动至第3个元素时,被“选中”的第3个元素将被放大且居中,效果如图58(右图)所示。 图58ListWheelScrollView的显示效果 Dart Tips 语法小贴士 列表中的if和for循环 上例代码利用for循环,方便地向ListWheelScrollView组件的children属性传入了5个组件。这是2019年5月发布的Dart 2.3的新语法,允许列表中使用if和for关键字,代码如下: Column( children: [ if (showHeader)//根据变量判断是否在列表中插入Header组件 Header(), Item1(), //固定插入Item1组件 Item2(), //固定插入Item2组件 for(int i = 0; i < 10; i++) FlutterLogo(),//循环插入10遍FlutterLogo组件 Item3(), //固定插入Item3组件 ], ) 实战中除了在列表中直接使用for之外,也可以使用List.generate功能,代码如下: ListWheelScrollView( itemExtent: 100, children: List.generate(5, (index) => FlutterLogo()), ) 上述代码使用List.generate()传入5,表示需要生成一个长度为5的列表,接着传入一个回传函数。运行时,Flutter会根据所设置的长度,连续调用回传函数,并依次提供递增的索引(index)。本例中的回传函数会被调用5次,其中index变量将分别对应0、1、2、3、4。每次返回的值会被最终合并到一个列表中,因此,上述代码最终会生成5个FlutterLogo组件。 此外,实战中也常常会根据业务逻辑,直接借助数据集的map等功能直接将数据转换为组件。很多其他编程语言都有类似的方法,5.1.3节也会简单介绍。 图59使用offAxisFraction设定 中心轴的偏差值 1. 渲染三维效果 1) offAxisFraction 该属性用于控制转轮中的children远离中心轴的偏差值,默认为0,即无左右偏离。当传入一个正数时,转轮会向观测者角度的右侧偏移,负数则向左侧偏移。数值的绝对值越大,偏离得越多。例如传入offAxisFraction: -1.2,会产生如图59所示的偏移效果。 2) 放大中心元素 如有必要,则可以通过useMagnifier(启用放大镜)属性传入true开启中心元素的放大功能。开启后,还需要通过magnification属性传入放大倍数,默认1.0倍无效果,例如传入2.0表示放大2倍,传入0.5表示缩小至原来一半的尺寸,代码如下: ListWheelScrollView( itemExtent: 80, useMagnifier: true, //启用放大镜 magnification: 1.5, //放大1.5倍 children: List.generate( 5, (index) => Container( color: Colors.grey, alignment: Alignment.center, child: Text("这是第$index项"), ), ), ) 运行效果如图510所示。 3) overAndUnderCenterOpacity 除了可以放大中心元素外,ListWheelScrollView组件还支持将不在中心位置的其他元素添加半透明的效果。这个名字比较长的属性overAndUnderCenterOpacity(中心上面和下面不透明度),主要就是做这个用途的。它可以接收数值0.0~1.0,默认为1.0,即没有特殊的半透明效果。 例如,可以传入overAndUnderCenterOpacity: 0.5,呈现50%透明的效果,如图511所示。 图510中心元素放大1.5倍的效果 图511非中心元素半透明的效果 值得一提的是,当使用这个属性时,ListWheelScrollView组件会自动打开useMagnifier(启用放大镜)属性,不需要特意设置,因此若需要同时放大中心元素的尺寸,则可直接额外通过magnification属性传入放大倍数实现。 4) diameterRatio 转轮直径可以通过diameterRatio参数设置,默认为2.0。该组件的开发者在源码注释中提到,默认选择2.0并无特殊含义,就是觉得渲染出的视觉效果看起来还不错。 图512从左到右依次展示了转轮直径0.5(较小)、2.0(默认)和3.0(较大)的效果,读者可自行体会。 图512转轮直径设置不同值的效果 用于实现图512所示效果的代码如下,读者也可自行修改代码,尝试其他的值: ListWheelScrollView( itemExtent: 20, diameterRatio: 2.0,//转轮直径,默认为2.0 children: List.generate( 100, (index) => Container( color: Colors.grey, alignment: Alignment.center, child: Text("这是第$index项"), ), ), ) 5) perspective 这个属性定义了将转轮的三维圆柱体投影到二维的屏幕时的视角。视角0表示从无限远的距离观察这个圆柱体,而视角1则表示从无限近的地方观测,但0和1这两个表示“无限”的值都不可被实际渲染,因此,该组件的开发者设置了允许的最大值为0.01,并选择默认值0.003,该默认值并无特殊含义,只是组件的开发者觉得渲染出的视觉效果看起来还不错。 图513从左到右依次展示了视角为0.00001(较远)、0.003(默认)和0.01(允许范围内最近)的效果,读者可自行体会。本例用到的代码与上例diameterRatio(转轮直径)的代码类似,只是将diameterRatio属性改写为perspective属性,代码略。 图513视角属性设置不同值的效果 6) squeeze 这个属性用于控制每个子组件在转盘上插入的位置,或密集程度,默认为1。若传入0.5,则转盘会将元素的密度减半,即原先每个可以插入children的位置,此时选择插一个空一个,做出一半留白的效果。相反,若传入2,则转盘会在原先每个元素中间的位置再挤入一个新元素,使密度变成双倍,并可能导致每个元素下半部分被新增的元素所遮挡。 改变转盘的密度会导致屏幕可显示的元素的数量有所增减,因此也会影响程序在运行时需要同时构建的子组件的数量。例如当传入squeeze: 1.5使密度变为原来的1.5倍时,原本一屏幕只能显示20个元素的列表,此时可以显示30个元素。 2. 精确选择 由于ListWheelScrollView组件的itemExtent属性不能为空,故其所有子组件的高度必须一致,因此列表中的每个元素所对应的子组件在没有加载完成时就已经可以预先确定尺寸了,所以这类列表可以做到对列表内容的精确定位。 1) physics 该组件的滚动物理physics属性与ListView组件的同名属性类似,对此属性不熟悉的读者可以先阅读5.1.1节ListView组件的相关内容。例如设置BouncingScrollPhysics()可使所有设备表现出iOS默认的触边回弹效果,而传入ClampingScrollPhysics()则可以使所有设备表现出安卓的色块效果。此外,还可以传入NeverScrollableScrollPhysics()禁止列表的滚动,以及设置AlwaysScrollableScrollPhysics()可使列表永远可以滚动。 除了上述这些普通的physics值之外,由于ListWheelScrollView支持精确选择,这里还可以传入一个特殊的值: FixedExtentScrollPhysics(),即固定范围的滚动物理。使用这个值可以保证列表滚动停止后最终会稳稳地停在一个元素上,而不是停在两个元素之间的任意位置。 2) onSelectedItemChanged 这是ListWheelScrollView所支持的回传函数,每当用户选择的值发生变化时该函数会被调用,并且会将用户当前选择的元素的索引作为参数传入,以方便开发者实现业务逻辑所需要的功能。 例如可用ListWheelScrollView组件及各项属性,实现一个三维滚动的日期选择器,代码如下: //第5章/list_wheel_date_selector.dart ListWheelScrollView( perspective: 0.005,//定义“视角” itemExtent: 48, //固定元素高度 magnification: 1.2, //中心元素放大1.2倍 overAndUnderCenterOpacity: 0.5, //非中心元素半透明 physics: FixedExtentScrollPhysics(),//固定范围的滚动物理 onSelectedItemChanged: (index) { //选择值改变时的回传 print("选择了${index + 1}日"); }, children: List.generate( //生成子组件列表 31,//共有31个元素 (index) => Container( color: Colors.grey, //灰色 alignment: Alignment.center, //居中 child: Text("${index + 1}日"), //显示日期,如28日 ), ), ) 运行效果如图514所示。 图514立体的日期选择器 3. 控制器 与ListView组件类似,ListWheelScrollView也可通过controller参数接收一个ScrollController类的控制器,但由于这个组件支持精确控制,实战中一般会选择传一个更具体的子类,FixedExtentScrollController,即固定范围的滚动控制器。这是一种专门为已经固定了子组件尺寸的列表(目前Flutter框架的内置组件中只有ListWheelScrollView组件符合这一要求)订制的、更方便好用的滚动控制器。它除了支持普通ScrollController所支持的全部功能,如jumpTo(跳转至)或offset(读取当前位置)等以逻辑像素为单位的操作外,FixedExtentScrollController还支持从逻辑像素到“元素索引”的转换。 例如之前介绍过的jumpTo(100)可以跳转至列表第100逻辑像素的位置,但jumpToItem(100)就可以直接跳转到列表的第100个元素开始的位置。同样地,animateToItem()和animateTo()用法类似,但数量单位从逻辑像素变为了元素索引。另外,FixedExtentScrollController提供selectedItem属性,可以获得当前选中(中心位置)的元素索引,非常方便。 该组件的controller属性也可以接收一个普通的ScrollController控制器,但这样做就会失去精确选择的功能,包括失去这里基于元素索引的操作,以及本节之前提到的FixedExtentScrollPhysics和onSelectedItemChanged回传函数功能。当不传入任何控制器时,Flutter会自动为ListWheelScrollView创建一个FixedExtentScrollController控制器,因此不会失去精确选择的功能。 对普通滚动控制器仍不熟悉的读者可参考本章5.1.1节ListView组件介绍中的相关内容。 5.1.3ReorderableListView ReorderableListView(可排序的列表)顾名思义,是一个支持用户拖动列表中的元素并任意改变它们的顺序的组件。该组件基本用法比较简单,可利用builder方法或直接通过children参数传入需要渲染的子组件,再通过onReorder参数设置一个回传函数,用来处理当列表组件的顺序发生改变时的业务逻辑。这里唯一需要注意的是,ReorderableListView组件要求所有子组件的key属性不为空,否则在改变顺序时会出现混淆。实战中一般使用ValueKey作为标识,代码如下: ReorderableListView( children: [ Text("在汗水中奋斗之后终将绽放", key: ValueKey(1)), Text("梦想就如同花朵一样", key: ValueKey(2)), Text("努力地前往也永远不曾改变方向", key: ValueKey(3)), ], onReorder: (int oldIndex, int newIndex) { print("用户把位于$oldIndex的元素移动到了$newIndex的位置"); }, ) 运行时,列表会先顺位显示3个Text组件,并在用户长按其中任意组件后进入排序模式。排序时,被拖动的元素会有阴影边框,以增加立体感,如图515所示。当用户松开手指后即退出排序模式,此时onReorder函数会被调用,输出如“用户把位于1的元素移动到了0的位置”的字样。 图515原本列表中第2项内容正被拖至第1项 (11min) Flutter 框架小知识 组件中常见的key属性是什么 在Flutter框架中,组件(Widget)本身是不可变(immutable)的,即组件一旦被创建后,就不可以再改变它的值了,因此当程序的界面需要发生改变时,例如一个Text组件中的文字从“张三”换成了“李四”时,Flutter需要将旧的Text组件摧毁,再重新创建一个新的Text组件。 由于组件在程序运行的过程中经常会被摧毁重制,它们并不适合保存程序运行时的状态,否则每当组件被摧毁时程序的运行状态就会丢失。Flutter的StatefulWidget(有状态的组件)就是通过其附属的State类存储状态信息。当某个组件在某一帧被摧毁时,对应的State会被暂时保留,并试图在同一帧找到新创建的组件,重新建立对应关系,以达到保存状态的目的。具体寻找的办法就是在组件树(Widget Tree)的相同位置查找相同类型的组件。于是,当树中某一级有不止一个同样类型的组件(如ListView的children一般都是同一类型的),且其中部分组件被添加、删除或调整了顺序,Flutter在寻找State与Widget对应的过程中就会出现混淆,而key(键)可以帮助避免混淆。设置了key以后,寻找对应关系时Flutter不但会检查组件在树中的位置和类型是否相同,还需要再检查key的值是否相等。只有在满足了这3个条件后,State才会与新的Widget建立对应关系。 Flutter中的局部键共有3种,分别是UniqueKey、ValueKey和ObjectKey。其中,UniqueKey可以直接使用,并且只与自身相等,其余2种在使用时需要传入一个捆绑的值,类型不限。ValueKey是否相等取决于捆绑的值是否相等(调用具体类的 == 方法对比),而ObjectKey是否相等则是根据捆绑的值是否为同一个实例(指针指向相同内存区域)。 1. 属性 1) children 实战中若列表元素较少,可以直接使用children参数传入一个子组件列表。若元素较多,则推荐使用builder方法实现动态加载,这些都与ListView组件无异。唯一不同的是,这里的每个子组件都必须有key,这样在用户拖动组件改变元素顺序时Flutter才能跟踪每个组件的新位置及组件与状态之间的对应关系。 2) onReorder 每当用户完成拖动操作,以及手指离开屏幕时,如果用户的操作确实有将任何元素改变位置,则回传函数就会被调用。如果用户的拖动操作最终并没有将任何元素改变位置,则这里的回传函数不会被调用。 回传函数被调用时会将oldIndex(旧索引)与newIndex(新索引)一并作为参数传入。开发者应在该回传函数中处理相关的业务逻辑,例如将列表背后真正的数据中的元素也调整顺序,并通过调用setState方法让Flutter重新渲染界面。 在用户拖动的过程中,ReorderableListView会自动处理被拖动的组件的位置(跟随手指移动),以及列表中的其他组件的相应位置(自动避让,为正在移动的组件腾出空间),但当用户完成拖动后,这些自动处理的临时效果也会随即消失,因此,如果这里的回传函数没有及时处理业务逻辑,用户在完成拖动操作后,列表中的元素则依然会按照原来的顺序排列。 3) header 表头,即列表正式开始之前的额外组件。实战中一般可以用这个属性做一些标题之类的用途,若不需要也可以留为空值。这个header组件不能被拖动,也不会被改变顺序,永远都是列表的第一项,但它并不是“钉”在屏幕上的: 当列表过长而发生滚动时,它也会随着滚动,渐渐移出屏幕可见范围。 4) 其他属性 除了上面介绍的这些属性外,ReorderableListView还有不少与ListView组件相同或相似的属性,在此不逐一列举。值得一提的是,负责关联滚动控制器的属性在该组件中被称作scrollController,但其用法仍和ListView的controller属性一致。 2. 实例 这里提供一个利用ReorderableListView组件完成的色彩排序的小游戏实例,完整代码如下: //第5章/reorderable_list_view_example.dart import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: MyHomePage(), ); } } class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { final shades = [700, 200, 600, 500, 900, 800]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("ReorderableListView"), ), body: ReorderableListView( children: shades .map((shade) => Container( key: ValueKey(shade), height: 50, margin: EdgeInsets.all(4.0), color: Colors.grey[shade], )) .toList(), onReorder: (int oldIndex, int newIndex) { if (newIndex > oldIndex) newIndex--; setState(() { final shade = shades.removeAt(oldIndex); shades.insert(newIndex, shade); }); }, ), ); } } 程序最初的运行效果如图516所示。在运行的过程中,用户可以通过长按并拖动任意色块,从而改变它在列表中的位置。 该例比较简单,首先定义了shades = [700, 200, 600, 500, 900, 800]变量,用一个数组保存若干色彩深度的信息。接着在ReorderableListView中,传入children子组件。这里通过map方法将数组中的色彩深度信息转换为相应的Container组件,并设置了宽度、高度、留白及对应的颜色。接着设置onReorder回传函数,即当用户改变列表中的元素顺序时,将相应改动应用到之前定义的shades变量,并借助setState要求Flutter重绘整个组件。 这个例子中值得注意的是,onReorder回传函数提供的旧索引变量(oldIndex)和新索引变量(newIndex)都以旧列表为参照系。这个设计不一定是最妥当的,但由于历史版本的兼容问题,这个小缺陷应该不会在未来版本中被修复。具体表现为,当用户由下至上拖动元素(如将第3个元素拖到第2个元素的位置)时,onReorder回传函数可以正确汇报“将3移动至2”,但当用户由上至下拖动元素(如将第2个元素拖到第3个元素的位置)时,onReorder回传函数则会汇报“将2移动至4”,这背后的逻辑如图517所示。 图516颜色排序游戏的运行效果 图517元素2与元素3调换位置的前后对比 但一般来讲,当元素2拖动到元素3的下方(同时元素3会自动避让,移动到元素2的位置,实则两者对调)时,开发者希望得到的数据是“将2移动至3”而不是“将2移动至4”。为了修正这个问题,使元素2与元素3调换位置时可以获得正确的新旧索引,上例使用了这句代码补丁: if (newIndex > oldIndex) newIndex--; 使用了上述代码后,当用户由下至上拖动元素时新索引会减1,这样可以得到正确的新索引,方便编写业务逻辑代码。 3. 扩展 默认情况下ReorderableListView组件要求用户长按后才可以触发拖动模式,然而在上述小游戏实例中,若允许用户直接轻触拖动,而不必长按,就可以显著提升游戏体验。 事实上,ReorderableListView组件背后调用的是一个更基础的ReorderableList组件,开发者也可以直接使用后者。ReorderableList组件不自动处理拖放手势,因此开发者可根据实际需求,通过向列表内的元素插入ReorderableDragStartListener或ReorderableDelayedDragStartListener组件之一,自行决定应在轻触后开始拖动,或在长按后开始拖动。 例如可将上例中的ReorderableListView组件替换为ReorderableList组件,代码如下: //第5章/reorderable_list_demo.dart ReorderableList( itemCount: shades.length, itemBuilder: (BuildContext context, int index) { return ReorderableDragStartListener( key: ValueKey(shades[index]), index: index, child: Container( height: 50, margin: EdgeInsets.all(4.0), color: Colors.grey[shades[index]], ), ); }, onReorder: (int oldIndex, int newIndex) { if (newIndex > oldIndex) newIndex--; setState(() { final shade = shades.removeAt(oldIndex); shades.insert(newIndex, shade); }); }, physics: NeverScrollableScrollPhysics(), ) 这里配合ReorderableDragStartListener组件,允许用户轻触后直接拖动。另外为了避免整个列表滚动,上述代码还传入了NeverScrollableScrollPhysics禁用列表滚动。修正这两个小问题后,用户体验得到显著提升,读者不妨亲自动手试一试。 5.1.4GridView (10min) GridView组件是一个可将元素显示为二维网格状的列表组件,并支持主轴方向滚动。网格与普通列表ListView组件十分相似,建议对ListView组件不熟悉的读者先阅读5.1.1节的内容。 网格列表最简单的用法是直接将不可滚动的交叉轴的元素数量固定,例如每行固定显示4个元素,这样可滚动的主轴就会根据元素的总数量自动确定总行数,代码如下: GridView.count( crossAxisCount: 4, children: List.generate( 23, (index) => Container( color: Colors.grey[index % 6 * 100], child: Text("$index"), ), ), ) 图518GridView固定每行4个 元素的运行效果 这样可实现每行显示4个元素,一共包含23个元素的网格列表。运行时每个元素的背景都为不同色度的灰色,且显示编号0~22的索引,在某款苹果手机的竖屏状态下,运行效果如图518所示。 1. 构造函数 1) GridView.count() 这是GridView组件最易用的构造函数,只需通过crossAxisCount传入交叉轴方向的元素数量,并通过children传入全部元素,即可实现一个滚动的二维网格状列表。例如,一个默认情况下主轴为垂直方向(竖着滚动)的网格,传入crossAxisCount:4就可以使每行固定显示4个元素,不因设备屏幕的尺寸而改变。在较窄的屏幕上显示4个元素就会使每个元素较小,而在较宽的屏幕上(如平板计算机或者横屏模式下的手机),则每个元素会比较大。将上例中的同样代码,运行在横屏模式下的某款苹果手机上的效果如图519所示。 图519GridView固定每行4个元素的横屏显示效果 2) GridView.extent() 除了固定交叉轴方向的元素数量(如每行4个)这种方式外,开发者也可以选择固定每个元素在交叉轴方向的最大尺寸(例如单个元素宽度不可超过200逻辑像素)。这往往是种更好的思路,首先因为用户在较大的屏幕上通常会期待看到较多的元素,而不只是少量元素的放大版,其次因为大部分情况下无论元素上用到的素材文件还是布局结构的设计,都可能会限制单个元素不宜太大,否则难免会遇到由放大而导致失真的素材图片,或在布局设计上出现大量空白。这里GridView.extent()构造函数的maxCrossAxisExtent参数可以设置每个元素交叉轴方向允许的最大尺寸,这样GridView就能根据屏幕的尺寸宽度自动选择合适的每行数量。 例如某款苹果手机屏幕的尺寸为414×896逻辑像素,如果竖屏显示,且GridView的主轴方向也是垂直方向,则传入maxCrossAxisExtent:100就会保证网格的每个元素的最大宽度不超过100单位。因为这款手机的屏幕宽度是414单位,若每行显示4个元素则一共会占用400单位,不足以填满屏幕的宽度,因此Flutter会自动选择每行显示5个元素,这样当每个元素宽度为82.8单位时刚好可以填满屏幕,且符合“每个元素的宽度都不超过100单位”的要求。当该手机切换到横屏模式时,设备屏幕宽度变成了896逻辑像素,因此每行需要显示9个元素,才可保证填满屏幕时每个元素的宽度均不超过100单位。经计算可得,此时 每个元素的宽度约为99.5单位。 3) GridView() 这个GridView组件的主构造函数没有命名。实际上GridView.count()和GridView.extent()这两个命名构造函数可以看作这个主构造函数的语法糖。不同于前2者,使用GridView()时需要传入children和gridDelegate属性,其中gridDelegate(网格委托)属性需要传入一个SliverGridDelegate类,说明网格该如何构建。Flutter框架已经提供了该委托的两种实现方式,分别是SliverGridDelegateWithFixedCrossAxisCount(交叉轴方向固定数量的委托)及SliverGridDelegateWithMaxCrossAxisExtent(交叉轴方向限制最大长度的委托),开发者可选择其中一种,直接传入。 当传入这两种委托之一时,实际效果等同于直接使用对应的命名参数。例如使用SliverGridDelegateWithFixedCrossAxisCount 就与直接使用GridView.count等效,代码如下: //使用主构造函数 GridView( gridDelegate://传入委托 SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4), children: children, ) //直接使用命名参数 GridView.count( crossAxisCount: 4, children: children, ) 4) GridView.builder() 当网格列表中需要显示的元素数量较多(或甚至无限多)时,一般不适宜将全部数据同时加载。这时,使用GridView.builder()构造函数就可以实现元素的动态加载与回收,以便高效且流畅地展示大量数据。该构造函数背后的动态加载原理与ListView.builder()函数相同,对此不熟悉的读者可阅读ListView相关内容,本书在此不再赘述。 使用builder构造函数时children参数将不可使用,取而代之的是itemBuilder回传函数。该回传函数会提供上下文(context)和位置索引(index)参数,开发者需要根据这2个参数,尤其是位置索引,返回一个供GridView渲染的子组件。同时,另一个必传函数则是gridDelegate(网格委托),这与主构造函数的同名属性一致,主要负责设置网格交叉轴方向的渲染方式,指明每行需有几个元素或每个元素的最大宽度。最后,若列表不是无限长,则在使用builder构造函数时还应通过itemCount参数传入元素的总量,代码如下: GridView.builder( gridDelegate: //传入委托 SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 100), itemCount: 5000000,//共500万个元素 itemBuilder: (context, index) { return Container( color: Colors.grey[index % 4 * 100], alignment: Alignment.center, child: Text("${index + 1}"), ); }, ) 这里利用itemBuilder回传函数,动态加载共500万个元素,并借助委托要求每个元素对应的子组件的最大宽度为100单位。例如某款苹果手机的横屏模式下屏幕宽度为896单位,则每行需要显示9个元素,效果如图520所示。 图520动态加载500万个元素也不会卡顿 这个例子中的500万个元素如果不使用动态加载,而是直接全部通过List.generate()等方法生成后再通过children参数传入,则在笔者的测试机上造成了近3s的卡顿。当使用上例示范的builder方法动态加载后,程序可在毫秒级完成。 2. 网格样式 除了上文介绍的交叉轴方向固定元素数量或最大宽度外,网格样式还有3个参数,分别是mainAxisSpacing属性、crossAxisSpacing属性和childAspectRatio属性,用于设置元素间距和长宽比。当使用GridView()或GridView.builder()这2个构造函数时,这3个属性可在委托类中设置。若直接使用GridView.count()或GridView.extent()命名构造函数,因为不会用到委托,所以这3个属性应直接传给构造函数。 1) 元素间距 元素之间默认不会留白,若需要设置元素间距,则可以通过mainAxisSpacing和crossAxisSpacing分别设置主轴与交叉轴方向的元素间距,代码如下: GridView.count( crossAxisCount: 4,//每行4个元素 mainAxisSpacing: 16, //主轴间距: 16逻辑像素 crossAxisSpacing: 4, //交叉轴间距: 4逻辑像素 children: List.filled(50, Container(color: Colors.grey)), ) 由于GridView的默认滚动方向是垂直方向,因此这里的主轴间距16单位会被运用到垂直方向的元素之间,而交叉轴间距4单位则会被插入元素的水平方向之间。具体运行效果如图521所示。 图521主轴与交叉轴方向的元素间距 这里同时可以观察到,元素间距不同于列表内的padding属性,只会在元素之间插入空白,而不会在网格列表与屏幕边缘之间插入留白。如有必要,实战中也可配合padding属性一同使用。 2) 元素比例 网格中的元素所对应的子组件默认为1∶1的正方形,且由于元素的宽度已被确定(无论是通过固定数量或限制最大宽度),元素的高度因此也只会有唯一的值,所以无论网格内的子组件怎样用Container或者SizedBox的width和height属性设置它们的尺寸,都不会有效果。 如果需要修改网格的长宽比,则可以通过childAspectRatio属性传入一个小数,例如需要3∶2的长宽比,可以通过传入1.5实现,再例如需设置16∶9的长宽比,可传入1.78(16÷9的近似结果)。为了提高代码的可读性,这里鼓励直接传入“16/9”而不使用计算后的小数,代码如下: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3,//每行3个元素 mainAxisSpacing: 4, //主轴间距: 4 crossAxisSpacing: 4, //交叉轴间距: 4 childAspectRatio: 16 / 9,//长宽比例为16∶9 ), itemCount: 20, itemBuilder: (_, index) => Container(color: Colors.grey), ) 这里使用了builder构造函数,以展示有委托时如何在委托类中设置元素间距与比例。程序运行效果如图522所示。 图522将元素长宽比设置为16∶9 3. 其他属性 除了上面列举的这些属性外,GridView还有一部分与ListView组件相似的属性,它们分别是controller(滚动控制器)、scrollDirection(滚动方向)、reverse(倒序)、padding(内部留白)、shrinkWrap(真空包装)、physics(滚动物理)、cacheExtent(缓冲区的长度)及semanticChildCount(语义元素数量),这些属性的名称和用法均与ListView组件的同名属性一致,不熟悉的读者可查阅本章ListView小节的内容。 4. 多种列表样式混搭 实战中利用shrinkWrap(真空包装)和NeverScrollableScrollPhysics(禁止列表滚动的物理),也可勉强实现ListView和GridView混搭的样式,代码如下: //第5章/grid_view_shrink_wrap.dart ListView( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Container(width: 100, height: 100, color: Colors.grey), Container(width: 100, height: 100, color: Colors.grey), ], ), GridView.extent( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), maxCrossAxisExtent: 80, padding: EdgeInsets.all(32), mainAxisSpacing: 32, crossAxisSpacing: 32, children: List.generate( 100, (index) => Container(color: Colors.grey), ), ), ], ) 图523多种列表样式混搭 这里主要利用shrinkWrap固定了GridView主轴方向的长度,再通过禁用滚动,将用户的滚动手势传导至外层的ListView上,运行效果如图523所示。 这个例子中的第一排元素虽然使用了Row容器,但也可以根据需要改成shrinkWrap的ListView或其他任意组件。需要注意的是,利用shrinkWrap的思路实现的混搭样式在程序运行时并不高效,因此只适用于列表元素极少的情况。这是由于启用shrinkWrap会导致ListView或GridView等列表放弃动态加载,即便使用了builder构造函数,它也会立刻将所有元素加载,以计算尺寸总和。如需高效地混搭有大量元素的列表,或需使程序顶部的导航栏也参与联合滚动,则应考虑使用Sliver方式。 5. 扩展到Sliver 正如ListView组件一样,GridView组件的本质也是CustomScrollView组件的简单应用,因此当普通ListView或GridView无法满足某些复杂的功能时,例如当网格列表需要与普通列表联合滚动,或当程序顶部的导航条也需要参与滚动时,可考虑直接使用CustomScrollView组件。对此不熟悉的读者可参考第13章“滚动布局”的内容。 5.1.5PageView PageView组件是一个可以实现整屏页面滚动效果的组件,用户滑动一次手指就可以直接翻动一整个屏幕的距离。页面滚动与普通列表ListView组件十分相似,建议对ListView组件不熟悉的读者先阅读本章5.1.1节的内容。 PageView的基础用法简单易读,默认为水平方向翻页,代码如下: PageView( children: [ Container( color: Colors.grey, child: Center( child: Text("这是第一页"), ), ), Container( color: Colors.white, child: Text("第二页"), ), ], ) 图524从第1个页面滑向第2个 页面时的效果 从代码中可以看到,这里第1个页面是灰色背景,且有一个居中的Text组件,写有“这是第一页”字样。第2个页面是白色背景,并在默认的左上角的位置有一个Text组件,写有“第二页”字样。运行时,程序会先打开第一页。当用户将屏幕滑向第二页时会出现一个滑动的视觉效果,如图524所示。滑动结束时若用户的滑动力度足够,则程序会翻页且稳稳地停留在第2个页面,若用户滑动的力度不够,则会自动弹回第1个页面。 由于PageView组件主要用于在多个整屏页面之间切换,这里它的每个子组件的尺寸都会被约束为父级允许的最大尺寸,如全屏。 1. 页面固定 默认情况下,PageView总是会在滚动结束后稳稳地停留在某个页面上,而不会停在2个页面之间。当页面为水平方向滚动时,相邻页面之间的滚动幅度为PageView的宽度,而当页面为垂直方向滚动时(可通过传入scrollDirection: Axis.vertical实现),每次滚动的幅度则为PageView的高度。 1) pageSnapping 如需允许用户停留在相邻页面之间的任意位置,则可传入pageSnapping: false实现。例如可先通过scrollDirection参数设置垂直滚动,再通过pageSnapping属性取消页面固定,代码如下: PageView( scrollDirection: Axis.vertical, pageSnapping: false, children: [ Container( color: Colors.grey, child: Center( child: Text("这是第一页"), ), ), Container( color: Colors.white, child: Text("第二页"), ), ], ) 图525禁用页面固定可使ListView 在任意位置停留 当用户滑动屏幕由第1页翻至第2页的过程中突然停止,ListView不会自动完成翻页操作,也不会跳转到第1页,而是直接停在两个页面之间,如图525所示。 2) onPageChanged 另外,PageView组件还支持onPageChanged(页码变化)属性,可以设置一个回传函数。每当发生翻页时Flutter会调用这个函数,并提供当前页面的索引,以方便开发者处理相应的业务逻辑。无论pageSnapping是否开启,页码变化的回传函数都会在翻页过半时触发,而不是在动作完成后触发。若用户在两个页面之间反复翻动而不松开手指,这里的回传函数可触发多次。 2. 页面控制器 PageView组件与之前介绍过的ListView及一些其他的类似组件不同,这里controller属性需要传入的控制器类型为PageController(页面控制器),继承于ScrollController(滚动控制器)。它除了支持普通ScrollController所支持的全部功能,如jumpTo(跳转至)或offset(读取当前位置)等以逻辑像素为单位的操作外,还可以额外支持从逻辑像素到“页面索引”之间的转换。 例如,jumpTo(300)可以跳转300个逻辑像素,但jumpToPage(3)则可以直接跳转到第4个页面(因为第1个页面的索引是0)。假设某款手机的屏幕尺寸是896×414逻辑像素,若PageView是水平滚动,则一般情况下jumpToPage(3)相当于jumpTo(414.0*3),即3倍于屏幕宽度,但若PageView的页面为垂直方向滚动,则一般情况下还需要从屏幕高度中扣除常见的导航条等元素的高度,最终跳转的幅度为PageView组件的实际尺寸高度的3倍。 同样地,animateToPage()和animateTo()用法类似,但数量单位从逻辑像素变为了页面索引。例如可通过300ms的时间,将页面翻至第3页,代码如下: _controller.animateToPage( 2,//翻至第3个页面(因为第一页的索引是0) curve: Curves.linear, duration: Duration(milliseconds: 300), ); 页面控制器还支持previousPage(前一页)和nextPage(后一页)方法,使用方式与animateToPage()翻页方法类似,但不需要传入目标页码,直接实现由当前所在页面滚动至上一页或下一页的动画效果。 另外,开发者可通过PageController的page属性直接读取当前所在页面。例如屏幕正停留在从第4个页面翻到第5个页面一半的位置时,print(_controller.page) 可以得到3.5的输出值。 3. 动态加载 除了通过children属性直接将全部子组件传入以外,PageView组件也支持PageView.builder()这个命名构造函数,通过builder方法动态加载页面列表中的元素。这与ListView组件的builder()用法相同,包含itemBuilder回传函数,并可用itemCount设置元素的总数量,代码如下: PageView.builder( itemCount: 20, itemBuilder: (context, index) { return Center( child: Text("这是第${index + 1}页"), ); }, ) 这里定义了20个页面,每个页面的中心位置都由Text组件显示页码,运行效果略。 4. 其他属性 PageView组件还有几个与ListView组件相同的参数,它们分别是scrollDirection(滚动方向)、reverse(倒序)和physics(滚动物理),这些属性的名称和用法均与ListView组件的同名属性一致,读者可翻阅5.1.1节关于ListView的介绍。 5.2滚动监听和控制 5.2.1Scrollbar Scrollbar组件可以为大部分滚动列表添加滚动条。使用时只需要在滚动列表组件(例如ListView、GridView、ListWheelScrollView甚至PageView)的父级插入Scrollbar组件,代码如下: Scrollbar( child: ListView.builder( itemCount: 200, itemBuilder: (context, index) { return Center(child: Text("这是第${index + 1}个元素")); }, ), ) 这样可为ListView组件添加一个滚动条,运行在安卓设备时会呈现Material风格的滚动条效果,如图526(左图)所示,而在iOS或macOS设备上则会自动切换为Cupertino风格的滚动条,如图526(右图)所示。事实上Cupertino风格的滚动条背后是由CupertinoScrollbar组件实现的,Flutter默认会根据程序运行时的当前设备自动适配。若需要在任何设备上都显示iOS风格的滚动条,则可以直接使用CupertinoScrollbar组件。 图526在安卓和iOS设备上的滚动条效果 滚动条会在用户开始滚动屏幕时出现,并在滚动完成不久后消失,因此,只有当列表的元素数量足够时才可能观察到滚动条效果。 1. 拖动跳转 自从iOS 13系统于2019年9月发布后,iOS设备上的滚动条便开始支持用户手指直接拖动,以及跳转列表。在Flutter程序中,如果屏幕上只有一个ListView等支持滚动的组件,则默认情况下CupertinoScrollbar组件也会自动支持手指拖动跳转功能,无须编写任何代码。 如果程序的某个页面使用了多个可滚动的列表类组件,并且需要支持拖动滚动条跳转的功能,则需要借助controller(控制器)来指定Scrollbar与列表的对应关系,代码如下: //第5章/scroll_bar_controller.dart import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: MyHomePage(), ); } } class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { //定义两个ScrollController滚动控制器 ScrollController _controller1 = ScrollController(); ScrollController _controller2 = ScrollController(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Scrollbar Demo"), ), body: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Container( height: 280, child: Scrollbar(//第1个滚动条 controller: _controller1, //使用第1个控制器 child: ListView.builder( //第1个列表(普通样式) controller: _controller1,//也使用第1个控制器 itemCount: 2000, itemBuilder: (context, index) => Center(child: Text("列表1的第${index + 1}个元素")), ), ), ), Container( height: 280, child: Scrollbar( //第2个滚动条 controller: _controller2, //使用第2个控制器 child: GridView.builder( //第2个列表(网格样式) gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4), controller: _controller2,//使用第2个控制器 itemCount: 2000, itemBuilder: (context, index) => Center(child: Text("网格的\n第${index + 1}个元素")), ), ), ), ], ), ); } } 运行效果如图527所示。 图527用控制器支持两个滚动条的拖动跳转 同一个页面很少会出现多个滚动列表,即使出现了通常也不需要支持手指拖动滚动条时发生跳转,因此实战中很少需要使用这里的controller属性。 2. 保持可见 如需使滚动条在列表没有发生滚动时也保持可见,则可以使用isAlwaysShown(永远显示)属性,并将此属性设置为true即可。唯一需要注意的是,当启用该特性时,controller(控制器)不能为空,否则运行时会出现错误。 5.2.2RefreshIndicator RefreshIndicator(刷新指示器)组件可为大部分滚动列表添加“下拉刷新”的功能,但它目前只支持垂直方向滚动的列表。使用时只需要在滚动列表(如ListView)组件的父级插入RefreshIndicator组件,并通过onRefresh参数传入刷新时的业务逻辑,代码如下: RefreshIndicator( onRefresh: () async { await Future.delayed(Duration(seconds: 2)); }, child: GridView.count( crossAxisCount: 4, children: List.filled(50, Text("列表的一格")), ), ) 图528下拉刷新的效果 上述代码为GridView组件添加了刷新指示器,在用户下拉时出现,效果如图528所示。 当用户成功完成下拉刷新的连贯手势后,Flutter会调用onRefresh参数传入的回传函数进行刷新操作。在该刷新函数的执行过程中,刷新指示器的滚动进度条会保持可见,直到该异步函数执行完毕后刷新指示器才会消失。 这里值得注意的是,只有当列表确实可被滚动时才有可能出现下拉刷新的效果。如在实战中发现某列表无法滚动,则可以考虑将该列表的physics(滚动物理)属性设置为 AlwaysScrollableScrollPhysics(永远可以滚动),详情可参考5.1.1节所介绍的ListView组件的相关内容。 1. 自定义样式 RefreshIndicator提供了4个用于自定义下拉刷新的指示器(滚动进度条)样式的属性。由于这部分内容相对比较简单,本书先依次介绍这些属性,最后一并举例。 1) color 颜色,指的是刷新进度条的前景颜色,默认为程序主题中的强调色,即ThemeData.colorScheme.secondary属性的颜色。若没有单独设置过,则Flutter程序默认为#2196f3淡蓝色。 例如,传入color: Colors.red可将刷新进度条改为红色。笔者测试时发现改变这里的color属性后,热更新(Hot Reload)无效,需要彻底重新启动Flutter程序才能观察到新赋的值。 2) backgroundColor 背景颜色,顾名思义,指的是刷新进度条的背景色,默认为程序主题中的画布背景色,即ThemeData.colorScheme.secondary属性的颜色。若没有单独设置过,则Flutter程序默认为#fafafa近白色。 3) displacement 位移,指的是刷新时进度条与列表顶部的位置关系,默认为40.0逻辑像素。这里需要注意的是,该属性定义的是用户松开手指触发刷新操作后,刷新的等待过程中的进度条的位置。在用户下拉的过程中实际产生的位移可超过这个数值。 4) strokeWidth 刷新图标的粗细,默认为2.0逻辑像素。例如可传入strokeWidth: 4.0将其加粗。 下例通过上述4个属性,同时设置RefreshIndicator组件的颜色、背景色、位移和图标的粗细,代码及详细注释如下: //第5章/refresh_indicator_styles.dart RefreshIndicator( onRefresh: () async { await Future.delayed(Duration(seconds: 2)); }, color: Colors.white,//颜色: 白色 backgroundColor: Colors.black, //背景色: 黑色 strokeWidth: 4.0, //粗细: 4单位 displacement: 20, //位移: 20单位 child: GridView.count( physics: AlwaysScrollableScrollPhysics(), crossAxisCount: 4, children: List.filled(10, Text("列表的一格")), ), ) 运行效果如图529所示。 图529自定义下拉刷新的样式 2. 实例 这里举一个利用RefreshIndicator组件实现为ListView列表添加内容的例子,完整代码如下: //第5章/refresh_indicator_example.dart import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', home: MyHomePage(), ); } } class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { //列表的初始内容 List<String> items = ["第1项", "第2项", "第3项"]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("RefreshIndicator Demo"), ), body: RefreshIndicator( onRefresh: () async { //等待2s,模拟网络延时 await Future.delayed(Duration(seconds: 2)); //添加新内容,并附加时间戳 setState(() { items.add("新增内容: ${DateTime.now()}"); }); }, child: ListView( //通过滚动列表将items的全部内容显示出来 children: items.map((item) => Text("$item")).toList(), ), ), ); } } 程序刚开始运行时,ListView列表中只有“第1项”“第2项”“第3项”这3个初始内容。当用户下拉触发刷新时,onRefresh属性中的函数会被调用。 图530RefreshIndicator实例运行效果 刷新过程为先等待2s,模拟网络延时,此时刷新进度条可见。2s结束后添加新内容,附上时间戳,并利用setState使Flutter重绘。当onRefresh异步函数执行完毕后,刷新进度条会被自动隐藏,且新增内容会被显示到列表中。 图530展示了本例运行时用户手动下拉刷新6次后,新增了6条带有时间戳的内容,并显示正在进行第7次刷新的效果。 5.2.3Dismissible (13min) Dismissible原意是“可被清除的”,因此这个Flutter组件主要用于帮助开发者实现看似复杂的“滑动清除”效果。例如,在电子邮箱管理软件中经常可以看到滑动即可删除某封电子邮件的功能。这个组件最常放在ListView之类的列表中,作为列表children的每个Widget的父级组件,为所有元素添加滑动清除功能,但Dismissible也可被用于其他任何接收Widget类型的场景。 使用时需要在可被清除的组件的父级插入Dismissible,并传入一个key(键)。如可在ListView组件的itemBuilder中返回Dismissible组件并利用child属性继续指定子组件,代码如下: ListView.separated( itemCount: 20, separatorBuilder: (_, index) { return Divider(); }, itemBuilder: (_, index) { return Dismissible( //添加滑动清除功能 key: ValueKey(index), //传入key作为标识 child: Container( height: 50, color: Colors.grey, alignment: Alignment.center, child: Text("这是第${index + 1}项"), ), ); }, ) 程序运行时可以得到一个包含20个元素的列表,并且每个元素都支持滑动清除。图531展示了当用户已经把第3项和第4项滑走,并正在滑动第5项时的效果。 图531滑动清除的运行效果 1. 滑动时的背景 Dimissible组件提供background(背景)和secondaryBackground(第二背景)属性,可用于设置向2个不同方向滑动的过程中的背景。若没有设置secondaryBackground属性,则无论用户向哪个方向滑动,都会采用background属性中的背景。这些参数支持Widget类型,因此开发者不仅可以修改背景颜色,还可以传入图标或文字等任意组件当作背景。 例如一款手机通讯录软件,从左向右滑动某位联系人可以打电话,从右向左滑动可以发短信。这样利用background和secondaryBackground属性,配合Icon组件,可在滑动时提供相应的图标以提示用户,代码如下: //第5章/dismissible_example.dart Dismissible( key: UniqueKey(), background: Container( padding: EdgeInsets.all(16), color: Colors.black, alignment: Alignment.centerLeft, child: Icon( Icons.phone, color: Colors.white, ), ), secondaryBackground: Container( padding: EdgeInsets.all(16), color: Colors.grey, alignment: Alignment.centerRight, child: Icon(Icons.sms), ), child: Container( height: 56, alignment: Alignment.center, child: Text("这是第${index + 1}项"), ), ) 图532左划和右划时展示不同背景 当用户向左或向右滑动时可观察到不同的背景,如图532所示。 2. 滑动行为 1) direction 方向属性用于设置Dismissible支持的滑动方向,需接收一个DismissDirection枚举类,有6种值,分别是horizontal(水平方向)、vertical(垂直方向)、startToEnd(从起始到末尾)、endToStart(从末尾到起始)、up(上)和down(下)。其中,前2种值表示支持水平或垂直维度的任意方向,例如设置vertical就表示同时支持上滑和下滑。后4种值表示唯一方向,例如设置startToEnd即表示只支持顺着阅读方向(在汉语或英语设备上即从左到右)滑动。 2) dismissThresholds 清除阈值,用于定义当用户的滑动手势完成到什么程度时可以视为成功的清除操作,低于该程度的滑动不会触发清除效果。默认为0.4,即用户至少滑动40%的位置后松手,该组件会自动帮其完成剩下的滑动动画,并顺利清除该元素。若用户滑动不足40%时提前松手,则视为取消操作。 实战中很少需要改动这里的默认40%阈值。如需改动,则可以传入一个Map数据类型,说明每种支持的滑动方向对应的有效阈值,例如可将从左向右滑动的阈值改为20%,而从右向左滑动的阈值改为99%难以滑动,代码如下: dismissThresholds: { DismissDirection.startToEnd: 0.20, DismissDirection.endToStart: 0.99, }, 当用户手指较快速地做出“甩出”动作时,这里会收到一个非常接近但不足1.0的数值,因此当设置阈值99%时,几乎只有此类大幅甩出动作才可能超过阈值,而当阈值被设置为大于或等于1.0时,用户将无法通过手势完成滑动清除的操作。 3) crossAxisEndOffset 交叉轴位移,用于定义当组件在滑动时向另一个维度的位移情况,默认为0,不会发生交叉轴位移。例如取值2.5即为位移2.5倍于组件尺寸的距离,而取值-1.0时则向反方向位移1.0倍。由于作用轴为交叉轴,在水平滑动的Dismissible中具体表现为child向上或向下飘走,如图533所示。 4) movementDuration和resizeDuration 移动时长是指当用户滑动超过阈值后松开手指,Flutter自动帮其完成操作(补滑)的动画时长,或当用户滑动不足阈值时提前松开手指,Flutter撤销该滑动操作,并将元素缓缓放回原位的时长。 实际运行时,Dismissible内部的动画控制器会处理整个动画,因此这里的时长是指从0%滑动到100%的总时长,开发者不必担心用户具体是在什么阶段松手,child补滑的速度是恒定的。 缩放时长则是指当用户成功触发滑动清除手势,并且Dismissible已经将其补滑到位后,原组件逐渐变小并最终消失的动画时长。例如设置resizeDuration: Duration(seconds: 10)表示将时长改为10s的超慢速动画后,可以清晰地观察到元素逐渐消失的过程,如图534所示。 图533水平滑动时child向上方位移的效果 图534被清除的元素正在逐渐消失 3. 滑动事件 1) onResize和onDismissed 当用户完成滑动手势并成功触发清除操作,并且元素移动的动画播放完毕后,子组件将开始进行第二部分动画,逐渐缩小并最终消失。在尺寸缩小的动画进行过程中,Flutter会反复多次调用onResize回传函数。 当移动和缩放动画均播放完毕后,Flutter会调用一次onDismissed回传函数,并将滑动的方向提供给开发者,以便完成相应的业务逻辑。例如可将它们打印出来,代码如下: onResize: () => print("resizing"), onDismissed: (direction) => print(direction), 用户完成滑动操作的手势后,清除操作开始进行。此时终端会出现大量的resizing输出,并等到该组件最终完成缩放动画并消失后,终端会显示一行例如DismissDirection.startToEnd的字样。 2) confirmDismiss 当用户完成滑动手势后,在真正处理onDismissed事件之前,Flutter还会调用confirmDismiss参数中的回传函数确认是否需要继续操作。如果函数的最终返回值为true,则会继续清除操作。若函数返回值为false,则会撤销操作,并开始逆向播放动画,将被清除的组件重新移动回原地。 这里用AlertDialog组件举例,弹出对话框后请用户确认是否删除,代码如下: //第5章/dismissible_confirm.dart import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: MyHomePage(), ); } } class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Dismissible Demo"), ), body: ListView.separated( itemCount: 20, separatorBuilder: (_, __) => Divider(), itemBuilder: (_, index) { return Dismissible( key: UniqueKey(), confirmDismiss: (DismissDirection direction) async { return await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text("确认"), content: Text("确认要删除这一项吗?"), actions: <Widget>[ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text("取消"), ), TextButton( onPressed: () => Navigator.of(context).pop(true), child: Text( "删除", style: TextStyle(color: Colors.red), )), ], ); }, ); }, background: Container( padding: EdgeInsets.all(16), color: Colors.black, alignment: Alignment.centerLeft, child: Icon( Icons.delete_outline, color: Colors.white, ), ), child: Container( height: 56, alignment: Alignment.center, child: Text("这是第${index + 1}项"), ), ); }, ), ); } } 运行效果如图535所示。 图535滑动清除之前先弹出用户确认对话框 对AlertDialog组件不熟悉的读者可翻阅第9章“悬浮与弹窗”中的相关内容与介绍。 5.2.4ScrollConfiguration 如果需要改变一部分或全部列表的默认样式,则可以使用ScrollConfiguration组件。这种思路与同时设置所有子Text组件的DefaultTextStyle组件,或同时设置所有子Icon组件的IconTheme组件类似,所有列表类组件的默认样式是由最近上级的ScrollConfiguration组件提供的,因此,若需要全局设置整个应用程序的所有列表默认样式,则可将ScrollConfiguration组件插入接近组件树根部的位置。若只需设置某个列表的默认样式,则应把ScrollConfiguration组件直接插入该列表组件的父级,从而避免干扰到其他的列表。 使用时开发者必须通过behavior参数传入一个ScrollBehavior类的值,例如可传入ScrollBehavior(),使用默认样式,代码如下: ScrollConfiguration( behavior: ScrollBehavior(), child: ListView( children: [ Text("1"), Text("2"), ], ), ) 1. ScrollBehavior 一般实战中开发者会创建一个新的继承ScrollBehavior的类,以实现自定义样式。继承时,一般会重写buildViewportChrome和getScrollPhysics方法。 1) buildViewportChrome 这是用于在列表组件外部添加修饰的属性,若不想添加任何修饰,则可以直接回传child本身。默认情况下,Flutter会为Android和Fuchsia这两个操作系统添加滚动过量时的波形色块效果,为其他系统(包括iOS、Linux、Windows和macOS)不添加任何修饰。 2) getScrollPhysics 滚动物理,默认情况下在iOS和macOS这两个操作系统上呈现过量滚动后自动弹回的动画效果,而在其他操作系统(包括Android、Fuchsia、Linux和Windows)则直接卡住,触碰到列表的边缘后不能过量滚动。 2. 实例 这里举个例子,不判断当前操作系统,当任何设备的列表滚动至边缘时均不能过量滚动,并且出现类似安卓的波形色块效果,但颜色改为灰色。为了实现这个效果,这里使用ScrollConfiguration组件,传入自制的MyScrollBehavior类,继承自ScrollBehavior,并重写上述两种方法。 在buildViewportChrome方法中,这里在列表外部添加修饰过量滚动时的色块修饰,并定义为灰色。在getScrollPhysics方法中,则直接将滚动物理设置为ClampingScrollPhysics,完整代码如下: //第5章/scroll_configuration_example.dart import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: MyHomePage(), ); } } class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("ScrollConfiguration Demo"), ), body: ScrollConfiguration( behavior: MyScrollBehavior(), child: ListView.separated( itemCount: 20, separatorBuilder: (_, __) => Divider(), itemBuilder: (_, index) { return Container( height: 56, alignment: Alignment.center, child: Text("这是第${index + 1}项"), ); }, ), ), ); } } class MyScrollBehavior extends ScrollBehavior { @override Widget buildViewportChrome(context, child, AxisDirection axisDirection) { return GlowingOverscrollIndicator( child: child, axisDirection: axisDirection, color: Colors.grey, ); } @override ScrollPhysics getScrollPhysics(BuildContext context) { return ClampingScrollPhysics(parent: RangeMaintainingScrollPhysics()); } } 运行效果如图536所示。 图536使用ScrollConfiguration设置子列表组件的样式 5.2.5NotificationListener 滚动类的列表组件如ListView或GridView等,在滚动的过程中会产生滚动通知事件。这类通知事件会沿着组件树向上冒泡(Bubble Up),直到被某个监听该通知事件的组件拦截为止。 本章之前介绍的滚动条Scrollbar组件和下拉刷新RefreshIndicator组件就是通过监听滚动通知事件获知滚动列表当前状态,以达到正确显示滚动进度或在恰当的时机触发刷新操作等功能,因此当使用它们时,只需简单地将Scrollbar或RefreshIndicator插入滚动列表组件的上级,并不需要编写过多的额外代码,它们就能自动获取列表的状态,非常方便。 开发者也可以使用NotificationListener组件直接监听此类通知事件。当下级组件发出事件时,onNotification回传函数会被调用,同时该函数的返回值(布尔类型)决定了是否拦截事件,被拦截后的事件将不再继续向上级冒泡,代码如下: NotificationListener( onNotification: (Notification notification) { print(notification);//输出通知内容 return false; //不拦截(通知将继续冒泡) }, child: ListView.builder( itemBuilder: (_, index) => Text("$index"), ), ) 在一个简单的ListView列表的父级插入NotificationListener组件后,一旦列表开始滚动,就可以在命令行观察到一系列事件。例如产生以下输出内容: I/flutter (22142): ScrollStartNotification(depth: 0 (local), FixedScrollMetrics(669.6..[657.5]..Infinity), DragStartDetails(Offset(165.8, 469.4))) I/flutter (22142): UserScrollNotification(depth: 0 (local), FixedScrollMetrics(669.6..[657.5]..Infinity), direction: ScrollDirection.reverse) I/flutter (22142): ScrollUpdateNotification(depth: 0 (local), FixedScrollMetrics(670.8..[657.5]..Infinity), scrollDelta: 1.1026278409090082, DragUpdateDetails(Offset(0.0, -1.1))) I/flutter (22142): ScrollUpdateNotification(depth: 0 (local), FixedScrollMetrics(673.3..[657.5]..Infinity), scrollDelta: 1.1026278409090082, DragUpdateDetails(Offset(0.0, -1.1))) I/flutter (22142): ScrollUpdateNotification(depth: 0 (local), FixedScrollMetrics(691.8..[657.5]..Infinity), scrollDelta: 1.1026278409090082, DragUpdateDetails(Offset(0.0, -1.1))) I/flutter (22142): ScrollUpdateNotification(depth: 0 (local), FixedScrollMetrics(696.6..[657.5]..Infinity), scrollDelta: 1.1026278409090082, DragUpdateDetails(Offset(0.0, -1.1))) I/flutter (22142): ScrollEndNotification(depth: 0 (local), FixedScrollMetrics(696.6..[657.5]..Infinity), DragEndDetails(Velocity(0.0, 0.0))) I/flutter (22142): UserScrollNotification(depth: 0 (local), FixedScrollMetrics(696.6..[657.5]..Infinity), direction: ScrollDirection.idle) 这里包括了ScrollStartNotification(滚动开始通知)、UserScrollNotification(用户滚动通知,通常在用户改变滚动方向时触发)、ScrollUpdateNotification(滚动更新通知)、ScrollEndNotification(滚动终止通知)等。这些通知内含具体的事件细节,如滚动更新通知包括滚动了多少逻辑像素等信息。若安卓用户在列表触边后继续滚动,则还会触发OverscrollNotification(过度滚动通知),表示列表已无法再继续滚动。在iOS系统上触边的列表会继续滚动,并在用户松开手指后弹回,因此不会触发这个通知。 1. 通知拦截 在NotificationListener的onNotification回传函数运行结束时,开发者可以选择回传一个布尔值,表明该通知事件是否有必要继续向上级冒泡。一般情况下,当需要处理的业务逻辑已经被处理完毕后可以选择回传true拦截该通知,阻止其继续通知上级的组件,以节约不必要的性能开支,但这么做时需注意确保父级没有依赖该通知的组件。 例如当NotificationListener组件选择拦截通知时,其父级的Scrollbar组件将无法获得滚动通知,因此无法显示列表的滚动进度。当NotificationListener不拦截通知时,Scrollbar就可以收到通知,并利用通知内容,正确地显示滚动条,代码如下: Scrollbar( child: NotificationListener( //拦截通知; 改为false后滚动条恢复正常 onNotification: (_) => true, child: ListView.builder( itemCount: 200, itemBuilder: (_, index) => Text("$index"), ), ), ) 2. 自定义通知事件 在Flutter中,滚动列表在滚动时发出的通知事件只是众多通知事件之一。其他Flutter框架自带的通知事件还有KeepAliveNotification、LayoutChangedNotification、OverscrollIndicatorNotification等。除此之外开发者还可以通过继承Notification类,自定义通知事件。例如可定义MyNotification类,并支持在通知事件内部存储一个dynamic类型(支持任意数据类型)的细节信息,代码如下: class MyNotification extends Notification { //自定义通知内部变量,用于存储通知细节信息 final dynamic details; MyNotification(this.details); } 当需要发送通知事件时,可通过调用Notification类的dispatch方法触发通知。例如当用户单击按钮时发出通知,并将Colors.green作为通知细节,传给该自定义通知的构造函数,代码如下: ElevatedButton( child: Text("发送绿色通知"), onPressed: () => MyNotification(Colors.green).dispatch(context), ) 接着,若在组件树的上级插入NotificationListener组件,即可在MyNotification被用户触发时收到该通知。由于NotificationListener还可能会收到其他组件发出的其他通知,因此这里最好先判断通知类型是否为MyNotification类,以避免受到其他无关通知的干扰,代码如下: NotificationListener( onNotification: (notification) { //判断通知是否为自定义的 MyNotification 类型 if (notification is MyNotification) { //如果是,则打印出自定义通知中的细节内容 print(notification.details); return true;//拦截该通知,不再冒泡 } return false;//不拦截其他类型的通知 }, child: ... ) 本例的完整源代码如下: //第5章/notification_listener.dart import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp(home: MyHomePage()); } } class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Notification Demo")), body: NotificationListener( //监听通知 onNotification: (notification) { //判断通知是否为自定义的 MyNotification 类型 if (notification is MyNotification) { //打印出自定义通知中的细节内容 print(notification.details); return true;//拦截,不再冒泡 } return false;//不拦截其他类型的通知 }, child: Sender(), ), ); } } class Sender extends StatelessWidget { @override Widget build(BuildContext context) { return Wrap( spacing: 20, children: [ ElevatedButton( child: Text("发送字符串通知"), onPressed: () => MyNotification("hello world").dispatch(context), ), ElevatedButton( child: Text("发送颜色通知"), onPressed: () => MyNotification(Colors.blue).dispatch(context), ), ], ); } } class MyNotification extends Notification { //自定义通知内部变量,用于存储通知细节信息,类型为dynamic,即支持任意类型 final dynamic details; MyNotification(this.details); } 程序运行效果如图537所示。 图537自定义通知的发送按钮运行效果 当用户依次单击2个按钮后,即可观察到程序输出的结果如下: flutter: hello world flutter: MaterialColor(primary value: Color(0xff2196f3)) 这里需要注意的是,NotificationListener只会监听子级(children)和其他下级(descendants)组件发出的通知事件,而无法监听父级(parents)和其他上级(ancestors)组件,也不会监听本身(或同级)发出的通知事件,因此在这个例子中,NotificationListener的child属性传入的是自定义的Sender组件,而不是直接嵌套Column完成组件构造,以确保发送通知事件的是子组件而不是本身。 实战中除了可以将组件单独分离外,还可以通过Builder实现从属关系,直接在原地完成“匿名子组件”的构造,对此不熟悉的读者可参考本书第9章的Flutter框架小知识“什么时候需要使用Builder组件”。 5.2.6SingleChildScrollView 本章在开头提到,数据显示通常是大部分应用程序界面的主要环节,之后本书也花费大量篇幅详细地介绍了ListView、GridView及其他支持动态加载元素的滚动列表类组件,但在实战中,屏幕滚动的作用绝非仅限于高效地将成千上万条元素呈现给用户。例如有时只是担心较小屏幕的设备可能会显示不下某个用户界面而已。例如偏好设置页面,若选项较多时也应加上滚动效果,但可能选项也不会多到需要动态加载。这时也可以选用SingleChildScrollView组件,方便地为任何组件(尤其是Column组件)添加滚动功能,代码如下: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Container(height: 250, color: Colors.grey[200]), Container(height: 250, color: Colors.grey[400]), Container(height: 250, color: Colors.grey[600]), Container(height: 250, color: Colors.grey[800]), ], ), ) 这里利用Column组件垂直排列了4个高度为250单位的Container组件,总高度为1000单位。程序运行后,当可用高度不足1000逻辑像素时,Column组件理应出现如图538(左图)所示的溢出,但由于其父级插入了SingleChildScrollView组件,这里不会溢出,如图538(右图)所示,并会自动开始允许用户滚动屏幕。 图538界面溢出时和允许滚动时的对比 但若本例中的Column原本就不足4个Container组件,不足以导致溢出,则即使插入了SingleChildScrollView也完全不会允许滚动。这与ListView的效果不同,读者不妨亲自动手试试。 1. 滚动少量UI元素 开发者难免会担心当屏幕尺寸不足时可能会导致一部分界面无法显示的情况,而屏幕尺寸不足的原因是多种多样的,例如可能是程序运行时的设备屏幕本身太小,或是用户将手机设备横屏使用,或安卓用户可能同时打开了多个程序分屏显示等,甚至是网页版的用户将浏览器窗口调整得太小。 SingleChildScrollView组件就是为了解决这种大部分情况下一屏可以显示,但遇到特殊情况偶尔显示不下的问题。使用SingleChildScrollView会放弃一切动态加载的行为,因此它只适用于元素较少的滚动场景。 这里通过一个简单的登录页面举例,代码如下: SingleChildScrollView( child: Column( children: [ FlutterLogo(size: 400), Text("欢迎来到Flutter登录页面"), TextField(decoration: InputDecoration(labelText: "用户名")), TextField( decoration: InputDecoration(labelText: "密码"), obscureText: true, ), SizedBox(height: 32), ElevatedButton( child: Text("登录"), onPressed: () {}, ) ], ), ) 该页面在大部分情况下可在一屏内显示完,不需要滚动,程序运行效果如图539所示。 但当用户将设备横屏显示时,若不添加SingleChildScrollView开启滚动,则只会显示该页面上半部分内容,也就是巨大的徽标图案,而重要的输入框和按钮会超出屏幕的边界。添加SingleChildScrollView组件后,用户可以自由将页面滚动至页面下半部分,效果如图540所示。 图539登录页面竖屏显示效果 图540登录页面横屏后允许滚动的效果 这里值得一提的是,在Column的父级插入SingleChildScrollView会迫使 Column进行真空包装(类似对Column传入了mainAxisSize: MainAxisSize.min参数),因此Column内的部分属性(如主轴尺寸和主轴方向的留白等)无法生效。如需要在主轴方向添加留白,则可考虑直接插入SizedBox组件固定留白,或考虑使用LayoutBuild或MediaQuery组件,根据屏幕尺寸动态计算留白尺寸。对这些组件不熟悉的读者可参考本书第6章“进阶布局”中关于尺寸与测量的相关内容。 2. 其他属性 SingleChildScrollView还有一部分与ListView组件相同或相似的属性,它们分别是controller(滚动控制器)、reverse(倒序)、padding(内部留白)、physics(滚动物理)及scrollDirection(滚动方向)。其中滚动方向默认为垂直方向,使SingleChildScrollView适合在Column组件的父级位置插入,而修改为Axis.horizontal后则为水平方向滚动,适合作为Row组件的父级。其他属性的名称和用法均与ListView组件的同名属性一致,读者可查阅本章ListView小节的内容。 最后值得一提的是,当SingleChildScrollView确实有必要滚动时,也会发出滚动通知,因此Scrollbar等组件也可照常使用。 进阶篇