Flutter(六)可滚动组件

news2024/11/18 17:37:50

目录

  • 1.可滚动组件简介
    • Sliver布局模型
    • Scrollable
    • Viewport
    • Sliver
    • 可滚动组件的通用配置
  • 2.SingleChildScrollView
  • 3.ListView
    • 默认构造函数
    • ListView.builder
    • ListView.separated
    • 固定高度列表
    • ListView 原理
    • 无限加载列表,分页
      • 添加Header
  • 4.滚动监听及控制
    • ScrollController
      • 滚动位置恢复PageStorage
      • ScrollPosition
      • ScrollController控制原理
    • 滚动监听NotificationListener
  • 5.AnimatedList
  • 6.GridView
    • 默认构造函数
      • SliverGridDelegateWithFixedCrossAxisCount
      • SliverGridDelegateWithMaxCrossAxisExtent
    • GridView.count
    • GridView.extent
    • GridView.builder
  • 7.PageView与页面缓存
    • 页面缓存
  • 8.可滚动组件子项缓存
  • 9.TabBarView
    • TabBarView
    • TabBar
  • 10.CustomScrollView 和 Slivers
    • Flutter 中常用的 Sliver
      • SliverToBoxAdapter
      • SliverPersistentHeader
  • 11.自定义 Sliver
  • 12.嵌套可滚动组件NestedScrollView
    • NestedScrollView 原理
    • SliverAppBar
    • 嵌套 TabBarView

1.可滚动组件简介

Sliver布局模型

Flutter 中的可滚动主要由三个角色组成:Scrollable、Viewport 和 Sliver:

  • Scrollable :用于处理滑动手势,根据滑动偏移构建 Viewport 。
  • Viewport:显示的视窗,即列表的可视区域;
  • Sliver:视窗里显示的元素。

具体布局过程:

Scrollable 监听到滑动后,根据滑动偏移构建 Viewport ,Viewport 将当前视图信息和配置信息通过 SliverConstraints 传递给 Sliver,Sliver 中对子组件按需进行构建和布局。
在这里插入图片描述
顶部和底部灰色的区域为 cacheExtent,cacheExtent 的默认值是 250,在构建可滚动列表时我们可以指定这个值,这个值最终会传给 Viewport。

它表示预渲染的高度,需要注意这是在可视区域之外,如果 RenderBox 进入这个区域内,即使它还未显示在屏幕上,也是要先进行构建的,预渲染是为了后面进入 Viewport 的时候更丝滑

Scrollable

Scrollable({
  ...
  this.axisDirection = AxisDirection.down,//滚动方向
  this.controller,//控制滚动位置和监听滚动事件
  this.physics,//滚动组件如何响应用户操作
  required this.viewportBuilder, //构建 Viewport 的回调。
})

physics可以显式指定一个固定的ScrollPhysics,Flutter SDK中包含了两个ScrollPhysics的子类,他们可以直接使用:

  • ClampingScrollPhysics:列表滑动到边界时将不能继续滑动,通常在Android 中 配合 GlowingOverscrollIndicator(实现微光效果的组件) 使用。
  • BouncingScrollPhysics:iOS 下弹性效果。

controller默认PrimaryScrollController,父组件可以控制子滚动组件的滚动行为

viewportBuilder用于构建 Viewport 的回调。当用户滑动时,Scrollable 会调用此回调构建新的 Viewport,Viewport 变化时对应的 RenderViewport 会更新信息,不会随着 Widget 进行重新构建。

在可滚动组件的坐标描述中,通常将滚动方向称为主轴,非滚动方向称为纵轴

Viewport

Viewport({
  Key? key,
  this.axisDirection = AxisDirection.down,
  this.crossAxisDirection,
  this.anchor = 0.0,
  required ViewportOffset offset, // 用户的滚动偏移
  // 类型为Key,表示从什么地方开始绘制,默认是第一个元素
  this.center,
  this.cacheExtent, // 预渲染区域
  this.cacheExtentStyle = CacheExtentStyle.pixel, 
  this.clipBehavior = Clip.hardEdge,
  List<Widget> slivers = const <Widget>[], // 需要显示的 Sliver 列表
})

cacheExtent 和 cacheExtentStyle:CacheExtentStyle 是一个枚举,有 pixel 和 viewport 两个取值。

当 cacheExtentStyle 值为 pixel 时,cacheExtent 为预渲染区域的具体像素长度;
当值为 viewport 时,cacheExtent 的值是一个乘数,表示有几个 viewport 的长度,最终的预渲染区域的像素长度为:cacheExtent * viewport 的积,

这在每一个列表项都占满整个 Viewport 时比较实用,这时 cacheExtent 的值就表示前后各缓存几个页面。

Sliver

Sliver 主要作用是对子组件进行构建和布局

可滚动组件的通用配置

几乎所有的可滚动组件在构造时都能指定 scrollDirection(滑动的主轴)、reverse(滑动方向是否反向)、controller、physics 、cacheExtent ,这些属性最终会透传给对应的 Scrollable 和 Viewport,这些属性我们可以认为是可滚动组件的通用属性

可滚动组件都有一个 controller 属性,通过该属性我们可以指定一个 ScrollController 来控制可滚动组件的滚动

Scrollbar是一个Material风格的滚动指示器(滚动条),如果要给可滚动组件添加滚动条,只需将Scrollbar作为可滚动组件的任意一个父级组件即可

Scrollbar(
  child: SingleChildScrollView(
    ...
  ),
);

CupertinoScrollbar是 iOS 风格的滚动条,如果你使用的是Scrollbar,那么在iOS平台它会自动切换为CupertinoScrollbar

2.SingleChildScrollView

//只能接收一个子组件
SingleChildScrollView({
  this.scrollDirection = Axis.vertical, //滚动方向,默认是垂直方向
  this.reverse = false, 
  this.padding, 
  bool primary, 
  this.physics, 
  this.controller,
  this.child,
})

SingleChildScrollView在不会超过屏幕太多时使用
因为SingleChildScrollView不支持基于 Sliver 的延迟加载模型会导致性能差,
超出屏幕太多应该使用一些支持Sliver延迟加载的可滚动组件,如ListView

3.ListView

默认构造函数

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列表
  List<Widget> children = const <Widget>[],
})
  • itemExtent
    在ListView中,指定itemExtent比让子组件自己决定自身长度会有更好的性能,指定itemExtent后,滚动系统可以提前知道列表的长度,而无需每次构建子组件时都去再计算一下,尤其是在滚动位置频繁变化时(滚动系统需要频繁去计算列表高度)。
  • prototypeItem
    指定 prototypeItem 后,会在 layout 时计算一次长度,这样也就预先知道了所有列表项的长度,
    所以和指定 itemExtent 一样,指定 prototypeItem 会有更好的性能。注意,itemExtent 和prototypeItem 互斥,不能同时指定它们。
  • shrinkWrap:
    是否根据子组件的总长度来设置ListView的长度,默认值为false 。默认ListView会在滚动方向尽可能多的占用空间

默认构造函数有一个children参数,它接受一个Widget列表(List)。这种方式适合只有少量的子组件数量已知且比较少的情况,反之则应该使用ListView.builder 按需动态构建列表项

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'),
  ],
);

ListView.builder

ListView.builder适合列表项比较多或者列表项不确定的情况

ListView.builder({
  // ListView公共参数已省略  
  ...
  required IndexedWidgetBuilder itemBuilder,
  int itemCount,
  ...
})
  • itemBuilder:它是列表项的构建器,类型为IndexedWidgetBuilder,返回值为一个widget。当列表滚动到具体的index位置时,会调用该构建器构建列表项。
  • itemCount:列表项的数量,如果为null,则为无限列表
    实例:
ListView.builder(
  itemCount: 100,
  itemExtent: 50.0, //强制高度为50.0
  itemBuilder: (BuildContext context, int index) {
    return ListTitle(title: Text("$index"));
  }
);

ListView.separated

ListView.separated可以在生成的列表项之间添加一个分割组件,它比ListView.builder多了一个separatorBuilder参数,该参数是一个分割组件生成器
实例:奇数行添加一条蓝色下划线,偶数行添加一条绿色下划线。

class ListView3 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //下划线widget预定义以供复用。  
    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;
      },
    );
  }
}

在这里插入图片描述

固定高度列表

给列表指定 itemExtent 或 prototypeItem 会有更高的性能,所以当我们知道列表项的高度都相同时,强烈建议指定 itemExtent 或 prototypeItem

ListView 原理

ListView 内部组合了 Scrollable、Viewport 和 Sliver,需要注意:

  • ListView 中的列表项组件都是 RenderBox,并不是 Sliver
  • 一个 ListView只有一个Sliver,列表项按需加载是 Sliver 中实现的。
  • ListView 的 Sliver 默认是 SliverList
    指定了 itemExtent ,会使用 SliverFixedExtentList
    指定了prototypeItem,会使用 SliverPrototypeExtentList
    无论哪个,都实现了子组件的按需加载模型。

无限加载列表,分页

记载更多时显示一个loading,成功后将数据插入列表;
没有更多,则提示"没有更多"。代码如下:

import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';
import 'package:flutter/rendering.dart';

class InfiniteListView extends StatefulWidget {
  @override
  _InfiniteListViewState createState() => _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(),
        );
      });
    });
  }
}

分页

添加Header

@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"));
      }),
    ),
  ]);
}

在这里插入图片描述

Flex是弹性布局,Column是继承自Flex的,加Expanded自动拉伸组件大小,所以使用Column + Expanded来实现

4.滚动监听及控制

ScrollController({
  double initialScrollOffset = 0.0, //初始滚动位置
  this.keepScrollOffset = true,//是否保存滚动位置
  ...
})

jumpTo(double offset)、animateTo(double offset,…):这两个方法用于跳转到指定的位置,后者在跳转时会执行一个动画,而前者不会

ScrollController

controller.addListener(()=>print(controller.offset))

实例
滚动时打印出当前滚动位置,
如果超过1000像素,显示“返回顶部”的按钮,
如果没有超过1000像素,则隐藏“返回顶部”按钮。
按钮点击使ListView恢复到初始位置;

class ScrollControllerTestRoute extends StatefulWidget {
  @override
  ScrollControllerTestRouteState createState() {
    return ScrollControllerTestRouteState();
  }
}

class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
  ScrollController _controller = 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,
          );
        }
      ),
    );
  }
}

在这里插入图片描述
item高度为 50 像素,当滑动到第 20 个时 “返回顶部” 按钮显示,
点击后ListView 会在返回顶部并执行一个滚动动画,动画时间是 200 毫秒,动画曲线是 Curves.ease

滚动位置恢复PageStorage

PageStorage是一个用于保存页面(路由)相关数据的组件,每次滚动结束,可滚动组件都会将滚动位置offset存储到PageStorage中,
ScrollController.keepScrollOffset为false,则滚动位置将不会被存储ScrollController.keepScrollOffset为true时,第一次创建时会滚动到initialScrollOffset处,这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而initialScrollOffset会被忽略

一个路由中包含多个可滚动组件时,可指定PageStorageKey保存不同滚动位置,但也不一定

只有当Widget发生结构变化,导致可滚动组件的State销毁或重新构建时才会丢失状态,这种情况就需要显式指定PageStorageKey,通过PageStorage来存储滚动位置,

一个典型的场景是在使用TabBarView时,在Tab发生切换时,Tab页中的可滚动组件的State就会销毁,这时如果想恢复滚动位置就需要指定PageStorageKey

ScrollPosition

ScrollPosition是用来保存可滚动组件的滚动位置,ScrollController会为每一个可滚动组件创建一个ScrollPosition对象

一个ScrollController同时被两个可滚动组件使用,那么我们可以通过如下方式分别读取他们的滚动位置

controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels

我们可以通过controller.positions.length来确定controller被几个可滚动组件使用。

ScrollPosition有两个常用方法:animateTo() 和 jumpTo(),它们是真正来控制跳转滚动位置的方法

ScrollController控制原理

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

当ScrollController和可滚动组件关联时,可滚动组件

1.首先会调用ScrollController的createScrollPosition()方法来创建一个ScrollPosition来存储滚动位置信息,

接着,可滚动组件会调用attach()方法,将创建的ScrollPosition添加到ScrollController的positions属性中,这一步称为“注册位置”,只有注册后animateTo() 和 jumpTo()才可以被调用。

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

ScrollController的animateTo() 和 jumpTo()内部会调用所有ScrollPosition的animateTo() 和 jumpTo(),以实现所有和该ScrollController关联的可滚动组件都滚动到指定的位置

滚动监听NotificationListener

NotificationListener和ScrollController的不同

  • NotificationListener可以在可滚动组件到widget树根之间任意位置监听。
    而ScrollController只能和具体的可滚动组件关联后才可以。

  • 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置

实例:滚动显示百分比

import 'package:flutter/material.dart';

class ScrollNotificationTestRoute extends StatefulWidget {
  @override
  _ScrollNotificationTestRouteState createState() =>
      _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 false;
          //return true; //放开此行注释后,进度条将失效
        },
        child: Stack(
          alignment: Alignment.center,
          children: <Widget>[
            ListView.builder(
              itemCount: 100,
              itemExtent: 50.0,
              itemBuilder: (context, index) => ListTile(title: Text("$index")),
            ),
            CircleAvatar(
              //显示进度百分比
              radius: 30.0,
              child: Text(_progress),
              backgroundColor: Colors.black54,
            )
          ],
        ),
      ),
    );
  }
}

在这里插入图片描述
在接收到滚动事件时,参数类型为ScrollNotification,它包括一个metrics属性,它的类型是ScrollMetrics,该属性包含当前ViewPort及滚动位置等信息:

  • pixels:当前滚动位置。
  • maxScrollExtent:最大可滚动长度。
  • extentBefore:滑出ViewPort顶部的长度;此示例中相当于顶部滑出屏幕上方的列表长度。
  • extentInside:ViewPort内部长度;此示例中屏幕显示的列表部分的长度。
  • extentAfter:列表中未滑入ViewPort部分的长度;此示例中列表底部未显示到屏幕范围部分的长度。
  • atEdge:是否滑到了可滚动组件的边界(此示例中相当于列表顶或底部)

5.AnimatedList

插入或删除有动画的ListView
在这里插入图片描述

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: [
        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) {
    //通过AnimatedListState 的 removeItem 方法来应用删除动画
  }
}

onDelete

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
  );
});

6.GridView

默认构造函数

GridView({
    Key? key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController? controller,
    bool? primary,
    ScrollPhysics? physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry? padding,
    required this.gridDelegate,  //下面解释
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    double? cacheExtent, 
    List<Widget> children = const <Widget>[],
    ...
  })

gridDelegate参数,
类型是SliverGridDelegate,它的作用是控制GridView子组件如何排列(layout)。

Flutter中提供了两个SliverGridDelegate的子类SliverGridDelegateWithFixedCrossAxisCount(横轴为固定数量)和SliverGridDelegateWithMaxCrossAxisExtent(横轴子元素为固定最大长度)

SliverGridDelegateWithFixedCrossAxisCount

SliverGridDelegateWithFixedCrossAxisCount({
  @required double crossAxisCount, //横轴子元素的数量
  double mainAxisSpacing = 0.0,//主轴方向的间距
  double crossAxisSpacing = 0.0,//横轴方向子元素的间距
  double childAspectRatio = 1.0,//子元素在横轴长度和主轴长度的比例
})

子元素的大小是通过crossAxisCount和childAspectRatio两个参数共同决定的
实例:

GridView(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 3, //横轴三个子widget
      childAspectRatio: 1.0 //宽高比为1时,子widget
  ),
  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)
  ]
);

在这里插入图片描述

SliverGridDelegateWithMaxCrossAxisExtent

SliverGridDelegateWithMaxCrossAxisExtent({
  double maxCrossAxisExtent,//子元素在横轴上的最大长度
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})

如果ViewPort的横轴长度是450,那么当maxCrossAxisExtent的值在区间[450/4,450/3)内的话,子元素最终实际长度都为112.5

实例

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.count

GridView.count构造函数内部使用了SliverGridDelegateWithFixedCrossAxisCount,我们通过它可以快速的创建横轴固定数量子元素的GridView,实现和上面例子相同的效果

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.extent

GridView.extent构造函数内部使用了SliverGridDelegateWithMaxCrossAxisExtent,我们通过它可以快速的创建横轴子元素为固定最大长度的的GridView,上面的示例代码等价于:

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),
   ],
 );

GridView.builder

当子widget比较多时,我们可以通过GridView.builder来动态创建子widget。GridView.builder 必须指定的参数有两个

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

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

class InfiniteGridView extends StatefulWidget {
  @override
  _InfiniteGridViewState createState() => _InfiniteGridViewState();
}

class _InfiniteGridViewState extends State<InfiniteGridView> {
  List<IconData> _icons = []; //保存Icon数据

  @override
  void initState() {
    super.initState();
    // 初始化数据
    _retrieveIcons();
  }

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        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,
        ]);
      });
    });
  }
}
  • _retrieveIcons():此方法中通过Future.delayed来模拟从异步数据源获取数据,每次获取数据需要200毫秒,获取成功后将新数据添加到_icons,然后调用setState重新构建。

  • 在 itemBuilder 中,如果显示到最后一个时,判断是否需要继续获取数据,然后返回一个Icon

7.PageView与页面缓存

图片轮动以及抖音上下滑页切换视频功能,这些都可以通过 PageView 轻松实现

PageView({
  Key? key,
  this.scrollDirection = Axis.horizontal, // 滑动方向
  this.reverse = false,
  PageController? controller,
  this.physics,
  List<Widget> children = const <Widget>[],
  this.onPageChanged,
  
  //每次滑动是否强制切换整个页面,如果为false,则会根据实际的滑动距离显示页面
  this.pageSnapping = true,
  //主要是配合辅助功能用的,后面解释
  this.allowImplicitScrolling = false,
  //后面解释
  this.padEnds = true,
})

实例:

// Tab 页面 
class Page extends StatefulWidget {
  const Page({
    Key? key,
    required this.text
  }) : super(key: key);

  final String text;

  @override
  _PageState createState() => _PageState();
}

class _PageState extends State<Page> {
  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
}
@override
Widget build(BuildContext context) {
  var children = <Widget>[];
  // 生成 6 个 Tab 页
  for (int i = 0; i < 6; ++i) {
    children.add( Page( text: '$i'));
  }

  return PageView(
    // scrollDirection: Axis.vertical, // 滑动方向为垂直方向
    children: children,
  );
}

在这里插入图片描述

页面缓存

默认每当页面切换时都会触发新 Page 页的 build,没有缓存,一旦页面滑出屏幕它就会被销毁

allowImplicitScrolling 置为 true 时就只会缓存前后各一页,所以滑到第三页时,第一页就会销毁。

PageView为什么没有cacheExtent 参数?
发现PageView 中设置 cacheExtent 会和 iOS 中 辅助功能有冲突(读者可以先不用关注),所以暂时还没有什么好的办法。看到这可能国内的很多开发者要说我们的 App 不用考虑辅助功能,既然如此,那问题很好解决,将 PageView 的源码拷贝一份,然后透传 cacheExtent 即可。

拷源码的方式虽然很简单,但毕竟不是正统做法,可以使用KeepAliveWrapper

8.可滚动组件子项缓存

AutomaticKeepAlive 的组件,
keepAlive为false,item滑出加载区域,item会被销毁。
keepAlive为 true,item滑出加载区域,Viewport 会将item缓存起来,当item再次进入加载区域时,如果缓存有直接复用,没有就重新创建。

flukit 组件库中的KeepAliveWrapper
一个Page组件需要同时在列表中和列表外使用,为了在列表中缓存它,则我们必须实现两份。为了解决这个问题,可以使用KeepAliveWrapper
如果哪个列表项需要缓存,只需要使用 KeepAliveWrapper 包裹一下它即可。
实例: ListView 中使用KeepAliveWrapper

class KeepAliveTest extends StatelessWidget {
  const KeepAliveTest({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(itemBuilder: (_, index) {
      return KeepAliveWrapper(
        // 为 true 后会缓存所有的列表项,列表项将不会销毁。
        // 为 false 时,列表项滑出预加载区域后将会别销毁。
        // 使用时一定要注意是否必要,因为对所有列表项都缓存的会导致更多的内存消耗
        keepAlive: true,
        child: ListItem(index: index),
      );
    });
  }
}

class ListItem extends StatefulWidget {
  const ListItem({Key? key, required this.index}) : super(key: key);
  final int index;

  @override
  _ListItemState createState() => _ListItemState();
}

class _ListItemState extends State<ListItem> {
  @override
  Widget build(BuildContext context) {
    return ListTile(title: Text('${widget.index}'));
  }

  @override
  void dispose() {
  //keepAlive 设为 false,日志面板将有输出
    print('dispose ${widget.index}');
    super.dispose();
  }
}

9.TabBarView

TabBarView

TabBarView 封装了 PageView

TabBarView({
  Key? key,
  required this.children, // tab 页
  //TabController 用于监听和控制 TabBarView 的页面切换,
  //通常和 TabBar 联动。如果没有指定,则会在组件树中向上查找并使用最近的一个 DefaultTabController
  this.controller, // TabController
  this.physics,
  this.dragStartBehavior = DragStartBehavior.start,
}) 

TabBar

在这里插入图片描述

const TabBar({
  Key? key,
  required this.tabs, // 具体的 Tabs,需要我们创建
  this.controller,
  this.isScrollable = false, // 是否可以滑动
  this.padding,
  this.indicatorColor,// 指示器颜色,默认是高度为2的一条下划线
  this.automaticIndicatorColorAdjustment = true,
  this.indicatorWeight = 2.0,// 指示器高度
  this.indicatorPadding = EdgeInsets.zero, //指示器padding
  this.indicator, // 指示器
  this.indicatorSize, // 指示器长度,有两个可选值,一个tab的长度,一个是label长度
  this.labelColor, 
  this.labelStyle,
  this.labelPadding,
  this.unselectedLabelColor,
  this.unselectedLabelStyle,
  this.mouseCursor,
  this.onTap,
  ...
}) 

TabBar 通常位于 AppBar 的底部,它也可以接收一个 TabController ,如果需要和 TabBarView 联动, TabBar 和 TabBarView 使用同一个 TabController 即可

另外我们需要创建需要的 tab 并通过 tabs 传给 TabBar, tab 可以是任何 Widget

//text 和 child 是互斥的,不能同时制定。
const Tab({
  Key? key,
  this.text, //文本
  this.icon, // 图标
  this.iconMargin = const EdgeInsets.only(bottom: 10.0),
  this.height,
  this.child, // 自定义 widget
})

实例:底部tab切换

class TabViewRoute1 extends StatefulWidget {
  @override
  _TabViewRoute1State createState() => _TabViewRoute1State();
}

class _TabViewRoute1State extends State<TabViewRoute1>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;
  List tabs = ["新闻", "历史", "图片"];

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: tabs.length, vsync: this);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("App Name"),
        bottom: TabBar(
          controller: _tabController,
          tabs: tabs.map((e) => Tab(text: e)).toList(),
        ),
      ),
      body: TabBarView( //构建
        controller: _tabController,
        children: tabs.map((e) {
          return KeepAliveWrapper(
            child: Container(
              alignment: Alignment.center,
              child: Text(e, textScaleFactor: 5),
            ),
          );
        }).toList(),
      ),
    );
  }
  
  @override
  void dispose() {
  //由于 TabController 中会执行动画,持有一些资源,所以我们在页面销毁时必须得释放资源(dispose)
    // 释放资源
    _tabController.dispose();
    super.dispose();
  }
}

在这里插入图片描述
创建 TabController 的过程还是比较复杂,也可以使用系统DefaultTabController 简单实现

class TabViewRoute2 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    List tabs = ["新闻", "历史", "图片"];
    return DefaultTabController(
      length: tabs.length,
      child: Scaffold(
        appBar: AppBar(
          title: Text("App Name"),
          bottom: TabBar(
            tabs: tabs.map((e) => Tab(text: e)).toList(),
          ),
        ),
        body: TabBarView( //构建
          children: tabs.map((e) {
            return KeepAliveWrapper(
              child: Container(
                alignment: Alignment.center,
                child: Text(e, textScaleFactor: 5),
              ),
            );
          }).toList(),
        ),
      ),
    );
  }
}

这样就无需去手动管理 Controller 的生命周期,也不需要提供 SingleTickerProviderStateMixin,同时也没有其他的状态需要管理,也就不需要用 StatefulWidget 了,这样简单很多

因为TabBarView 内部封装了 PageView,如果要缓存页面,和PageView同样处理,使用 KeepAliveWrapper 包裹一下它即可。

10.CustomScrollView 和 Slivers

需求:scrollview嵌套两个listview滑动
方案:创建共用的 Scrollable 和 Viewport 对象,然后再将两个 ListView 对应的 Sliver 添加到这个共用的 Viewport 对象中就可以实现。

Flutter 提供了一个 CustomScrollView 组件来帮助我们创建一个公共的 Scrollable 和 Viewport ,然后它的 slivers 参数接受一个 Sliver 数组

Widget buildTwoSliverList() {
  // SliverFixedExtentList 是一个 Sliver,它可以生成高度相同的列表项。
  // 再次提醒,如果列表项高度相同,我们应该优先使用SliverFixedExtentList 
  // 和 SliverPrototypeExtentList,如果不同,使用 SliverList.
  var listView = SliverFixedExtentList(
    itemExtent: 56, //列表项高度固定
    delegate: SliverChildBuilderDelegate(
      (_, index) => ListTile(title: Text('$index')),
      childCount: 10,
    ),
  );
  // 使用
  return CustomScrollView(
    slivers: [
      listView,
      listView,
    ],
  );
}

在这里插入图片描述
CustomScrollView 的主要功能是提供一个公共的的 Scrollable 和 Viewport,来组合多个 Sliver

在这里插入图片描述

Flutter 中常用的 Sliver

在这里插入图片描述
还有一些用于对 Sliver 进行布局、装饰的组件,它们的子组件必须是 Sliver,我们列举几个常用的:在这里插入图片描述
还有一些其他常用的 Sliver:
在这里插入图片描述
CustomScrollView的子组件必须都是Sliver

实例:

// 因为本路由没有使用 Scaffold,为了让子级Widget(如Text)使用
// Material Design 默认的样式风格,我们使用 Material 作为本路由的根。
Material(
  child: CustomScrollView(
    slivers: <Widget>[
      // AppBar,包含一个导航栏.
      SliverAppBar(
        pinned: true, // 滑动到顶端时会固定住
        expandedHeight: 250.0,
        flexibleSpace: FlexibleSpaceBar(
          title: const Text('Demo'),
          background: Image.asset(
            "./imgs/sea.png",
            fit: BoxFit.cover,
          ),
        ),
      ),
      SliverPadding(
        padding: const EdgeInsets.all(8.0),
        sliver: SliverGrid(
          //Grid
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2, //Grid按两列显示
            mainAxisSpacing: 10.0,
            crossAxisSpacing: 10.0,
            childAspectRatio: 4.0,
          ),
          delegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) {
              //创建子widget
              return Container(
                alignment: Alignment.center,
                color: Colors.cyan[100 * (index % 9)],
                child: Text('grid item $index'),
              );
            },
            childCount: 20,
          ),
        ),
      ),
      SliverFixedExtentList(
        itemExtent: 50.0,
        delegate: SliverChildBuilderDelegate(
          (BuildContext context, int index) {
            //创建列表项
            return Container(
              alignment: Alignment.center,
              color: Colors.lightBlue[100 * (index % 9)],
              child: Text('list item $index'),
            );
          },
          childCount: 20,
        ),
      ),
    ],
  ),
);

头部SliverAppBar:SliverAppBar对应AppBar,两者不同之处在于SliverAppBar可以集成到CustomScrollView。SliverAppBar可以结合FlexibleSpaceBar实现Material Design中头部伸缩的模型,具体效果,读者可以运行该示例查看。
中间的SliverGrid:它用SliverPadding包裹以给SliverGrid添加补白。SliverGrid是一个两列,宽高比为4的网格,它有20个子组件。
底部SliverFixedExtentList:它是一个所有子元素高度都为50像素的列表。
在这里插入图片描述

SliverToBoxAdapter

可以将 RenderBox 适配为 Sliver。比如我们想在列表顶部添加一个可以横向滑动的 PageView,可以使用 SliverToBoxAdapter 来配置:

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(
      child: SizedBox(
        height: 300,
        child: PageView(
          children: [Text("1"), Text("2")],
        ),
      ),
    ),
    buildSliverFixedList(),
  ],
);

但是如果将 PageView 换成一个滑动方向和 CustomScrollView 一致的 ListView 则不会正常工作
因为:
如果 CustomScrollView 有孩子也是一个完整的可滚动组件且它们的滑动方向一致,则 CustomScrollView 不能正常工作。要解决这个问题,可以使用 NestedScrollView

SliverPersistentHeader

SliverPersistentHeader 的功能是当滑动到 CustomScrollView 的顶部时,可以将组件固定在顶部。

const SliverPersistentHeader({
  Key? key,
  // 构造 header 组件的委托
  required SliverPersistentHeaderDelegate delegate,
  this.pinned = false, // header 滑动到可视区域顶部时是否固定在顶部
  this.floating = false,
})

floating 的做用是:pinned 为 false 时 ,则 header 可以滑出可视区域(CustomScrollView 的 Viewport)(不会固定到顶部),当用户再次向下滑动时,此时不管 header 已经被滑出了多远,它都会立即出现在可视区域顶部并固定住,直到继续下滑到 header 在列表中原来的位置时,header 才会重新回到原来的位置(不再固定在顶部

delegate 是用于生成 header 的委托,类型为 SliverPersistentHeaderDelegate,它是一个抽象类,需要我们自己实现,先看下源码:

abstract class SliverPersistentHeaderDelegate {

  // header 最大高度;pined为 true 时,当 header 刚刚固定到顶部时高度为最大高度。
  double get maxExtent;
  
  // header 的最小高度;pined为true时,当header固定到顶部,用户继续往上滑动时,header
  // 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtent
  double get minExtent;

  // 构建 header。
  // shrinkOffset取值范围[0,maxExtent],当header刚刚到达顶部时,shrinkOffset 值为0,
  // 如果用户继续向上滑动列表,shrinkOffset的值会随着用户滑动的偏移减小,直到减到0时。
  //
  // overlapsContent:一般不建议使用,在使用时一定要小心,后面会解释。
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent);
  
  // header 是否需要重新构建;通常当父级的 StatefulWidget 更新状态时会触发。
  // 一般来说只有当 Delegate 的配置发生变化时,应该返回false,比如新旧的 minExtent、maxExtent
  // 等其他配置不同时需要返回 true,其余情况返回 false 即可。
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);

  // 下面这几个属性是SliverPersistentHeader在SliverAppBar中时实现floating、snap 
  // 效果时会用到,平时开发过程很少使用到,读者可以先不用理会。
  TickerProvider? get vsync => null;
  FloatingHeaderSnapConfiguration? get snapConfiguration => null;
  OverScrollHeaderStretchConfiguration? get stretchConfiguration => null;
  PersistentHeaderShowOnScreenConfiguration? get showOnScreenConfiguration => null;

}

最需要关注的就是maxExtent 和 minExtent;pined为true 时,当 header 刚刚固定到顶部,此时会对它应用 maxExtent (最大高度);当用户继续往上滑动时,header 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtent。如果我们想让 header 高度固定,则将 maxExtent 和 minExtent 指定为同样的值即可

为了构建 header 我们必须要定义一个类,让它继承自 SliverPersistentHeaderDelegate,这无疑会增加使用成本!为此,我们封装一个通用的委托构造器 SliverHeaderDelegate,通过它可以快速构建 SliverPersistentHeaderDelegate,实现如下:

typedef SliverHeaderBuilder = Widget Function(
    BuildContext context, double shrinkOffset, bool overlapsContent);

class SliverHeaderDelegate extends SliverPersistentHeaderDelegate {
  // child 为 header
  SliverHeaderDelegate({
    required this.maxHeight,
    this.minHeight = 0,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        assert(minHeight <= maxHeight && minHeight >= 0);

  //最大和最小高度相同
  SliverHeaderDelegate.fixedHeight({
    required double height,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        maxHeight = height,
        minHeight = height;

  //需要自定义builder时使用
  SliverHeaderDelegate.builder({
    required this.maxHeight,
    this.minHeight = 0,
    required this.builder,
  });

  final double maxHeight;
  final double minHeight;
  final SliverHeaderBuilder builder;

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    Widget child = builder(context, shrinkOffset, overlapsContent);
    //测试代码:如果在调试模式,且子组件设置了key,则打印日志
    assert(() {
      if (child.key != null) {
        print('${child.key}: shrink: $shrinkOffset,overlaps:$overlapsContent');
      }
      return true;
    }());
    // 让 header 尽可能充满限制的空间;宽度为 Viewport 宽度,
    // 高度随着用户滑动在[minHeight,maxHeight]之间变化。
    return SizedBox.expand(child: child);
  }

  @override
  double get maxExtent => maxHeight;

  @override
  double get minExtent => minHeight;

  @override
  bool shouldRebuild(SliverHeaderDelegate old) {
    return old.maxExtent != maxExtent || old.minExtent != minExtent;
  }
}

使用:

class PersistentHeaderRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        buildSliverList(),
        SliverPersistentHeader(
          pinned: true,
          delegate: SliverHeaderDelegate(//有最大和最小高度
            maxHeight: 80,
            minHeight: 50,
            child: buildHeader(1),
          ),
        ),
        buildSliverList(),
        SliverPersistentHeader(
          pinned: true,
          delegate: SliverHeaderDelegate.fixedHeight( //固定高度
            height: 50,
            child: buildHeader(2),
          ),
        ),
        buildSliverList(20),
      ],
    );
  }

  // 构建固定高度的SliverList,count为列表项属相
  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      itemExtent: 50,
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return ListTile(title: Text('$index'));
        },
        childCount: count,
      ),
    );
  }

  // 构建 header
  Widget buildHeader(int i) {
    return Container(
      color: Colors.lightBlue.shade200,
      alignment: Alignment.centerLeft,
      child: Text("PersistentHeader $i"),
    );
  }
}

在这里插入图片描述
总结一下:

  • 当有多个 SliverPersistentHeader时,需要注意第一个 SliverPersistentHeader 的 overlapsContent 值会一直为 false。
  • 如果我们在使用 SliverPersistentHeader 构建子组件时需要依赖 overlapsContent 参数,则必须保证之前至少还有一个 SliverPersistentHeader 或 SliverAppBar(SliverAppBar 在当前 Flutter 版本的实现中内部包含了SliverPersistentHeader)

11.自定义 Sliver

Sliver 的布局协议如下:

  • Viewport 将当前布局和配置信息通过 SliverConstraints 传递给 Sliver。
  • Sliver 确定自身的位置、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。
  • Viewport 读取 geometry 中的信息来对 Sliver 进行布局和绘制。

这个过程有两个重要的对象 SliverConstraintsSliverGeometry

class SliverConstraints extends Constraints {
    //主轴方向
    AxisDirection? axisDirection;
    //Sliver 沿着主轴从列表的哪个方向插入?枚举类型,正向或反向
    GrowthDirection? growthDirection;
    //用户滑动方向
    ScrollDirection? userScrollDirection;
    //当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移
    double? scrollOffset;
    //当前Sliver之前的Sliver占据的总高度,因为列表是懒加载,如果不能预估时,该值为double.infinity
    double? precedingScrollExtent;
    //上一个 sliver 覆盖当前 sliver 的长度(重叠部分的长度),通常在 sliver 是 pinned/floating
    //或者处于列表头尾时有效,我们在后面的小节中会有相关的例子。
    double? overlap;
    //当前Sliver在Viewport中的最大可以绘制的区域。
    //绘制如果超过该区域会比较低效(因为不会显示)
    double? remainingPaintExtent;
    //纵轴的长度;如果列表滚动方向是垂直方向,则表示列表宽度。
    double? crossAxisExtent;
    //纵轴方向
    AxisDirection? crossAxisDirection;
    //Viewport在主轴方向的长度;如果列表滚动方向是垂直方向,则表示列表高度。
    double? viewportMainAxisExtent;
    //Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]
    double? cacheOrigin;
    //Viewport加载区域的长度,范围:
    //[viewportMainAxisExtent,viewportMainAxisExtent + Viewport.cacheExtent*2]
    double? remainingCacheExtent;
}
const SliverGeometry({
  //Sliver在主轴方向预估长度,大多数情况是固定值,用于计算sliverConstraints.scrollOffset
  this.scrollExtent = 0.0, 
  this.paintExtent = 0.0, // 可视区域中的绘制长度
  this.paintOrigin = 0.0, // 绘制的坐标原点,相对于自身布局位置
  //在 Viewport中占用的长度;如果列表滚动方向是垂直方向,则表示列表高度。
  //范围[0,paintExtent]
  double? layoutExtent, 
  this.maxPaintExtent = 0.0,//最大绘制长度
  this.maxScrollObstructionExtent = 0.0,
  double? hitTestExtent, // 点击测试的范围
  bool? visible,// 是否显示
  //是否会溢出Viewport,如果为true,Viewport便会裁剪
  this.hasVisualOverflow = false,
  //scrollExtent的修正值:layoutExtent变化后,为了防止sliver突然跳动(应用新的layoutExtent)
  //可以先进行修正,具体的作用在后面 SliverFlexibleHeader 示例中会介绍。
  this.scrollOffsetCorrection,
  double? cacheExtent, // 在预渲染区域中占据的长度
}) 

Sliver布局模型和盒布局模型的区别?

  • 父组件传递给子组件的约束信息不同。盒模型传递的是 BoxConstraints,而 Sliver 传递的是 SliverConstraints。
  • 描述子组件布局信息的对象不同。盒模型的布局信息通过 Size 和 offset描述 ,而 Sliver的是通过 SliverGeometry 描述。
  • 布局的起点不同。Sliver布局的起点一般是Viewport ,而盒模型布局的起点可以是任意的组件。

实例:
可参考flukit组件库中的SliverFlexibleHeader、ExtraInfoBoxConstraints 以及 SliverPersistentHeaderToBox

12.嵌套可滚动组件NestedScrollView

const NestedScrollView({
  ... //省略可滚动组件的通用属性
  //header,sliver构造器
  required this.headerSliverBuilder,
  //可以接受任意的可滚动组件
  required this.body,
  this.floatHeaderSlivers = false,
}) 

NestedScrollView 分为 header 和 body 两部分,header 是外部可滚动组件(outer scroll view),只能接收 Sliver,headerSliverBuilder 构建一个 Sliver 列表给外部的可滚动组件;body 可以接收任意的可滚动组件,称为内部可滚动组件 (inner scroll view)。

NestedScrollView 原理

在这里插入图片描述
NestedScrollView 核心功能就是通过一个协调器来协调外部(outer)可滚动组件和内部(inner)可滚动组件的滚动,以使滑动效果连贯统一,协调器的实现原理就是分别给内外可滚动组件分别设置一个 controller,然后通过这两个controller 来协调控制它们的滚动。
注意:
内部的可滚动组件(body的)不能设置 controller 和 primary,这是因为 NestedScrollView 的协调器中已经指定了它的 controller,如果重新设定则协调器将会失效

SliverAppBar

SliverAppBar 是 AppBar 的Sliver 版,大多数参数都相同,但 SliverAppBar 会有一些特有的功能

const SliverAppBar({
  this.collapsedHeight, // 收缩起来的高度
  this.expandedHeight,// 展开时的高度
  this.pinned = false, // 是否固定
  this.floating = false, //是否漂浮
  this.snap = false, // 当漂浮时,此参数才有效
  bool forceElevated //导航栏下面是否一直显示阴影
  ...
})

嵌套 TabBarView

用户上滑时,导航栏滑出屏幕,用户下滑时,导航栏回到屏幕

class NestedTabBarView1 extends StatelessWidget {
  const NestedTabBarView1({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final _tabs = <String>['猜你喜欢', '今日特价', '发现更多'];
    // 构建 tabBar
    return DefaultTabController(
      length: _tabs.length, // tab的数量.
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return <Widget>[
              SliverOverlapAbsorber(
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                sliver: SliverAppBar(
                  title: const Text('商城'),
                  floating: true,
                  snap: true,
                  forceElevated: innerBoxIsScrolled,
                  bottom: TabBar(
                    tabs: _tabs.map((String name) => Tab(text: name)).toList(),
                  ),
                ),
              ),
            ];
          },
          body: TabBarView(
            children: _tabs.map((String name) {
              return Builder(
                builder: (BuildContext context) {
                  return CustomScrollView(
                    key: PageStorageKey<String>(name),
                    slivers: <Widget>[
                      SliverOverlapInjector(
                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                      ),
                      SliverPadding(
                        padding: const EdgeInsets.all(8.0),
                        sliver: buildSliverList(50),
                      ),
                    ],
                  );
                },
              );
            }).toList(),
          ),
        ),
      ),
    );
  }
}


更多:https://book.flutterchina.club/chapter6/

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/419295.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

SpringCloud-Alibaba学习笔记01——Nacos介绍以及注册中心的演变和Nacos核心功能

文章参考自图灵大佬课程&#xff1a;https://www.bilibili.com/video/BV1fe4y1b7ha?p1&vd_source5f425e0074a7f92921f53ab87712357b 1.什么是Nacos 官方&#xff1a;一个更易于构建云原生应用的动态服务发现(Nacos Discovery )、服务配置(Nacos Config)和服务管理平台。 …

threejs 模型 世界坐标系和设备坐标系

前言 开发中遇到需求需要点击屏幕位置处&#xff0c;生成一个类似圆形弹窗面板&#xff0c;这个交互需要进行的坐标转换为模型坐标&#xff08;局部坐标&#xff09;>场景坐标&#xff08;世界坐标&#xff09;>标准设备坐标>屏幕空间坐标&#xff0c;也就是一个将3D…

开启分片支持需要如何去做?

开启分片支持 如果您计划使您的Javashop系统数据分片&#xff0c;请参考本文档进行相应的配置。 一、做好分片策略 在开始之前&#xff0c;您应该根据自己的业务情况准备好分片策略&#xff0c;包括&#xff1a; 1、要用几个数据库来分片 2、相应的表要分几张表 在本例&#x…

大数据 | 实验一:大数据系统基本实验 | MapReduce 初级编程

文章目录&#x1f4da;实验目的&#x1f4da;实验平台&#x1f4da;实验内容&#x1f407;编程实现文件的合并和去重&#x1f407;编程实现对输入文件的排序&#x1f407;对指定的表格进行信息挖掘&#x1f4da;实验目的 1&#xff09;通过实验掌握基本的 MapReduce 编程方法。…

警惕“Money Message”勒索软件!数据安全不容忽视

近段时间&#xff0c;出现了一个名为“Money Message”的新型勒索软件&#xff0c;他们利用Money Message 病毒加密文件并以此向受害者勒索巨额赎金。 Money Message勒索软件是用 C编写&#xff0c;包含一个嵌入式JSON 配置文件&#xff0c;用于确定设备的加密方式。加密设备后…

小红书内容种草,曝光渠道分析总结

这是一个内容为王的时代&#xff0c;也是一个内容爆炸的时代。想要在以分享特色的小红书平台&#xff0c;实现内容种草&#xff0c;迅速出圈。今天来马文化传媒就从实操的角度&#xff0c;为大家带来小红书内容种草&#xff0c;曝光渠道分析总结的各种干货&#xff01; 一、什…

关于图形界面Pyqt与QT的区别选择

关于图像界面&#xff08;GUI&#xff09;想必大家都并不陌生&#xff0c;想要将一段已经完善的功能列表进行可视化操作并且具有一定的操作空间&#xff0c;将功能可视化必不可少&#xff0c;一个好的可视化工具不仅可以集成一系列小的文件功能&#xff0c;还能将不同方法之间的…

ubuntu基本环境配置及mysql8.0.32和mysql workbench安装

ubuntu基本环境配置 文章目录ubuntu基本环境配置各种依赖包下载地址一、使用root账号进行远程连接二、防火墙相关设置2.1启用2.2开放和关闭端口数据库mysql安装(8.0.32)工具mysqlworkbench(8.0.32)各种依赖包下载地址 http://cn.archive.ubuntu.com/ubuntu/pool/main/liba/lib…

【1019. 链表中的下一个更大节点】

来源&#xff1a;力扣&#xff08;LeetCode&#xff09; 描述&#xff1a; 给定一个长度为 n 的链表 head 对于列表中的每个节点&#xff0c;查找下一个 更大节点 的值。也就是说&#xff0c;对于每个节点&#xff0c;找到它旁边的第一个节点的值&#xff0c;这个节点的值 严…

三天吃透Redis八股文

Redis连环40问&#xff0c;绝对够全&#xff01; Redis是什么&#xff1f; Redis&#xff08;Remote Dictionary Server&#xff09;是一个使用 C 语言编写的&#xff0c;高性能非关系型的键值对数据库。与传统数据库不同的是&#xff0c;Redis 的数据是存在内存中的&#xf…

java调用python动态生成光电雷达图

一、编写java调用程序 //http://localhost:8945/api/ExecPy/ExecPyPollutionRadarMap ApiOperation(value "ExecPy") GetMapping(value "/ExecPyPollutionRadarMap") public String ExecPyPollutionRadarMap() {String scriptpath"F:\\demo\\Radar…

如何驱动模拟舵机-Controller 1.0b软件的使用

1.支持平台 win10、win7 win10打开Controller 1.0.exe即可运行&#xff1b;win7需要先安装Controller1.0b资料包\NetFarmwork文件夹中的.net框架组件。 2.电子硬件 我们用以下硬件为例来讲解Controller 1.0b软件的使用&#xff1a; 主控板 Basra主控板&#xff08;兼容Arduino…

selenium自动化测试面试题【含答案】

目录 1、selenium中如何判断元素是否存在&#xff1f; 2、selenium中hidden或者是display &#xff1d; none的元素是否可以定位到&#xff1f; 3、selenium中如何保证操作元素的成功率&#xff1f;也就是说如何保证我点击的元素一定是可以点击的&#xff1f; 4、如何提高s…

谷歌浏览器安装插件(从 Edge 浏览器里获取插件)

前言&#xff1a; 因为谷歌插件 商店&#xff0c;国内&#xff08;不科学上网&#xff09;是无法访问的&#xff0c;所以 要安装插件就得 通过各种途径 下载后 解压&#xff0c;然后安装。 谷歌浏览器下载、安装插件的方式 方式一&#xff1a;自行 百度下载压缩包&#xff0…

win10安装telnet服务器(开启端口,开启telnet客户端后依旧显示:无法打开到主机的连接,在端口xxxx连接失败)

前言 注&#xff1a;我使用telnet的根本原因是想测试端口是否通&#xff0c;因为要使用花生壳&#xff0c; 而之所以会显示 启telnet客户端后依旧显示&#xff1a;无法打开到主机的连接&#xff0c;在端口xxxx连接失败 错误&#xff0c;本质原因是&#xff1a; 1、你没有teln…

tensorflow深度神经网络实现鸢尾花分类

tensorflow深度神经网络实现鸢尾花分类 本文目录tensorflow深度神经网络实现鸢尾花分类获取数据集相关库的导入数据展示和划分对标签值进行热编码模型搭建使用Sequential模型搭建模型模型训练对训练好的模型进行评估使用model模型搭建模型对训练好的模型进行评估损失函数优化方…

使用golang连接kafka

1 下载&#xff0c;配置&#xff0c;启动 kafka 下载链接 配置修改 在config目录下的server文件和zookeeper文件&#xff0c;其中分别修改kafka的日志保存路径和zookeeper的数据保存路径。 启动kafka 先启动kafka自带的zookeeper&#xff0c;在kafka的根目录下打开终端&a…

百模大战,谁是下一个ChatGPT?

“不敢下手&#xff0c;现在中国还没跑出来一家绝对有优势的大模型&#xff0c;上层应用没法投&#xff0c;担心押错宝。”投资人Jucy&#xff08;化名&#xff09;向光锥智能表示&#xff0c;AI项目看得多、投的少是这段时间的VC常态。 ChatGPT点燃AI大爆炸2个月中&#xff0…

为什么工控行业生意越来越难做了?

前段时间跟几个做工业品销售的朋友聚了一下&#xff0c;大家都说去年一年挺难的&#xff0c;有些甚至想把小店关了。为什么现在工业品领域越来越难做了呢&#xff1f;今天也想给大家说一说我的一些看法。 以前的工控生意相对现在来说较为有限和封闭&#xff0c;技术上也没有现今…

Android 大图检测插件的落地

作者&#xff1a;layz4android 在实际的项目开发中&#xff0c;引入图片的方式基本可以分为两种&#xff1a;本地图片和云端图片&#xff0c;对于云端图片来说&#xff0c;可以动态地配置图片的大小&#xff0c;如果服务端的伙伴下发的图片很大导致程序异常&#xff0c;那么可以…