Flutter了解之入门篇6-1(PageView、TabBarView、CustomScrollView、NestedScrollView)

目录
  1. PageView 分页
  2. TabBarView(对PageView进行了封装)
  3. CustomScrollView
  4. NestedScrollView

1. PageView 分页

可用来实现页面切换、Tab换页、视频上下滑页、图片轮动。

  PageView({
    Key? key,
    this.scrollDirection = Axis.horizontal,  // 滚动方向
    this.reverse = false,
    PageController? controller,
    this.physics,
    this.pageSnapping = true,  // true:滑动超过一半则自动切换否则还原。false:不切换
    this.onPageChanged,  // 页面发生变化后回调
    List children = const [],
    this.dragStartBehavior = DragStartBehavior.start,
    this.allowImplicitScrolling = false,
    this.restorationId,
    this.clipBehavior = Clip.hardEdge,
  }) 
  PageView.builder({
    其他参数同上(没有children参数)
    required IndexedWidgetBuilder itemBuilder,
    int? itemCount,
  }) 

最终都会生成一个SliverChildDelegate来负责列表项的按需加载,而在SliverChildDelegate中每当列表项构建完成后都会为其添加一个AutomaticKeepAlive父组件。

示例

// 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 {
  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
}

创建一个 PageView
@override
Widget build(BuildContext context) {
  var children = [];
  // 生成 6 个 Tab 页
  for (int i = 0; i < 6; ++i) {
    children.add( Page( text: '$i'));
  }
  return PageView(
    // scrollDirection: Axis.vertical, // 滑动方向为垂直方向
    children: children,
  );
}

示例2

  PageView(
    onPageChanged: (currentPage)=>print('pageIndex:$currentPage'),
    controller: PageController(
      initialPage: 1, // 初始页面
      keepPage: false,  // 
      viewportFraction: 0.85, // 页面占用可视比例
    ),
    children: [
      Container(
        color: Colors.brown[900],
        alignment: Alignment(0,0),
        child: Text(
          'One',
          style: TextStyle(
            fontSize: 32,color: Colors.white,
          ),
        ),
      ),
      Container(
        color: Colors.grey[900],
        alignment: Alignment(0,0),
        child: Text(
          'Two',
          style: TextStyle(
            fontSize: 32,color: Colors.white,
          ),
        ),
      ),
      Container(
        color: Colors.blueGrey[900],
        alignment: Alignment(0,0),
        child: Text(
          'Three',
          style: TextStyle(
            fontSize: 32,color: Colors.white,
          ),
        ),
      )
    ],
  );

示例3(PageView.builder)

    PageView.builder(
      itemCount: posts.length,
      itemBuilder: _pageItemBuilder,
    )
  //
  Widget _pageItemBuilder(BuildContext context,int index){
    return Stack(
      children: [
        SizedBox(
          child: Image.network(
            posts[index].imgUrl,
            fit: BoxFit.cover,
          ),
        ),
        Positioned(
          bottom: 8.0,
          left: 8.0,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                posts[index].title,
                style: TextStyle(
                  fontWeight: FontWeight.bold,
                ),
              ),
              Text(
                posts[index].author,
                style: TextStyle(
                  fontWeight: FontWeight.bold,
                ),
              )
            ],
          ),
        )
      ],
    );
  }
/*
页面缓存

PageView 默认并没有缓存功能,一旦页面滑出屏幕它就会被销毁,这和我们前面讲过的 ListView/GridView 不一样,在创建 ListView/GridView 时我们可以手动指定 ViewPort 之外多大范围内的组件需要预渲染和缓存(通过 cacheExtent 指定),只有当组件滑出屏幕后又滑出预渲染区域,组件才会被销毁,但是不幸的是 PageView 并没有 cacheExtent 参数。

PageView 创建Viewport 的代码中是这样的:
child: Scrollable(
  ...
  viewportBuilder: (BuildContext context, ViewportOffset position) {
    return Viewport(
      // TODO(dnfield): we should provide a way to set cacheExtent
      // independent of implicit scrolling:
      // https://github.com/flutter/flutter/issues/45632
      cacheExtent: widget.allowImplicitScrolling ? 1.0 : 0.0,
      cacheExtentStyle: CacheExtentStyle.viewport,
      ...
    );
  },
)
发现 虽然 PageView 没有透传 cacheExtent,但是却在allowImplicitScrolling 为 true 时设置了预渲染区域,注意,此时的缓存类型为 CacheExtentStyle.viewport,则 cacheExtent 则表示缓存的长度是几个 Viewport 的宽度,cacheExtent 为 1.0,则代表前后各缓存一个页面宽度,即前后各一页。将 PageView 的 allowImplicitScrolling 置为 true 则不就可以缓存前后两页。
根据文档以及注释中 issue 的链接,发现PageView 中设置 cacheExtent 会和 iOS 中 辅助功能有冲突(读者可以先不用关注),所以暂时还没有什么好的办法。看到这可能国内的很多开发者要说我们的 App 不用考虑辅助功能,既然如此,那问题很好解决,将 PageView 的源码拷贝一份,然后透传 cacheExtent 即可。
*/
Flutter提供了一个AutomaticKeepAliveClientMixin ,只需要让 PageState 混入这个 mixin,且同时添加一些必要操作:
class _PageState extends State with AutomaticKeepAliveClientMixin{
  @override
  Widget build(BuildContext context) {
    super.build(context); // 必须调用。方法实现在 AutomaticKeepAliveClientMixin 中。根据当前 wantKeepAlive 的值给 AutomaticKeepAlive 发送消息,AutomaticKeepAlive 收到消息后就会开始工作。
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
  @override
  bool get wantKeepAlive => true; // 是否需要缓存
}
需要注意,如果采用 PageView.custom 构建页面时没有给列表项包装 AutomaticKeepAlive 父组件,则上述方案不能正常工作。
通过混入的方式实现缓存不是很优雅,因为必须更改 Page 的代码,有侵入性。

网上开发者自定义了一个KeepAliveWrapper组件,只需要使用 KeepAliveWrapper包裹需要缓存的组件就可以了。
// KeepAliveWrapper(child:Page( text: '$i')
class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper({
    Key? key,
    this.keepAlive = true,
    required this.child,
  }) : super(key: key);
  final bool keepAlive;
  final Widget child;
  @override
  _KeepAliveWrapperState createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child;
  }
  @override
  void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
    if(oldWidget.keepAlive != widget.keepAlive) {
      // keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中
      updateKeepAlive();
    }
    super.didUpdateWidget(oldWidget);
  }
  @override
  bool get wantKeepAlive => widget.keepAlive;
}
示例

lass 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 {
  @override
  Widget build(BuildContext context) {
    return ListTile(title: Text('${widget.index}'));
  }
  @override
  void dispose() {
    print('dispose ${widget.index}');
    super.dispose();
  }
}

2. TabBarView(对PageView进行了封装)

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

// TabBar 为 TabBarView 的导航标题,通常位于底部。
TabBar({
  Key? key,
  required this.tabs, // Tabs
  this.controller, // 如果需要和 TabBarView 联动, TabBar 和 TabBarView 使用同一个 TabController。注意,联动时 TabBar 和 TabBarView 的孩子数量需要一致。 
  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,
  ...
}) 

// tab可以是任何 Widget,但一般会使用Tab
Tab({
  Key? key,
  this.text, // 文本。text 和 child 是互斥的,不能同时指定。
  this.icon, // 图标
  this.iconMargin = const EdgeInsets.only(bottom: 10.0),
  this.height,
  this.child, // 自定义 widget
})

示例

// 为了实现 TabBar 和 TabBarView 的联动,显式创建了一个 TabController。
// 由于 TabController 需要一个 TickerProvider (vsync 参数), 又混入了 SingleTickerProviderStateMixin。
// 由于 TabController 中会执行动画,持有一些资源,所以在页面销毁时必须得释放资源。
class TabViewRoute1 extends StatefulWidget {
  @override
  _TabViewRoute1State createState() => _TabViewRoute1State();
}
class _TabViewRoute1State extends State
    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();
    super.dispose();
  }
}
滑动页面时顶部的 Tab 也会跟着动,点击顶部 Tab 时页面也会跟着切换。

实战中,如果需要 TabBar 和 TabBarView 联动,通常会创建一个 DefaultTabController 作为它们共同的父级组件,这样它们在执行时就会从组件树向上查找,都会使用指定的这个 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 了。

3. CustomScrollView

多个可滚动组件时滑动效果是分离的(各自拥有独立的Scrollable、Viewport、Sliver)。
需要一个"胶水"(CustomScrollView:创建一个共用的 Scrollable和Viewport对象,然后将各可滚动组件对应的Sliver添加到共用的Viewport对象中)把可滚动组件"粘"起来使滑动效果统一。

直接将ListView、GridView(本身是可滚动组件而不是Sliver,各自包含滚动模型)作为Sliver是不行的,所以Flutter提供了可滚动组件的Sliver版(如SliverList、SliverGrid等)。CustomScrollView的子组件必须都是Sliver。

列表Sliver
  SliverList             列表(对应ListView)
  SliverFixedExtentList  高度固定的列表(对应ListView,指定itemExtent时)
  SliverAnimatedList     添加/删除列表项可以执行动画(对应AnimatedList)
  SliverGrid             二维网格(对应GridView)
  SliverPrototypeExtentList  根据原型生成高度固定的列表(对应ListView,指定prototypeItem 时)
  SliverFillViewport         包含多个子组件,每个都可以填满屏幕(对应PageView)

布局、装饰Sliver
  SliverPadding                      (对应Padding)
  SliverVisibility、SliverOpacity    (对应Visibility、Opacity)
  SliverFadeTransition               (对应FadeTransition)
  SliverLayoutBuilder                (对应LayoutBuilder)

其他Sliver
  SliverToBoxAdapter        一个适配器,可以将RenderBox适配为Sliver。
  SliverAppBar              对应AppBar
  SliverPersistentHeader    滑动到顶部时可以固定住。
/*
  SliverSafeArea({
    Key? key,
    this.left = true,
    this.top = true,
    this.right = true,
    this.bottom = true,
    this.minimum = EdgeInsets.zero,
    required this.sliver,
  })
*/

示例(SliverList)

SliverList( 
  delegate: new SliverChildBuilderDelegate(
        (BuildContext context, int index) {
      return new Container(
        alignment: Alignment.center,
        color: Colors.cyan[100 * (index % 9)],
        child: new Text('grid item $index'),
      );
    },
    childCount: 20,
  ),
),

示例(SliverAppBar、SliverPadding、SliverGrid、SliverFixedExtentList)

import 'package:flutter/material.dart';
class CustomScrollViewTestRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Material(
      child: CustomScrollView(
        slivers: [
          SliverAppBar(  // AppBar,包含一个导航栏
            pinned: true,
            expandedHeight: 250.0,
            flexibleSpace: FlexibleSpaceBar(
              title: const Text('Demo'),
              background: Image.asset(
                "./images/avatar.png", fit: BoxFit.cover,),
            ),
          ),
          SliverPadding(
            padding: const EdgeInsets.all(8.0),
            sliver: new SliverGrid( // Grid
              gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2, // 按两列显示
                mainAxisSpacing: 10.0,
                crossAxisSpacing: 10.0,
                childAspectRatio: 4.0,
              ),
              delegate: new SliverChildBuilderDelegate(
                    (BuildContext context, int index) {
                  return new Container(
                    alignment: Alignment.center,
                    color: Colors.cyan[100 * (index % 9)],
                    child: new Text('grid item $index'),
                  );
                },
                childCount: 20,
              ),
            ),
          ),
          // List
          new SliverFixedExtentList( // 子元素高度50的列表
            itemExtent: 50.0,
            delegate: new SliverChildBuilderDelegate(
                    (BuildContext context, int index) {
                  // 创建列表项      
                  return new Container(
                    alignment: Alignment.center,
                    color: Colors.lightBlue[100 * (index % 9)],
                    child: new Text('list item $index'),
                  );
                },
                childCount: 50 // 50个列表项
            ),
          ),
        ],
      ),
    );
  }
}

示例 (SliverFixedExtentList)

列表项高度相同时优先使用:SliverFixedExtentList、SliverPrototypeExtentList。

Widget buildTwoSliverList() {
  var listView = SliverFixedExtentList(
    itemExtent: 56, // 列表项高度固定
    delegate: SliverChildBuilderDelegate(
      (_, index) => ListTile(title: Text('$index')),
      childCount: 10,
    ),
  );
  return CustomScrollView(
    slivers: [
      listView,
      listView,
    ],
  );
}
  1. SliverToBoxAdapter组件(一个适配器:将RenderBox适配为Sliver)

不是所有组件都有Sliver版本,为此Flutter提供了SliverToBoxAdapter。

注意:
  如果子组件可滑动且滑动方向和CustomScrollView一致,则不会正常工作。

原因:
  CustomScrollView 组合 Sliver 的原理是为所有子 Sliver 提供一个共享的 Scrollable,然后统一处理指定滑动方向的滑动事件,如果 Sliver 中引入了其他的 Scrollable,则滑动事件便会冲突。
  Flutter中手势冲突时,默认的策略是子元素生效(即子元素处理后停止冒泡)。

解决:
  使用NestedScrollView。

示例(SliverToBoxAdapter)

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(
      child: SizedBox(
        height: 300,
        child: PageView(
          children: [Text("1"), Text("2")],
        ),
      ),
    ),
    buildSliverFixedList(),
  ],
);
  1. SliverAppBar(顶部时只显示标题,下滑后展开内容)
1. floating
2. snap
  手指放开时会根据当前状态决定是否展开或收起。
  false:则导航栏会停留在上次滑动位置。
3. pinned
  滚动到顶部后,是否固定。默认false(滚动出顶部后导航栏将消失)。
4. expandedHeight
  导航栏展开后的高度。
5. flexibleSpace
  扩展弹性空间:导航栏滑动时的收起/展开组件(可以有背景图片和导航栏文字),当滑动到顶部后只显示文字导航栏,当下滑后会逐步显示背景内容。
例
SliverAppBar _getAppBar(String title) {
  return SliverAppBar(
    pinned: true,
    expandedHeight: 200,
    brightness: Brightness.dark,
    flexibleSpace: FlexibleSpaceBar(
      title: Text(title),
      background: Image.network(
        'https://ss1.bdstatic.com/70cFvXSh_Q1YnxGkpoWK1HF6hhy/it/u=688497718,308119011&fm=26&gp=0.jpg',
        fit: BoxFit.cover,
      ),
    ),
  );
}
  1. SliverPersistentHeader(当滑动到顶部时将组件固定在顶部)

初衷是为了实现SliverAppBar,所以它的一些属性和回调在SliverAppBar中才会用到。

SliverPersistentHeader({
  Key? key,
  // 构造header组件的委托
  required SliverPersistentHeaderDelegate delegate, 
  // header滑动到可视区域顶部时是否固定在顶部
  this.pinned = false, 
  // pinned为false 时 ,则 header 可以滑出可视区域(不会固定到顶部),当用户再次向下滑动时,不管 header已经被滑出了多远,都会立即出现在可视区域顶部并固定住,直到继续下滑到 header在列表中原来的位置时,header才会重新回到原来的位置(不再固定在顶部)。 
  this.floating = false, 
})

abstract class SliverPersistentHeaderDelegate {
  // header 最大高度、最小高度
  // pined为true时,当header刚刚固定到顶部时,此时会对它应用 maxExtent (最大高度);当用户继续往上滑动时,header 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtent。
  // 如果想让header高度固定,则将 maxExtent 和 minExtent 指定为同样的值即可。
  double get maxExtent;
  double get minExtent;

  // 构建 header。
  // shrinkOffset:取值范围[0,maxExtent],当header刚刚到达顶部时,shrinkOffset 值为maxExtent,如果用户继续向上滑动列表,shrinkOffset的值会随着用户滑动的偏移减小,直到减到0时。
  // overlapsContent:只要有内容和 Sliver 重叠时就应该为 true。一般不建议使用,使用时一定要小心。
  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;
}

示例(一个通用的委托构造器)

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);
    assert(() { // 测试代码:如果在调试模式,且子组件设置了key,则打印日志
      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"),
    );
  }
}

注意:
  1. 当有多个SliverPersistentHeader时,第一个 SliverPersistentHeader 的 overlapsContent值会一直为 false。
  1. 自定义Sliver
Sliver布局协议
    1. (当Sliver进入构建区域时)Viewport 将当前布局和配置信息通过 SliverConstraints 传递给 Sliver。
    2. Sliver 确定自身的布局、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。
    3. Viewport 读取 geometry 中的信息来对 Sliver 进行布局和绘制。

/*
Sliver布局模型和盒布局模型

相同点:
  布局流程基本相同:父组件告诉子组件约束信息 > 子组件根据父组件的约束确定自生大小 > 父组件获得子组件大小调整其位置。
不同点:
    1. 父组件传递给子组件的约束信息不同。盒模型传递的是 BoxConstraints,而 Sliver 传递的是 SliverConstraints。
    2. 描述子组件布局信息的对象不同。盒模型的布局信息通过 Size 和 offset描述 ,而 Sliver的是通过 SliverGeometry 描述。
    3. 布局的起点不同。Sliver布局的起点一般是Viewport ,而盒模型布局的起点可以是任意的组件。
*/

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

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)可以先进行修正。
  this.scrollOffsetCorrection,
  double? cacheExtent, // 在预渲染区域中占据的长度
}) 

示例(顶部图片可伸缩)

实现目标:顶部图片只显示一部分,当用户向下拽时图片的剩余部分会逐渐显示。
实现思路:实现一个 Sliver,将它作为 CustomScrollView 的第一孩子,然后根据用户的滑动来动态调整 Sliver 的布局和显示。

@override
Widget build(BuildContext context) {
  return CustomScrollView(
    // 为了能使CustomScrollView拉到顶部时还能继续往下拉,必须让 physics 支持弹性效果
    physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
    slivers: [
      // 自定义组件
      SliverFlexibleHeader(
        visibleExtent: 200,, // 初始状态在列表中占用的布局高度
        // 为了能根据下拉状态变化来定制显示的布局,通过一个 builder 来动态构建布局。
        builder: (context, availableHeight, direction) {
          return GestureDetector(
            onTap: () => print('tap'), // 测试是否可以响应事件
            child: Image(
              image: AssetImage("imgs/avatar.png"),
              width: 50.0,
              height: availableHeight,
              alignment: Alignment.bottomCenter,
              fit: BoxFit.cover,
            ),
          );
        },
      ),
      // 构建一个list
      buildSliverList(30),
    ],
  );
}
typedef SliverFlexibleHeaderBuilder = Widget Function(
  BuildContext context,
  double maxExtent,
  //ScrollDirection direction,
);
class SliverFlexibleHeader extends StatelessWidget {
  const SliverFlexibleHeader({
    Key? key,
    this.visibleExtent = 0,
    required this.builder,
  }) : super(key: key);
  final SliverFlexibleHeaderBuilder builder;
  final double visibleExtent;
  @override
  Widget build(BuildContext context) {
    // 当_SliverFlexibleHeader 中每次对子组件进行布局时,都会触发 LayoutBuilder 来重新构建子 widget ,LayoutBuilder 中收到的 constraints 就是 _SliverFlexibleHeader 中对子组件进行布局时 传入的 constraints。
    return _SliverFlexibleHeader(
      visibleExtent: visibleExtent,
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight
          );
        },
      ),
    );
  }
}
// 要自定义 RenderObject,所以需要继承 RenderObjectWidget
class _SliverFlexibleHeader extends SingleChildRenderObjectWidget {
  const _SliverFlexibleHeader({
    Key? key,
    required Widget child,
    this.visibleExtent = 0,
  }) : super(key: key, child: child);
  final double visibleExtent;
  @override
  RenderObject createRenderObject(BuildContext context) {
   return _FlexibleHeaderRenderSliver(visibleExtent);
  }
  @override
  void updateRenderObject(
      BuildContext context, _FlexibleHeaderRenderSliver renderObject) {
    renderObject..visibleExtent = visibleExtent;
  }
}
class _FlexibleHeaderRenderSliver extends RenderSliverSingleBoxAdapter {
    _FlexibleHeaderRenderSliver(double visibleExtent)
      : _visibleExtent = visibleExtent;
  double _lastOverScroll = 0;
  double _lastScrollOffset = 0;
  late double _visibleExtent = 0;
  set visibleExtent(double value) {
    // 可视长度发生变化,更新状态并重新布局
    if (_visibleExtent != value) {
      _lastOverScroll = 0;
      _visibleExtent = value;
      markNeedsLayout();
    }
  }
  @override
  // 通过 Viewport 传来的 SliverConstraints 结合子组件的高度,最终确定了 _SliverFlexibleHeader 的布局、绘制等相关信息,它们被保存在了 geometry 中,之后,Viewport 就可以读取 geometry 来确定 _SliverFlexibleHeader 在 Viewport 中的位置,然后进行绘制。
  void performLayout() {
    // 滑动距离大于_visibleExtent时则表示子节点已经在屏幕之外了
    if (child == null || (constraints.scrollOffset > _visibleExtent)) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      return;
    }
    // 测试overlap,下拉过程中overlap会一直变化.
    double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
    var scrollOffset = constraints.scrollOffset;
    // 在Viewport中顶部的可视空间为该 Sliver 可绘制的最大区域。
    // 1. 如果Sliver已经滑出可视区域则 constraints.scrollOffset 会大于 _visibleExtent,
    //    这种情况我们在一开始就判断过了。
    // 2. 如果我们下拉超出了边界,此时 overScroll>0,scrollOffset 值为0,所以最终的绘制区域为
    //    _visibleExtent + overScroll.
    double paintExtent = _visibleExtent + overScroll - constraints.scrollOffset;
    // 绘制高度不超过最大可绘制空间
    paintExtent = min(paintExtent, constraints.remainingPaintExtent);
    // 对子组件进行布局,子组件通过 LayoutBuilder可以拿到这里我们传递的约束对象(ExtraInfoBoxConstraints)
    child!.layout(
      constraints.asBoxConstraints(maxExtent: paintExtent),
      parentUsesSize: false,
    );
    // 最大为_visibleExtent,最小为 0
    double layoutExtent = min(_visibleExtent, paintExtent);
    // 设置geometry,Viewport 在布局时会用到
    geometry = SliverGeometry(
      scrollExtent: layoutExtent,
      paintOrigin: -overScroll,
      paintExtent: paintExtent,
      maxPaintExtent: paintExtent,
      layoutExtent: layoutExtent,
    );
  }
}
传递额外的布局信息

在实际使用 SliverFlexibleHeader 时,我们有时在构建子 widget 时可能会依赖当前列表的滑动方向,当然我们可以在 SliverFlexibleHeader 的 builder 中记录前后的 availableHeight 的差来确定滑动方向,但是这样比较麻烦,需要使用者来手动处理。我们知道在滑动时,Sliver 的 SliverConstraints 中已经包含了 userScrollDirection,如果我们能将它经过统一的处理然后透传给 LayoutBuilder 的话就非常好好了,这样就不需要开发者在使用时自己维护滑动方向了!按照这个思路我们来实现一下。

首先我们遇到了第一个问题: LayoutBuilder 接收的参数我们没法指定。两种方案:
    1. 我们知道在上面的场景中,在对子组件进行布局时我们传给子组件的约束只使用了最大长度,最小长度是没有用到的,那么我们可以将滑动方向通过最小长度传递给 LayoutBuilder,然后再 LayoutBuilder 中取出即可。
    2(建议). 定义一个新类,让它继承自 BoxConstraints,然后再添加一个可以保存 scrollDirection 的属性。
方案 1 有一个副作用就是会影响子组件布局。我们知道 LayoutBuilder 是在子组件 build 阶段执行的,当我们设置了最小长度后,我们虽然在 build 阶段没有用到它,但是在子组件在布局阶段仍然会应用此约束,所以最终还会影响子组件的布局。

按照方案 2 来实现:定义一个 ExtraInfoBoxConstraints 类,它可以携带约束之外的信息,为了尽可能通用,我们使用泛型:
class ExtraInfoBoxConstraints extends BoxConstraints {
  ExtraInfoBoxConstraints(
    this.extra,
    BoxConstraints constraints,
  ) : super(
          minWidth: constraints.minWidth,
          minHeight: constraints.minHeight,
          maxWidth: constraints.maxWidth,
          maxHeight: constraints.maxHeight,
        );

  // 额外的信息
  final T extra;
  
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is ExtraInfoBoxConstraints &&
        super == other &&
        other.extra == extra;
  }

  @override
  int get hashCode {
    return hashValues(super.hashCode, extra);
  }
}

重载了“==”运算符,这是因为 Flutter 在布局期间在特定的情况下会检测前后两次 constraints 是否相等然后来决定是否需要重新布局,所以我们需要重载“==”运算符,否则可能会在最大/最小宽高不变但 extra 发生变化时不会触发 child 重新布局,这时也就不会触发 LayoutBuilder,这明显不符合预期,因为我们希望 extra 发生变化时,会触发 LayoutBuilder 重新构建 child。

首先我们修改 __FlexibleHeaderRenderSliver 的 performLayout 方法:
...
  //对子组件进行布局,子组件通过 LayoutBuilder可以拿到这里我们传递的约束对象(ExtraInfoBoxConstraints)
  child!.layout(
  ExtraInfoBoxConstraints(
    direction, //传递滑动方向
    constraints.asBoxConstraints(maxExtent: paintExtent),
  ),
  parentUsesSize: false,
);
...

然后修改 SliverFlexibleHeader 实现,在 LayoutBuilder 中就可以获取到滑动方向:
typedef SliverFlexibleHeaderBuilder = Widget Function(
  BuildContext context,
  double maxExtent,
  ScrollDirection direction,
);
class SliverFlexibleHeader extends StatelessWidget {
  const SliverFlexibleHeader({
    Key? key,
    this.visibleExtent = 0,
    required this.builder,
  }) : super(key: key);
  final SliverFlexibleHeaderBuilder builder;
  final double visibleExtent;
  @override
  Widget build(BuildContext context) {
    return _SliverFlexibleHeader(
      visibleExtent: visibleExtent,
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight,
            // 获取滑动方向
            (constraints as ExtraInfoBoxConstraints).extra,
          );
        },
      ),
    );
  }
}

最后我们看一下 SliverFlexibleHeader 中确定滑动方向的逻辑:
// 下拉过程中overlap会一直变化.
double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
var scrollOffset = constraints.scrollOffset;
_direction = ScrollDirection.idle;

// 根据前后的overScroll值之差确定列表滑动方向。注意,不能直接使用 constraints.userScrollDirection,
// 这是因为该参数只表示用户滑动操作的方向。比如当我们下拉超出边界时,然后松手,此时列表会弹回,即列表滚动
// 方向是向上,而此时用户操作已经结束,ScrollDirection 的方向是上一次的用户滑动方向(向下),这是便有问题。
var distance = overScroll > 0
  ? overScroll - _lastOverScroll
  : _lastScrollOffset - scrollOffset;
_lastOverScroll = overScroll;
_lastScrollOffset = scrollOffset;

if (constraints.userScrollDirection == ScrollDirection.idle) {
  _direction = ScrollDirection.idle;
  _lastOverScroll = 0;
} else if (distance > 0) {
  _direction = ScrollDirection.forward;
} else if (distance < 0) {
  _direction = ScrollDirection.reverse;
}
高度修正 scrollOffsetCorrection

如果 visibleExtent 变化时,可以看到有一个突兀地跳动,这是因为 visibleExtent 变化时会导致 layoutExtent 发生变化,也就是说 SliverFlexibleHeader 在屏幕中所占的布局高度会发生变化,所以列表就出现跳动。但这个跳动效果太突兀了,我们知道每一个 Sliver 的高度是通过 scrollExtent 属性预估出来的,因此我们需要修正一下 scrollExtent,但是我们不能直接修改 scrollExtent 的值,直接修改不会有任何动画效果,仍然会跳动,为此,SliverGeometry 提供了一个 scrollOffsetCorrection 属性,它专门用于修正 scrollExtent ,我们只需要将要修正差值传给scrollOffsetCorrection,然后 Sliver 会自动执行一个动画效果过渡到我们期望的高度。

  // 是否需要修正scrollOffset. _visibleExtent 值更新后,
  // 为了防止突然的跳动,要先修正 scrollOffset。
  double? _scrollOffsetCorrection;
  set visibleExtent(double value) {
    // 可视长度发生变化,更新状态并重新布局
    if (_visibleExtent != value) {
      _lastOverScroll = 0;
      _reported = false;
      // 计算修正值
      _scrollOffsetCorrection = value - _visibleExtent;
      _visibleExtent = value;
      markNeedsLayout();
    }
  }
  @override
  void performLayout() {
    // _visibleExtent 值更新后,为了防止突然的跳动,先修正 scrollOffset
    if (_scrollOffsetCorrection != null) {
      geometry = SliverGeometry(
        //修正
        scrollOffsetCorrection: _scrollOffsetCorrection,
      );
      _scrollOffsetCorrection = null;
      return;
    }
    ...
  } 
边界

在 SliverFlexibleHeader 构建子组件时开发者可能会依赖“当前的可用高度是否为0”来做一些特殊处理,比如记录是否子组件已经离开了屏幕。但是根据上面的实现,当用户滑动非常快时,子组件离开屏幕时的最后一次布局时传递的约束的 maxExtent 可能不为 0,而当 constraints.scrollOffset 大于 _visibleExtent 时我们在 performLayout 的一开始就返回了,因此 LayoutBuilder 的 builder 中就有可能收不到 maxExtent 为 0 时的回调。为了解决这个问题,我们只需要在每次 Sliver 离开屏幕时调用一次 child.layout 同时 将maxExtent 指定为 0 即可,为此我们修改一下:

void performLayout() {
    if (child == null) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      return;
    }
    //当已经完全滑出屏幕时
    if (constraints.scrollOffset > _visibleExtent) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      // 通知 child 重新布局,注意,通知一次即可,如果不通知,滑出屏幕后,child 在最后
      // 一次构建时拿到的可用高度可能不为 0。因为使用者在构建子节点的时候,可能会依赖
      // "当前的可用高度是否为0" 来做一些特殊处理,比如记录是否子节点已经离开了屏幕,
      // 因此,我们需要在离开屏幕时确保LayoutBuilder的builder会被调用一次(构建子组件)。
      if (!_reported) {
        _reported = true;
        child!.layout(
          ExtraInfoBoxConstraints(
            _direction, //传递滑动方向
            constraints.asBoxConstraints(maxExtent: 0),
          ),
          //我们不会使用自节点的 Size, 关于此参数更详细的内容见本书后面关于layout原理的介绍
          parentUsesSize: false,
        );
      }
      return;
    }
    //子组件回到了屏幕中,重置通知状态
    _reported = false;
  ...
}

示例2

SliverPersistentHeaderToBox,可以将任意 RenderBox 适配为可以固定到顶部的 Sliver 而不用显式指定高度,同时避免overlapsContent问题。
在没有使用 SliverAppBar 时,用 SliverPersistentHeaderToBox,如果使用了 SliverAppBar ,用SliverPersistentHeader。

第一步: 实现SliverPersistentHeaderToBox

typedef SliverPersistentHeaderToBoxBuilder = Widget Function(
  BuildContext context,
  double maxExtent, //当前可用最大高度
  bool fixed, // 是否已经固定
);
class SliverPersistentHeaderToBox extends StatelessWidget {
  // 默认构造函数,直接接受一个 widget,不用显式指定高度
  SliverPersistentHeaderToBox({
    Key? key,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        super(key: key);
 // builder 构造函数,需要传一个 builder,同样不需要显式指定高度
  SliverPersistentHeaderToBox.builder({
    Key? key,
    required this.builder,
  }) : super(key: key);
  final SliverPersistentHeaderToBoxBuilder builder;
  @override
  Widget build(BuildContext context) {
    return _SliverPersistentHeaderToBox(
      // 通过 LayoutBuilder接收 Sliver 传递给子组件的布局约束信息
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight,
            //约束中需要传递的额外信息是一个bool类型,表示 Sliver 是否已经固定到顶部
            (constraints as ExtraInfoBoxConstraints).extra,
          );
        },
      ),
    );
  }
}

第二步:实现 _SliverPersistentHeaderToBox
class _RenderSliverPersistentHeaderToBox extends RenderSliverSingleBoxAdapter {
  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    child!.layout(
      ExtraInfoBoxConstraints(
        //只要 constraints.scrollOffset不为0,则表示已经有内容在当前Sliver下面了,即已经固定到顶部了
        constraints.scrollOffset != 0,
        constraints.asBoxConstraints(
          // 我们将剩余的可绘制空间作为 header 的最大高度约束传递给 LayoutBuilder
          maxExtent: constraints.remainingPaintExtent,
        ),
      ),
      //我们要根据child大小来确定Sliver大小,所以后面需要用到child的大小(size)信息
      parentUsesSize: true,
    );
    // 子节点 layout 后就能获取它的大小了
    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }
    geometry = SliverGeometry(
      scrollExtent: childExtent,
      paintOrigin: 0, // 固定,如果不想固定应该传` - constraints.scrollOffset`
      paintExtent: childExtent,
      maxPaintExtent: childExtent,
    );
  }
  // 重要,必须重写。
  @override
  double childMainAxisPosition(RenderBox child) => 0.0;
}

上面代码有四点需要注意:
    1. constraints.scrollOffset 不为 0 时,则表示已经固定到顶部了。
    2. 在布局阶段拿到子组件的 size 信息,然后通过通过子组件的大小来确定 Sliver 大小(设置geometry)。 这样就不再需要我们显式传高度值了。
    3. 通过给 paintOrigin 设为 0 来实现顶部固定效果;不固定到顶部时应该传 - constraints.scrollOffset。
    4. 必须要重写 childMainAxisPosition ,否则事件便会失效,该方法的返回值(paintOrigin 的位置)在“点击测试”中会用到。

最后一步:测试
class SliverPersistentHeaderToBoxRoute extends StatelessWidget {
  const SliverPersistentHeaderToBoxRoute({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        buildSliverList(5),
        SliverPersistentHeaderToBox.builder(builder: headerBuilder),
        buildSliverList(5),
        SliverPersistentHeaderToBox(child: wTitle('Title 2')),
        buildSliverList(50),
      ],
    );
  }
  // 当 header 固定后显示阴影
  Widget headerBuilder(context, maxExtent, fixed) {
    // 获取当前应用主题
    var theme = Theme.of(context);
    return Material(
      child: Container(
        color: fixed ? Colors.white : theme.canvasColor,
        child: wTitle('Title 1'),
      ),
      elevation: fixed ? 4 : 0,
      shadowColor: theme.appBarTheme.shadowColor,
    );
  }
  // 我们约定小写字母 w 开头的函数代表是需要构建一个 Widget,这比 buildXX 会更简洁
  Widget wTitle(String text) =>
      ListTile(title: Text(text), onTap: () => print(text));
}
创建两个 header:
    第一个 header:当没有滑动到顶部时,外观和正常列表项一样;当固定到顶部后,显示一个阴影。为了实现这个效果我们需要通过 SliverPersistentHeaderToBox.builder 来动态创建。
    第二个 header: 一个普通的列表项,它接受一个 widget。

4. NestedScrollView

如果CustomScrollView的子组件是一个可滚动组件(通过SliverToBoxAdapter嵌入)且它们的滑动方向一致时则会造成手势冲突,不能正常工作。可使用NestedScrollView解决。

NestedScrollView({
  ...
  required this.headerSliverBuilder,  // 外部可滚动组件:CustomScrollView
  required this.body,  // 内部可滚动组件:任意可滚动组件 
  this.floatHeaderSlivers = false,  //
}) 

示例

Material(
  child: NestedScrollView(
    headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
      // 返回一个 Sliver 数组给外部可滚动组件。
      return [
        SliverAppBar(
          title: const Text('嵌套ListView'),
          pinned: true, // 固定在顶部
          forceElevated: innerBoxIsScrolled,
        ),
        buildSliverList(5), // 构建一个 sliverList
      ];
    },
    body: ListView.builder(
      padding: const EdgeInsets.all(8),
      physics: const ClampingScrollPhysics(), // 重要
      itemCount: 30,
      itemBuilder: (BuildContext context, int index) {
        return SizedBox(
          height: 50,
          child: Center(child: Text('Item $index')),
        );
      },
    ),
  ),
);

你可能感兴趣的:(Flutter了解之入门篇6-1(PageView、TabBarView、CustomScrollView、NestedScrollView))