flutter 滚动家族

1,107 阅读7分钟

可滚动控件

可滚动控件官方文档

Flutter 为我们提供了多种可滚动控件用于显示各种复杂的列表长布局

  • BoxScrollView子类系列
  • Sliver家族系列
  • 长布局SingleChildScrollView 控件
  • 其他列表系列

ScrollView 总览

ScrollView的类继承关系如下,其中ScrollViewBoxScrollView都是抽象类 img

  • CustomScrollView

    包含多个子布局模型。用于创建各种自定义滚动效果的ScrollView

  • BoxScrollView

    包含单个子布局模型。通常使用其两个子类创建UI

ScrollView 由三部分组成:

  1. Scrollable

    可滚动控件会直接或间接包含一个Scrollable控件,它主要用于监听各种用户手势并实现滚动的交互模型,但不包含UI显示相关的逻辑。

  2. Viewport

    指视口。即Widget在屏幕上的实际显示区域,通常滚动列表中会有无数的项,但只有该项滑动到视口区域才是可见的,滑出视口,则不可见。

  3. Sliver

    可以组合以创建各种滚动效果的小控件,如列表,网格和扩展标题。通常可滚动控件的子项会非常多,累积的总高度非常大,如果一次性将子控件全部构建出将会非常耗费性能,因此Flutter提出一个Sliver的(“薄片”)概念。如果可滚动控件支持Sliver模型,则该滚动可以将子控件分成多个“薄片”(Sliver),只有当Sliver出现在视口中时才会去构建它,这种模型也称为“基于Sliver的延迟构建模型”。如ListViewGridView都支持该模型

img

ScrollView 常见属性

属性名类型简述
scrollDirectionAxis滚动视图的滚动轴,即滚动的方向
reversebool是否逆向。本质上是决定可滚动控件的初始滚动位置是在“头”还是“尾”
controllerScrollController控制滚动位置和监听滚动事件
primarybool是否是与父级PrimaryScrollController关联的主滚动视图。当此值为true时,滚动视图是可滚动的,即使它没有足够的内容来实际滚动。否则,默认情况下,用户只能在视图有足够内容的情况下滚动视图。需注意,为true时,controller应为null
physicsScrollPhysics决定可滚动控件如何响应用户操作,比如用户滑动完抬起手指后,继续执行动画;或者滑动到边界时,如何显示
shrinkWrapbool是否根据正在查看的内容确定滚动视图的范围。如果在指定滚动轴上的滚动视图没有包裹内容,那么滚动视图将扩展到滚动轴允许的最大尺寸。如果滚动视图在滚动轴上没有边界约束,那么该属性必须为true。包裹滚动视图的内容比扩展到最大尺寸要更消耗性能,因为在滚动过程中,内容可以扩展和收缩,意味着每次滚动时,需要重新计算滚动视图的尺寸。
cacheExtentdouble视口在可见区域之前和之后有一个区域,用于缓存用户滚动时即将可见的项目

physics参数的使用

  • BouncingScrollPhysics :允许滚动超出边界,但之后内容会反弹回来, iOS的原生效果
  • ClampingScrollPhysics : 防止滚动超出边界,滚动到列表末尾时有一个蓝色水波纹效果,Android的原生效果
  • AlwaysScrollableScrollPhysics :始终响应用户的滚动,即使没有足够的内容。
  • PageScrollPhysics:用于PageView使用的ScrollPhysics
  • FixedExtentScrollPhysics:仅适用于使用FixedExtendScrollControllers 的列表。表示仅滚动到子项而不存在任何偏移
  • NeverScrollableScrollPhysics :不响应用户的滚动,即禁用滚动。

ScrollController

  • jumpTo() 直接跳转到指定的位置
  • animateTo() 以动画方式跳转到指定的位置
  • addListener() 添加滚动监听
  • offset 获取当前的滚动位置
import 'package:flutter/material.dart';
​
class ScrollControllerTest extends StatefulWidget {
  @override
  ScrollControllerTestState createState() {
    return ScrollControllerTestState();
  }
}
​
class ScrollControllerTestState extends State<ScrollControllerTest> {
  ScrollController _controller =  ScrollController();
​
  @override
  void initState() {
    super.initState();
    //监听滚动事件,打印滚动位置
    _controller.addListener(() {
      print(_controller.offset);
    });
  }
​
  @override
  void 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: FloatingActionButton(
          child: Icon(Icons.arrow_upward),
          onPressed: () {
            //以动画方式返回到顶部
            _controller.animateTo(0.0, duration: Duration(milliseconds: 200),
                curve: Curves.ease
            );
          }
      ),
    );
  }
}

需要注意,一个ScrollController可以同时被多个Scrollable使用,ScrollController会为每一个Scrollable创建一个ScrollPosition对象,这些ScrollPosition保存在ScrollControllerpositions属性中(它是一个数组)。

如果ScrollController被设置给多个滚动控件使用,那么要获取某个滚动控件的offset ,可以使用如下方式

// 查看controller的offset属性实现
double get offset => position.pixels;
​
// 读取相关的滚动位置的方式
controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels

Scrollbar

它是一个滚动条控件,如果要给可滚动控件添加滚动条,只需使用Scrollbar作为父控件包裹可滚动控件即可。

Scrollbar(
  child: ListView(
    ///...
  ),
);

ScrollConfiguration

用于控制可滚动小控件在子树中的行为方式。它实际上是继承自InheritedWidget,内部共享了一个ScrollBehavior对象。我们使用该功能控件的目的,其实就是为了设置它的ScrollBehavior对象,从而更改某些滚动行为。

通常的,我们可以使用该控件去除那种Android式的蓝色的列表回弹效果

class MyBehavior extends ScrollBehavior{
 @override
 Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) {
    if(Platform.isAndroid||Platform.isFuchsia){
     return child;
  }else{
    return super.buildViewportChrome(context,child,axisDirection);
  }
 }
}
​
/// 使用ScrollConfiguration包裹列表
ScrollConfiguration(
       behavior: MyBehavior(),
        child: ListView(),
);

滚动监听

我们可以使用NotificationListener来监听ScrollNotification事件,这方式比ScrollController中的监听更强大。

NotificationListener<ScrollNotification>(
  onNotification: (ScrollNotification notification) {
    print("onNotification: ${notification.metrics.pixels}");
    return true;
  },
  child: ListView.builder(
      itemCount: 100,
      itemExtent: 50.0,
      itemBuilder: (context, index) {
        return ListTile(title: Text("$index"));
      }
  ),
),

onNotification回调的参数类型为ScrollNotification,它包括一个metrics属性,该属性包含一些信息:

  • pixels:当前滚动位置
  • maxScrollExtent:最大可滚动长度
  • extentBefore:滑出视口顶部的长度
  • extentInside:视口内部长度。当没有内边距时,相当于列表的长度
  • extentAfter:列表中未滑入视口部分的长度
  • atEdge:是否滑到了可滚动控件的边界(相当于列表顶或底部)

常用滚动控件

这些控件提供了更简洁的使用方式,在实际开发中使用最多。

ListView

它的同名构造方法中有几个参数需要说明

  • shrinkWrap: 当为 false 时,列表会在主轴方向上扩展到可占用的最大空间,反之列表占用的空间是其列表项高度之和,此时会耗费更多性能,每当列表项发生变化时,都会重新计算高度
  • itemExtent :如果主轴是垂直方向,则代表的是子项的高度,如果主轴为水平方向,则代表的是子项的长度。指定 该值能提升性能,每当列表项发生变化时,都不需要重新计算
  • addAutomaticKeepAlives :表示是否将列表项包裹在AutomaticKeepAlive中。在一个懒加载列表里,如果子项需要保证自己在滑出视口时不被回收,就需要设置为 true,内保就会使用KeepAliveNotification来保存其状态。如果子项要自己维护其KeepAlive 状态,此参数必须置为false
  • addRepaintBoundaries :表示是否将列表项包裹在RepaintBoundary中。为 true 时,可以避免列表项重绘,提高性能。但当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加 RepaintBoundary 反而会更高效

ListView配套使用的,还有一个ListTile控件,用于列表的子项,当然也可以单独使用

属性名类型简述
leadingWidget列表项左侧的图标
titleWidget标题,通常放置文本控件
subtitleWidget副标题
trailingWidget列表项右侧的图标
isThreeLinebool内容是否可显示3行。为true,副标题最多显示2行
densebool是否密集显示。为true时,内容及图标将会变小,显示更紧密
contentPaddingEdgeInsetsGeometry内边距
enabledbool设为 false,可禁止点击事件
onTapGestureTapCallback单击事件回调
onLongPressGestureLongPressCallback长按事件回调
selectedbool是否选中。为true时,文本和图标的颜色变为主题的主色

GridView

主要构建网格视图。在用法上与ListView非常相似,可参照其用法。

该控件主要有一个gridDelegate参数需要设置,该参数类型是SliverGridDelegate,用于控制子项如何排列。该类是一个抽象类,Flutter框架给我们提供了两个子类实现

  • SliverGridDelegateWithFixedCrossAxisCount
  • SliverGridDelegateWithMaxCrossAxisExtent

GridView配套的,也有一个GridTile控件,用于网格的子项,它带有页眉和页脚的显示功能。其headerfooter参数通常使用GridTileBar控件,该控件属性与ListTile有些类似,可参照ListTile

GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2
  ),
  itemBuilder: (context, index) {
    return GridTile(
      header: GridTileBar(
        backgroundColor:Color.fromRGBO(0, 0, 0, 0.4),
        title: Text('Header'),),
      child: Container(
        child: Image.network(
          'https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/fbb.jpg',
          fit: BoxFit.cover,),
      ),
      footer: GridTileBar(
        backgroundColor:Color.fromRGBO(0, 0, 0, 0.6),
        title: Text('Footer'),),
    );
  },
  itemCount: 20,
)

需要注意,框架的GridView默认子元素显示空间是相等的,但在实际开发中,可能会遇到子元素大小不等的情况,这里推荐一个第三方库实现

flutter_staggered_grid_view

PageView

用于页面滑动的控件,子项会占据当前屏幕的所有可视区域。它有三种构造方式

  • PageView()
  • PageView.builder()
  • PageView.custom()

它的主轴方法默认是水平方向,通常的我们也将之用于左右滑动切换,需要注意,我们也能将其主轴设置为垂直方向,用作列表式的上下滚动。

SingleChildScrollView

该控件通常用于长布局的滚动。它只能包含一个子项,通常只应在期望的内容不会超过屏幕太多时使用,因为它不支持基于Sliver的延迟实例化模型,所以包含的长布局超出屏幕尺寸太多时,性能会变差,此时应该考虑使用一些支持Sliver延迟加载的可滚动控件,如ListView之类。

如果非要使用SingleChildScrollView来实现一个短列表,可以使用SingleChildScrollView + ListBody组合的方式实现。ListBody是一个依照给定的轴方向,并按照顺序排列子元素的控件。

Sliver 家族

CustomScrollView是一种可以使用Sliver来自定义滚动模型(效果)的控件,它可以包含多种滚动模型。具体的,是在它的slivers属性里放置各种Sliver 系列控件。

Sliver通常指可滚动控件的子元素(像一个个薄片),即那些需要粘合起来的可滚动控件就Sliver。但在CustomScrollView中,如果直接将ListViewGridView作为Sliver是不行的,因为它们本身就是可滚动控件而不是Sliver。为了能让可滚动控件和CustomScrollView配合使用,Flutter为我们提供了一些Sliver版可滚动控件,如SliverListSliverGrid等等。

Sliver版的可滚动控件和非Sliver版的区别主要是前者不包含滚动模型(自身不能再滚动),而后者包含滚动模型 ,这些Sliver共用CustomScrollViewScrollable,最终实现统一的滑动效果

SliverAppBar

它类似于Android中的CollapsingToolbarLayout,可轻松实现页面头部可伸缩效果,且与AppBar的大部分的属性重合,很像AppBar的加强版。需要注意,它通常要结合 CustomScrollView 或者 NestedScrollView 来使用,且作为第一个Sliver元素。

AppBar相比,SliverAppBar 特有的属性

属性名类型简述
forceElevatedbool结合 elevation 使用,当elevation 不为 0 时,表示是否显示阴影
expandedHeightdouble展开时的高度
floatingbooltrue时下滑,则AppBar优先滑动展示,展示完成后才给滑动控件滑动
snapbooltrue时则 floating 也要为 true 。会根据手指松开的位置展开或者收缩AppBar
pinnedboolappBar 收缩到最小高度的时候是否可见

示例

Scaffold(
  appBar: AppBar(title: Text("滚动列表")),
  body: CustomScrollView(
      slivers:<Widget>[
        SliverAppBar(
          floating:true,
          pinned: true,
          snap: false,
          expandedHeight: 250.0,
          flexibleSpace: FlexibleSpaceBar(
            title: const Text('一人之下'),
            background: Image.network(
                "https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/timg.jpg",fit: BoxFit.cover,),
          ),
        ),
        SliverFixedExtentList(
          itemExtent: 50.0,
          delegate: SliverChildBuilderDelegate((BuildContext context, int index) {
                return ListTile(
                  title: Text("item $index"),
                );
              },
              childCount: 50
          ),
        ),
      ]
  ),
);

这里FlexibleSpaceBar 是一个具有折叠功能的控件,它的collapseMode属性有三个值,可以实现一些折叠效果

  • CollapseMode.none 背景不跟随滚动
  • CollapseMode.parallax 背景滚动,且具有一些收缩效果
  • CollapseMode.pin 背景跟随滚动

另外,我们还可以在SliverAppBarbottom属性中组合TabBar来使用。

SliverPadding

相当于Sliver 系列的Padding控件,可以给Sliver 系列控件加边距。如果在Sliver 系列中直接使用Padding会报错

SliverPadding(
  padding: const EdgeInsets.all(8.0),
  sliver: SliverAppBar(
    /// ......
  ),
)

SliverSafeArea

SafeArea功能相同,区别在于该控件是用于Sliver中,包装Sliver系列的控件。

SliverList

它类似于ListView,有两种表现形式

  • SliverChildBuilderDelegate 用于加载不确定数量的列表
  • SliverChildListDelegate 只能加载固定的已知数量的列表
      SliverList(
        delegate: SliverChildBuilderDelegate(
                    (context, index){
                      return ListTile(title: Text('item ${index+1}'),);
                    }, 
                   childCount: 10),
      )
​
      /// -------------------------------------------------
      SliverList(
        delegate: SliverChildListDelegate([
              ListTile(title: Text('item 1'),),
              ListTile(title: Text('item 2'),),
              ListTile(title: Text('item 3'),),
            ]),
       )

SliverFixedExtentList

SliverList 多一个itemExtent 属性,可用于固定 item 的高度 ,item 里面的子控件无法再改动高度。

SliverGrid

类似于GridView,它有三个构造函数

  • SliverGrid.count() 指定了一行展示几列 item

      /// 一行有4列
      SliverGrid.count(children: scrollItems, crossAxisCount: 4)
    
  • SliverGrid.extent() 指定item的最大宽度,然后让框架自动计算一行展示几列

      /// 任一列最大宽度为80
      SliverGrid.extent(children: scrollItems, maxCrossAxisExtent: 80.0)
    
  • SliverGrid() 自定义item排列方式

      SliverGrid(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: products.length,
        ),
        delegate: SliverChildBuilderDelegate(
          (BuildContext context, int index) {
            return _buildItem(products[index]);;
          }
      );
    

自定义SliverGrid的item展示方式,需要设置gridDelegate参数,该参数有两种选项

  • SliverGridDelegateWithMaxCrossAxisExtent

    /// 根据给定的 maxCrossAxisExtent 宽度自动分配一行展示多少列
    SliverGridDelegateWithMaxCrossAxisExtent(
                    maxCrossAxisExtent: 150,
                    mainAxisSpacing: 10.0, //主轴中间间距
                    crossAxisSpacing: 10.0, //交叉轴中间间距
                    childAspectRatio: 2.0, //item 宽高比
    ),
    
  • SliverGridDelegateWithFixedCrossAxisCount

    /// 固定一行展示多少列
    SliverGridDelegateWithFixedCrossAxisCount(
                    crossAxisCount: 4,
                    mainAxisSpacing: 10.0, //主轴中间间距
                    crossAxisSpacing: 10.0, //副轴中间间距
                    childAspectRatio: 2.0, //item 宽高比
    )
    

综合示例

CustomScrollView(
    slivers:<Widget>[
        /// 首先放一个SliverAppBar
        SliverAppBar(
          floating:true,
          pinned: true,
          snap: false,
          expandedHeight: 250.0,
          flexibleSpace: FlexibleSpaceBar(
            title: const Text('一人之下'),
            background: Image.network(
                "https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/timg.jpg",fit: BoxFit.cover,),
          ),
        ),
​
      /// 中间放一个SliverGrid
      SliverGrid(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          mainAxisSpacing: 10.0,
          crossAxisSpacing: 10.0,
          childAspectRatio: 4.0,
        ),
        delegate: SliverChildBuilderDelegate(
              (BuildContext context, int index) {
            return Container(
              alignment: Alignment.center,
              color: Colors.yellow[100 * (index % 9)],
              child:GridTile(
                  child:Text('grid tile $index')
              ),
            );
          },
          childCount: 30,
        ),
      ),
      /// 最后放一个SliverFixedExtentList
      SliverFixedExtentList(
        itemExtent: 50.0,
        delegate: SliverChildBuilderDelegate((BuildContext context, int index) {
              return ListTile(
                title: Text("item $index"),
              );
            },
            childCount: 50 //50个列表项
        ),
      ),
    ]
),

SliverPersistentHeader

SliverAppBar实际上就是封装了SliverPersistentHeader,是其的简化版本。与SliverAppBar相比,该控件可以出现在Sliver的任何位置,不一定是头部,且还能实现更多复杂的效果。

要使用SliverPersistentHeader,主要是需要一个SliverPersistentHeaderDelegate类型的参数,由于该类是一个抽象类,且没有暴露出具体的子类实现,因此我们只能自定义一个类继承自SliverPersistentHeaderDelegate,并重写四个方法

  • minExtent:收起状态下控件的高度
  • maxExtent:展开状态下控件的高度
  • shouldRebuild:是否重新构建,一般默认返回true
  • build:构建要显示的UI。其中shrinkOffset参数表示从maxExtentminExtent的距离,当shrinkOffset为0时表示完全展开
class SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
​
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return null;
  }
  @override
  double get maxExtent => null;
​
  @override
  double get minExtent => null;
​
  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }
}

Sliver头根据上下滑动实现背景渐变的过渡效果,具体示例

import 'package:flutter/material.dart';
​
class CustomSliverHeader extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: <Widget>[
          SliverPersistentHeader(
            pinned: true,
            delegate: SliverMyHeaderDelegate(
                title: '一人之下',
                appBarHeight: 60,
                expandedHeight: 300,
                paddingTop: MediaQuery.of(context).padding.top,
                background: Image.network("https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/timg.jpg",fit: BoxFit.cover,)
            ),
          ),
          SliverFillRemaining(
            child: Container(
              padding: const EdgeInsets.all(16),
              color: Colors.grey[300],
              child: Text('这是一段文章'),
            ),
          )
        ],
      ),
    );
  }
}
​
class SliverMyHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double appBarHeight;
  final double expandedHeight;
  final double paddingTop;
  final Widget background;
  final String title;
​
  SliverMyHeaderDelegate({
    this.appBarHeight,
    this.expandedHeight,
    this.paddingTop,
    this.background,
    this.title,
  });
​
  @override
  double get minExtent => this.appBarHeight + this.paddingTop;
​
  @override
  double get maxExtent => this.expandedHeight;
​
  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }
​
  Color _updateAppBarColor(shrinkOffset) {
    final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();
    return Color.fromARGB(alpha, 255, 255, 255);
  }
​
  Color _updateAppBarTitleColor(shrinkOffset, isIcon) {
    if(shrinkOffset <= 50) {
      return isIcon ? Colors.white : Colors.transparent;
    } else {
      final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();
      return Color.fromARGB(alpha, 0, 0, 0);
    }
  }
​
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return SizedBox(
      height: maxExtent,
      width: MediaQuery.of(context).size.width,
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          background,
          Positioned(
            left: 0,
            right: 0,
            top: 0,
            child: Container(
              color: _updateAppBarColor(shrinkOffset),
              child: SafeArea(
                child: SizedBox(
                  height: appBarHeight,
                  child: Row(
                    children: <Widget>[
                      IconButton(
                        icon: Icon(
                          Icons.arrow_back_ios,
                          color: _updateAppBarTitleColor(shrinkOffset, true),
                        ),
                      ),
                      Text(
                        title,
                        style: TextStyle(
                          fontSize: 20,
                          color: _updateAppBarTitleColor(shrinkOffset, false),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

SliverToBoxAdapter

如果想要在Sliver系列的可滚动视图中添加一个非Sliver控件,就可以使用SliverToBoxAdapter来包装,将各种其他的控件组合在一起。

SliverFillRemaining

可创建一个填充视口中剩余空间的Sliver控件。

SliverFillViewport

创建包含多个框状子项的Sliver控件,每个子项都会填充视口。它会将其子项沿主轴放置在线性数组中。

SliverFillViewport(
  delegate: SliverChildBuilderDelegate(
          (BuildContext context, int index) {
        return  Container(
          color: Colors.brown[200],
          child: Text('SliverFillViewport item $index'),
        );
      }, childCount: 5
  ),
  viewportFraction:1.0,//占屏幕的比例
),

滑动嵌套

NestedScrollView

该控件是一个可以实现滑动嵌套的 ScrollView。有些时候,我们可能需要将一个列表嵌套入另一个可滚动控件中作为子项,这时候可能会存在滑动冲突,而NestedScrollView则可以作为父布局实现嵌套。

import 'package:flutter/material.dart';
​
class NestedScrollViewTest extends StatefulWidget {
  @override
  NestedScrollViewTestState createState() {
    return NestedScrollViewTestState();
  }
}
​
class NestedScrollViewTestState extends State<NestedScrollViewTest>
    with SingleTickerProviderStateMixin {
  TabController _controller;
​
  @override
  void initState() {
    super.initState();
    _controller = TabController(length: 2, vsync: this);
  }
​
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
​
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              floating: true,
              pinned: true,
              snap: true,
              expandedHeight: 250.0,
              flexibleSpace: FlexibleSpaceBar(
                title: const Text('一人之下'),
                background: PageView(
                  children: <Widget>[
                    Image.network(
                      "https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/timg.jpg",
                      fit: BoxFit.cover,
                    ),
                    Image.network(
                      "https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/fbb.jpg",
                      fit: BoxFit.cover,
                    )
                  ],
                ),
              ),
            ),
            SliverPersistentHeader(
              pinned: true,
              delegate: StickyTabBarDelegate(
                child: TabBar(
                  labelColor: Colors.black,
                  controller: _controller,
                  tabs: <Widget>[
                    Tab(text: '标签1'),
                    Tab(text: '标签2'),
                  ],
                ),
              ),
            ),
          ];
        },
        body: TabBarView(
          controller: _controller,
          children: <Widget>[
            _buildTabPage(0),
            _buildTabPage(1),
          ],
        ),
      ),
    );
  }
​
  _buildTabPage(int index) {
    if (index == 0) {
      return ListView.builder(
          itemExtent: 60,
          itemBuilder: (ctx, index) {
            return ListTile(
              title: Text("item $index"),
            );
          });
    } else {
      return Container(
        alignment: Alignment.center,
        child: Text("这是一个新的Tab页"),
      );
    }
  }
}
​
/// 吸附式TabBar效果
class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  final TabBar child;
​
  StickyTabBarDelegate({@required this.child});
​
  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
      color: Theme.of(context).backgroundColor,
      child: this.child,
    );
  }
​
  @override
  double get maxExtent => this.child.preferredSize.height;
​
  @override
  double get minExtent => this.child.preferredSize.height;
​
  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }
}

其他滚动控件

ListWheelScrollView

它是一个带滚筒效果的ListView,用法上也和ListView相似。它有两种构造方式

  • ListWheelScrollView()
  • ListWheelScrollView.useDelegate()
ListWheelScrollView.useDelegate(
  itemExtent: 80,
  childDelegate: ListWheelChildBuilderDelegate(
      builder: (context, index) {
        return Container(
          margin: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
          color: Colors.primaries[index % 10],
          alignment: Alignment.center,
          child: Text('$index'),
        );
      },
      childCount: 50),
)

ListWheelScrollView 常见属性

属性名类型简述
diameterRatiodouble圆筒直径和主轴窗口的高度比。值越小越小表示圆筒越圆
perspectivedouble取值在(0,0.01]之间。为0时表示从无限远处看圆筒,0.01表示无限近处看
offAxisFractiondouble表示车轮水平偏离中心的程度
useMagnifierbool是否启用放大镜
magnificationdouble放大倍率,与useMagnifier配合使用
squeezedouble挤压程度。值越大,一屏展示的子项越多,也越挤压。
onSelectedItemChangedValueChanged<int>选中回调

ExpansionPanelList

是一个子项可展开的列表控件。

class ExpansionTest extends StatefulWidget {
  @override
  ExpansionTestState createState() {
    return ExpansionTestState();
  }
}
​
class ItemData{
  ItemData(this.index,this.isExpanded);
​
  int index;
  bool isExpanded;
}
​
class ExpansionTestState extends State<ExpansionTest> {
  List<ItemData> dataList ;
​
  @override
  void initState() {
    super.initState();
    dataList = List.generate(30, (i){
      return ItemData(i,false);
    });
  }
​
  @override
  void dispose() {
    super.dispose();
  }
​
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: SingleChildScrollView(
          child: ExpansionPanelList(
            expansionCallback: (index, isExpanded) {
              setState(() {
                dataList[index].isExpanded = !isExpanded;
              });
            },
            children: dataList.map((value) {
              return ExpansionPanel(
                isExpanded: value.isExpanded,
                headerBuilder: (context, isExpanded) {
                  return ListTile(
                    title: Text('子项 - ${value.index}'),
                  );
                },
                body: Container(
                  alignment: Alignment.center,
                  height: 80,
                  color: Colors.grey[200],
                  child: Text("这是展开的内容"),
                ),
              );
            }).toList(),
          ),
        ),
      ),
    );
  }
}

ExpansionTile

是一个折叠菜单列表,严格的说,它并不是一个可滚动的列表。

ExpansionTile(
    title: Text('折叠菜单'),
    leading: Icon(Icons.label, color: Colors.lightBlue),
    backgroundColor: Colors.white,
    initiallyExpanded: false, /// 是否默认展开
    children: <Widget>[
      ListTile(
          title:Text('这是主标题1'),
          subtitle:Text('这是副标题1')
      ),
      ListTile(
          title:Text('这是主标题2'),
          subtitle:Text('这是副标题2')
      )
    ]
)

滚动相关控件小结

  • ListView
  • GridView
  • PageView
  • SingleChildScrollView
  • NestedScrollView
  • CustomScrollView
  • Scrollbar
  • NotificationListener
  • ScrollConfiguration
  • ListWheelScrollView
  • ExpansionPanelList
  • AnimatedList

其他控件

RefreshIndicator

Material Design下拉刷新指示器,用于包装一个可滚动控件。

List _data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
​
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: RefreshIndicator(
      onRefresh: () async {
        setState(() {
          _data = List.from(_data.reversed);
        });
      },
      child: ListView.builder(
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('item ${_data[index]}'),
          );
        },
        itemExtent: 50,
        itemCount: _data.length,
      ),
    ),
  );
}

CupertinoSliverRefreshControl

ios风格的下拉刷新控件。它和RefreshIndicator稍有不同,它需要放在CustomScrollView中使用。

List _data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
​
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: SafeArea(
      child: CustomScrollView(
        physics: BouncingScrollPhysics(),
        slivers: <Widget>[
          CupertinoSliverRefreshControl(
            onRefresh: () async {
              setState(() {
                _data = List.from(_data.reversed);
              });
            },
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate((content, index) {
              return ListTile(
                title: Text('item ${_data[index]}'),
              );
            }, childCount: _data.length),
          )
        ],
      ),
    ),
  );
}

Flutter框架没有提供上拉加载功能,我们通过滚动监听也可以自行实现。这里推荐使用第三方库

GridPaper

可用于绘制一个像素宽度的网格背景,结合Stack可以实现表格效果。

Container(
  constraints: BoxConstraints.expand(),
  child: GridPaper(
    color: Colors.red,
    divisions: 1,
    subdivisions: 4,
    child: Text("这是 GridPaper 的显示效果"),
  ),
)

Form

通常在向服务器提交数据前,都会对各个输入框数据进行合法性校验,但是对每一个TextField都分别进行校验将会是一件很麻烦的事。有时候用户想清除一组TextField的内容,只能一个个清除非常麻烦。为此,Flutter提供了一个Form 组件,它可以对输入框进行分组,然后进行一些统一操作,如内容校验、输入框重置以及输入内容保存。

由于Form的子孙元素必须是FormField类型,为了方便使用,Flutter提供了一个TextFormField组件,它继承自FormField类,也是TextField的一个包装类。

var _account = '';
var _pwd = '';
final _formKey = GlobalKey<FormState>();
​
​
Form(
  key: _formKey,
  child: Column(
    children: <Widget>[
      TextFormField(
        decoration: InputDecoration(hintText: '请输入账号'),
        onSaved: (value) {
          _name = value;
        },
        validator: (String value) {
          return value.length >= 6 ? null : '最少6个字符';
        },
      ),
      TextFormField(
        decoration: InputDecoration(hintText: '请输入密码'),
        obscureText: true,
        onSaved: (value) {
          _pwd = value;
        },
        validator: (String value) {
          return value.length >= 6 ? null : '最少6个字符';
        },
      ),
      RaisedButton(
        child: Text('登录'),
        onPressed: () {
          var _state = _formKey.currentState;
          if(_state.validate()){
            _state.save();
            login(_name,_pwd);
          }
        },
      )
    ],
  ),
)

\