Flutter了解之入門篇6-1(PageView乍楚、TabBarView、CustomScrollView丛楚、NestedScrollView)

目錄
  1. PageView 分頁
  2. TabBarView(對(duì)PageView進(jìn)行了封裝)
  3. CustomScrollView
  4. NestedScrollView

1. PageView 分頁

可用來實(shí)現(xiàn)頁面切換族壳、Tab換頁、視頻上下滑頁趣些、圖片輪動(dòng)仿荆。

  PageView({
    Key? key,
    this.scrollDirection = Axis.horizontal,  // 滾動(dòng)方向
    this.reverse = false,
    PageController? controller,
    this.physics,
    this.pageSnapping = true,  // true:滑動(dòng)超過一半則自動(dòng)切換否則還原。false:不切換
    this.onPageChanged,  // 頁面發(fā)生變化后回調(diào)
    List<Widget> children = const <Widget>[],
    this.dragStartBehavior = DragStartBehavior.start,
    this.allowImplicitScrolling = false,
    this.restorationId,
    this.clipBehavior = Clip.hardEdge,
  }) 
  PageView.builder({
    其他參數(shù)同上(沒有children參數(shù))
    required IndexedWidgetBuilder itemBuilder,
    int? itemCount,
  }) 

最終都會(huì)生成一個(gè)SliverChildDelegate來負(fù)責(zé)列表項(xiàng)的按需加載,而在SliverChildDelegate中每當(dāng)列表項(xiàng)構(gòu)建完成后都會(huì)為其添加一個(gè)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<Page> {
  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
}

創(chuàng)建一個(gè) PageView
@override
Widget build(BuildContext context) {
  var children = <Widget>[];
  // 生成 6 個(gè) Tab 頁
  for (int i = 0; i < 6; ++i) {
    children.add( Page( text: '$i'));
  }
  return PageView(
    // scrollDirection: Axis.vertical, // 滑動(dòng)方向?yàn)榇怪狈较?    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 默認(rèn)并沒有緩存功能锦亦,一旦頁面滑出屏幕它就會(huì)被銷毀,這和我們前面講過的 ListView/GridView 不一樣令境,在創(chuàng)建 ListView/GridView 時(shí)我們可以手動(dòng)指定 ViewPort 之外多大范圍內(nèi)的組件需要預(yù)渲染和緩存(通過 cacheExtent 指定)杠园,只有當(dāng)組件滑出屏幕后又滑出預(yù)渲染區(qū)域,組件才會(huì)被銷毀舔庶,但是不幸的是 PageView 并沒有 cacheExtent 參數(shù)抛蚁。

PageView 創(chuàng)建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,
      ...
    );
  },
)
發(fā)現(xiàn) 雖然 PageView 沒有透?jìng)?cacheExtent,但是卻在allowImplicitScrolling 為 true 時(shí)設(shè)置了預(yù)渲染區(qū)域惕橙,注意瞧甩,此時(shí)的緩存類型為 CacheExtentStyle.viewport,則 cacheExtent 則表示緩存的長度是幾個(gè) Viewport 的寬度弥鹦,cacheExtent 為 1.0肚逸,則代表前后各緩存一個(gè)頁面寬度,即前后各一頁惶凝。將 PageView 的 allowImplicitScrolling 置為 true 則不就可以緩存前后兩頁吼虎。
根據(jù)文檔以及注釋中 issue 的鏈接,發(fā)現(xiàn)PageView 中設(shè)置 cacheExtent 會(huì)和 iOS 中 輔助功能有沖突(讀者可以先不用關(guān)注)苍鲜,所以暫時(shí)還沒有什么好的辦法思灰。看到這可能國內(nèi)的很多開發(fā)者要說我們的 App 不用考慮輔助功能混滔,既然如此洒疚,那問題很好解決,將 PageView 的源碼拷貝一份坯屿,然后透?jìng)?cacheExtent 即可油湖。
*/
Flutter提供了一個(gè)AutomaticKeepAliveClientMixin ,只需要讓 PageState 混入這個(gè) mixin领跛,且同時(shí)添加一些必要操作:
class _PageState extends State<Page> with AutomaticKeepAliveClientMixin{
  @override
  Widget build(BuildContext context) {
    super.build(context); // 必須調(diào)用乏德。方法實(shí)現(xiàn)在 AutomaticKeepAliveClientMixin 中。根據(jù)當(dāng)前 wantKeepAlive 的值給 AutomaticKeepAlive 發(fā)送消息吠昭,AutomaticKeepAlive 收到消息后就會(huì)開始工作喊括。
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
  @override
  bool get wantKeepAlive => true; // 是否需要緩存
}
需要注意,如果采用 PageView.custom 構(gòu)建頁面時(shí)沒有給列表項(xiàng)包裝 AutomaticKeepAlive 父組件矢棚,則上述方案不能正常工作郑什。
通過混入的方式實(shí)現(xiàn)緩存不是很優(yōu)雅,因?yàn)楸仨毟?Page 的代碼蒲肋,有侵入性蘑拯。

網(wǎng)上開發(fā)者自定義了一個(gè)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<KeepAliveWrapper>
    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 狀態(tài)需要更新申窘,實(shí)現(xiàn)在 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 后會(huì)緩存所有的列表項(xiàng)弯蚜,列表項(xiàng)將不會(huì)銷毀。
        // 為 false 時(shí)偶洋,列表項(xiàng)滑出預(yù)加載區(qū)域后將會(huì)別銷毀熟吏。
        // 使用時(shí)一定要注意是否必要,因?yàn)閷?duì)所有列表項(xiàng)都緩存的會(huì)導(dǎo)致更多的內(nèi)存消耗
        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() {
    print('dispose ${widget.index}');
    super.dispose();
  }
}

2. TabBarView(對(duì)PageView進(jìn)行了封裝)

TabBarView({
  Key? key,
  required this.children, // tab 頁
  this.controller, // TabController玄窝。用于監(jiān)聽和控制 TabBarView 的頁面切換牵寺,通常和 TabBar 聯(lián)動(dòng)。如果沒有指定恩脂,則會(huì)在組件樹中向上查找并使用最近的一個(gè)DefaultTabController帽氓。
  this.physics,
  this.dragStartBehavior = DragStartBehavior.start,
}) 

// TabBar 為 TabBarView 的導(dǎo)航標(biāo)題,通常位于底部俩块。
TabBar({
  Key? key,
  required this.tabs, // Tabs
  this.controller, // 如果需要和 TabBarView 聯(lián)動(dòng)黎休, TabBar 和 TabBarView 使用同一個(gè) TabController。注意玉凯,聯(lián)動(dòng)時(shí) TabBar 和 TabBarView 的孩子數(shù)量需要一致势腮。 
  this.isScrollable = false, // 是否可以滑動(dòng)
  this.padding,
  this.indicatorColor,// 指示器顏色,默認(rèn)是高度為2的一條下劃線
  this.automaticIndicatorColorAdjustment = true,
  this.indicatorWeight = 2.0,// 指示器高度
  this.indicatorPadding = EdgeInsets.zero, //指示器padding
  this.indicator, // 指示器
  this.indicatorSize, // 指示器長度漫仆,有兩個(gè)可選值捎拯,一個(gè)tab的長度,一個(gè)是label長度
  this.labelColor, 
  this.labelStyle,
  this.labelPadding,
  this.unselectedLabelColor,
  this.unselectedLabelStyle,
  this.mouseCursor,
  this.onTap,
  ...
}) 

// tab可以是任何 Widget盲厌,但一般會(huì)使用Tab
Tab({
  Key? key,
  this.text, // 文本署照。text 和 child 是互斥的,不能同時(shí)指定吗浩。
  this.icon, // 圖標(biāo)
  this.iconMargin = const EdgeInsets.only(bottom: 10.0),
  this.height,
  this.child, // 自定義 widget
})

示例

// 為了實(shí)現(xiàn) TabBar 和 TabBarView 的聯(lián)動(dòng)建芙,顯式創(chuàng)建了一個(gè) TabController。
// 由于 TabController 需要一個(gè) TickerProvider (vsync 參數(shù))懂扼, 又混入了 SingleTickerProviderStateMixin禁荸。
// 由于 TabController 中會(huì)執(zhí)行動(dòng)畫,持有一些資源阀湿,所以在頁面銷毀時(shí)必須得釋放資源屡限。
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( // 構(gòu)建
        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();
  }
}
滑動(dòng)頁面時(shí)頂部的 Tab 也會(huì)跟著動(dòng),點(diǎn)擊頂部 Tab 時(shí)頁面也會(huì)跟著切換炕倘。

實(shí)戰(zhàn)中,如果需要 TabBar 和 TabBarView 聯(lián)動(dòng)翰撑,通常會(huì)創(chuàng)建一個(gè) DefaultTabController 作為它們共同的父級(jí)組件罩旋,這樣它們?cè)趫?zhí)行時(shí)就會(huì)從組件樹向上查找啊央,都會(huì)使用指定的這個(gè) DefaultTabController。

修改后的實(shí)現(xiàn)如下:
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( // 構(gòu)建
          children: tabs.map((e) {
            return KeepAliveWrapper(
              child: Container(
                alignment: Alignment.center,
                child: Text(e, textScaleFactor: 5),
              ),
            );
          }).toList(),
        ),
      ),
    );
  }
}
無需去手動(dòng)管理 Controller 的生命周期涨醋,也不需要提供 SingleTickerProviderStateMixin瓜饥,同時(shí)也沒有其他的狀態(tài)需要管理,也就不需要用 StatefulWidget 了浴骂。

3. CustomScrollView

多個(gè)可滾動(dòng)組件時(shí)滑動(dòng)效果是分離的(各自擁有獨(dú)立的Scrollable乓土、Viewport、Sliver)溯警。
需要一個(gè)"膠水"(CustomScrollView:創(chuàng)建一個(gè)共用的 Scrollable和Viewport對(duì)象趣苏,然后將各可滾動(dòng)組件對(duì)應(yīng)的Sliver添加到共用的Viewport對(duì)象中)把可滾動(dòng)組件"粘"起來使滑動(dòng)效果統(tǒng)一。

直接將ListView梯轻、GridView(本身是可滾動(dòng)組件而不是Sliver食磕,各自包含滾動(dòng)模型)作為Sliver是不行的,所以Flutter提供了可滾動(dòng)組件的Sliver版(如SliverList喳挑、SliverGrid等)彬伦。CustomScrollView的子組件必須都是Sliver。

列表Sliver
  SliverList             列表(對(duì)應(yīng)ListView)
  SliverFixedExtentList  高度固定的列表(對(duì)應(yīng)ListView伊诵,指定itemExtent時(shí))
  SliverAnimatedList     添加/刪除列表項(xiàng)可以執(zhí)行動(dòng)畫(對(duì)應(yīng)AnimatedList)
  SliverGrid             二維網(wǎng)格(對(duì)應(yīng)GridView)
  SliverPrototypeExtentList  根據(jù)原型生成高度固定的列表(對(duì)應(yīng)ListView单绑,指定prototypeItem 時(shí))
  SliverFillViewport         包含多個(gè)子組件,每個(gè)都可以填滿屏幕(對(duì)應(yīng)PageView)

布局曹宴、裝飾Sliver
  SliverPadding                      (對(duì)應(yīng)Padding)
  SliverVisibility搂橙、SliverOpacity    (對(duì)應(yīng)Visibility、Opacity)
  SliverFadeTransition               (對(duì)應(yīng)FadeTransition)
  SliverLayoutBuilder                (對(duì)應(yīng)LayoutBuilder)

其他Sliver
  SliverToBoxAdapter        一個(gè)適配器浙炼,可以將RenderBox適配為Sliver份氧。
  SliverAppBar              對(duì)應(yīng)AppBar
  SliverPersistentHeader    滑動(dòng)到頂部時(shí)可以固定住。
/*
  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: <Widget>[
          SliverAppBar(  // AppBar资厉,包含一個(gè)導(dǎo)航欄
            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) {
                  // 創(chuàng)建列表項(xiàng)      
                  return new Container(
                    alignment: Alignment.center,
                    color: Colors.lightBlue[100 * (index % 9)],
                    child: new Text('list item $index'),
                  );
                },
                childCount: 50 // 50個(gè)列表項(xiàng)
            ),
          ),
        ],
      ),
    );
  }
}

示例 (SliverFixedExtentList)

列表項(xiàng)高度相同時(shí)優(yōu)先使用:SliverFixedExtentList厅缺、SliverPrototypeExtentList。

Widget buildTwoSliverList() {
  var listView = SliverFixedExtentList(
    itemExtent: 56, // 列表項(xiàng)高度固定
    delegate: SliverChildBuilderDelegate(
      (_, index) => ListTile(title: Text('$index')),
      childCount: 10,
    ),
  );
  return CustomScrollView(
    slivers: [
      listView,
      listView,
    ],
  );
}
  1. SliverToBoxAdapter組件(一個(gè)適配器:將RenderBox適配為Sliver)

不是所有組件都有Sliver版本宴偿,為此Flutter提供了SliverToBoxAdapter湘捎。

注意:
  如果子組件可滑動(dòng)且滑動(dòng)方向和CustomScrollView一致,則不會(huì)正常工作窄刘。

原因:
  CustomScrollView 組合 Sliver 的原理是為所有子 Sliver 提供一個(gè)共享的 Scrollable窥妇,然后統(tǒng)一處理指定滑動(dòng)方向的滑動(dòng)事件,如果 Sliver 中引入了其他的 Scrollable娩践,則滑動(dòng)事件便會(huì)沖突活翩。
  Flutter中手勢(shì)沖突時(shí)烹骨,默認(rèn)的策略是子元素生效(即子元素處理后停止冒泡)。

解決:
  使用NestedScrollView材泄。

示例(SliverToBoxAdapter)

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(
      child: SizedBox(
        height: 300,
        child: PageView(
          children: [Text("1"), Text("2")],
        ),
      ),
    ),
    buildSliverFixedList(),
  ],
);
  1. SliverAppBar(頂部時(shí)只顯示標(biāo)題沮焕,下滑后展開內(nèi)容)
1. floating
2. snap
  手指放開時(shí)會(huì)根據(jù)當(dāng)前狀態(tài)決定是否展開或收起。
  false:則導(dǎo)航欄會(huì)停留在上次滑動(dòng)位置拉宗。
3. pinned
  滾動(dòng)到頂部后峦树,是否固定。默認(rèn)false(滾動(dòng)出頂部后導(dǎo)航欄將消失)旦事。
4. expandedHeight
  導(dǎo)航欄展開后的高度魁巩。
5. flexibleSpace
  擴(kuò)展彈性空間:導(dǎo)航欄滑動(dòng)時(shí)的收起/展開組件(可以有背景圖片和導(dǎo)航欄文字),當(dāng)滑動(dòng)到頂部后只顯示文字導(dǎo)航欄族檬,當(dāng)下滑后會(huì)逐步顯示背景內(nèi)容歪赢。
例
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(當(dāng)滑動(dòng)到頂部時(shí)將組件固定在頂部)

初衷是為了實(shí)現(xiàn)SliverAppBar,所以它的一些屬性和回調(diào)在SliverAppBar中才會(huì)用到单料。

SliverPersistentHeader({
  Key? key,
  // 構(gòu)造header組件的委托
  required SliverPersistentHeaderDelegate delegate, 
  // header滑動(dòng)到可視區(qū)域頂部時(shí)是否固定在頂部
  this.pinned = false, 
  // pinned為false 時(shí) 埋凯,則 header 可以滑出可視區(qū)域(不會(huì)固定到頂部),當(dāng)用戶再次向下滑動(dòng)時(shí)扫尖,不管 header已經(jīng)被滑出了多遠(yuǎn)白对,都會(huì)立即出現(xiàn)在可視區(qū)域頂部并固定住,直到繼續(xù)下滑到 header在列表中原來的位置時(shí)换怖,header才會(huì)重新回到原來的位置(不再固定在頂部)甩恼。 
  this.floating = false, 
})

abstract class SliverPersistentHeaderDelegate {
  // header 最大高度、最小高度
  // pined為true時(shí)沉颂,當(dāng)header剛剛固定到頂部時(shí)条摸,此時(shí)會(huì)對(duì)它應(yīng)用 maxExtent (最大高度);當(dāng)用戶繼續(xù)往上滑動(dòng)時(shí)铸屉,header 的高度會(huì)隨著用戶繼續(xù)上滑從 maxExtent 逐漸減小到 minExtent钉蒲。
  // 如果想讓header高度固定,則將 maxExtent 和 minExtent 指定為同樣的值即可彻坛。
  double get maxExtent;
  double get minExtent;

  // 構(gòu)建 header顷啼。
  // shrinkOffset:取值范圍[0,maxExtent],當(dāng)header剛剛到達(dá)頂部時(shí),shrinkOffset 值為maxExtent昌屉,如果用戶繼續(xù)向上滑動(dòng)列表钙蒙,shrinkOffset的值會(huì)隨著用戶滑動(dòng)的偏移減小,直到減到0時(shí)间驮。
  // overlapsContent:只要有內(nèi)容和 Sliver 重疊時(shí)就應(yīng)該為 true躬厌。一般不建議使用,使用時(shí)一定要小心竞帽。
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent);
  
  // header 是否需要重新構(gòu)建烤咧;通常當(dāng)父級(jí)的 StatefulWidget 更新狀態(tài)時(shí)會(huì)觸發(fā)偏陪。
  // 一般來說只有當(dāng) Delegate 的配置發(fā)生變化時(shí),應(yīng)該返回false煮嫌,比如新舊的 minExtent、maxExtent等其他配置不同時(shí)需要返回 true抱虐,其余情況返回 false 即可昌阿。
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);

  // 下面這幾個(gè)屬性是SliverPersistentHeader在SliverAppBar中時(shí)實(shí)現(xiàn)floating、snap效果時(shí)會(huì)用到恳邀,開發(fā)過程很少用到懦冰。
  TickerProvider? get vsync => null;
  FloatingHeaderSnapConfiguration? get snapConfiguration => null;
  OverScrollHeaderStretchConfiguration? get stretchConfiguration => null;
  PersistentHeaderShowOnScreenConfiguration? get showOnScreenConfiguration => null;
}

示例(一個(gè)通用的委托構(gòu)造器)

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時(shí)使用
  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(() { // 測(cè)試代碼:如果在調(diào)試模式,且子組件設(shè)置了key谣沸,則打印日志
      if (child.key != null) {
        print('${child.key}: shrink: $shrinkOffset刷钢,overlaps:$overlapsContent');
      }
      return true;
    }());
    // 讓 header 盡可能充滿限制的空間;寬度為 Viewport 寬度乳附,高度隨著用戶滑動(dòng)在[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),
      ],
    );
  }
  // 構(gòu)建固定高度的SliverList,count為列表項(xiàng)屬相
  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      itemExtent: 50,
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return ListTile(title: Text('$index'));
        },
        childCount: count,
      ),
    );
  }
  // 構(gòu)建 header
  Widget buildHeader(int i) {
    return Container(
      color: Colors.lightBlue.shade200,
      alignment: Alignment.centerLeft,
      child: Text("PersistentHeader $i"),
    );
  }
}

注意:
  1. 當(dāng)有多個(gè)SliverPersistentHeader時(shí)赋除,第一個(gè) SliverPersistentHeader 的 overlapsContent值會(huì)一直為 false阱缓。
  1. 自定義Sliver
Sliver布局協(xié)議
    1. (當(dāng)Sliver進(jìn)入構(gòu)建區(qū)域時(shí))Viewport 將當(dāng)前布局和配置信息通過 SliverConstraints 傳遞給 Sliver。
    2. Sliver 確定自身的布局举农、繪制等信息荆针,保存在 geometry 中(一個(gè) SliverGeometry 類型的對(duì)象)。
    3. Viewport 讀取 geometry 中的信息來對(duì) Sliver 進(jìn)行布局和繪制颁糟。

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

相同點(diǎn):
  布局流程基本相同:父組件告訴子組件約束信息 > 子組件根據(jù)父組件的約束確定自生大小 > 父組件獲得子組件大小調(diào)整其位置航背。
不同點(diǎn):
    1. 父組件傳遞給子組件的約束信息不同。盒模型傳遞的是 BoxConstraints棱貌,而 Sliver 傳遞的是 SliverConstraints玖媚。
    2. 描述子組件布局信息的對(duì)象不同。盒模型的布局信息通過 Size 和 offset描述 键畴,而 Sliver的是通過 SliverGeometry 描述最盅。
    3. 布局的起點(diǎn)不同。Sliver布局的起點(diǎn)一般是Viewport 起惕,而盒模型布局的起點(diǎn)可以是任意的組件涡贱。
*/

class SliverConstraints extends Constraints {
    // 主軸方向
    AxisDirection? axisDirection;
    // Sliver 沿著主軸從列表的哪個(gè)方向插入?枚舉類型惹想,正向或反向
    GrowthDirection? growthDirection;
    // 用戶滑動(dòng)方向
    ScrollDirection? userScrollDirection;
    // 當(dāng)前Sliver理論上(可能會(huì)固定在頂部)已經(jīng)滑出可視區(qū)域的總偏移
    double? scrollOffset;
    // 當(dāng)前Sliver之前的Sliver占據(jù)的總高度问词,因?yàn)榱斜硎菓屑虞d,如果不能預(yù)估時(shí)嘀粱,該值為double.infinity
    double? precedingScrollExtent;
    // 上一個(gè) sliver 覆蓋當(dāng)前 sliver 的大小激挪,通常在 sliver 是 pinned/floating 或者處于列表頭尾時(shí)有效辰狡。
    double? overlap;
    // 當(dāng)前Sliver在Viewport中的最大可以繪制的區(qū)域。
    // 繪制如果超過該區(qū)域會(huì)比較低效(因?yàn)椴粫?huì)顯示)
    double? remainingPaintExtent;
    // 縱軸的長度垄分;如果列表滾動(dòng)方向是垂直方向宛篇,則表示列表寬度。
    double? crossAxisExtent;
    // 縱軸方向
    AxisDirection? crossAxisDirection;
    // Viewport在主軸方向的長度薄湿;如果列表滾動(dòng)方向是垂直方向叫倍,則表示列表高度。
    double? viewportMainAxisExtent;
    // Viewport 預(yù)渲染區(qū)域的起點(diǎn)[-Viewport.cacheExtent, 0]
    double? cacheOrigin;
    // Viewport加載區(qū)域的長度豺瘤,范圍:
    //[viewportMainAxisExtent,viewportMainAxisExtent + Viewport.cacheExtent*2]
    double? remainingCacheExtent;
}

SliverGeometry({
  // Sliver在主軸方向預(yù)估長度吆倦,大多數(shù)情況是固定值,用于計(jì)算sliverConstraints.scrollOffset
  this.scrollExtent = 0.0, 
  this.paintExtent = 0.0, // 可視區(qū)域中的繪制長度
  this.paintOrigin = 0.0, // 繪制的坐標(biāo)原點(diǎn)坐求,相對(duì)于自身布局位置
  // 在 Viewport中占用的長度蚕泽;如果列表滾動(dòng)方向是垂直方向,則表示列表高度桥嗤。
  // 范圍[0,paintExtent]
  double? layoutExtent, 
  this.maxPaintExtent = 0.0,//最大繪制長度
  this.maxScrollObstructionExtent = 0.0,
  double? hitTestExtent, // 點(diǎn)擊測(cè)試的范圍
  bool? visible,// 是否顯示
  // 是否會(huì)溢出Viewport抖僵,如果為true潦牛,Viewport便會(huì)裁剪
  this.hasVisualOverflow = false,
  // scrollExtent的修正值:layoutExtent變化后赶诊,為了防止sliver突然跳動(dòng)(應(yīng)用新的layoutExtent)可以先進(jìn)行修正赤屋。
  this.scrollOffsetCorrection,
  double? cacheExtent, // 在預(yù)渲染區(qū)域中占據(jù)的長度
}) 

示例(頂部圖片可伸縮)

實(shí)現(xiàn)目標(biāo):頂部圖片只顯示一部分,當(dāng)用戶向下拽時(shí)圖片的剩余部分會(huì)逐漸顯示师逸。
實(shí)現(xiàn)思路:實(shí)現(xiàn)一個(gè) Sliver司倚,將它作為 CustomScrollView 的第一孩子,然后根據(jù)用戶的滑動(dòng)來動(dòng)態(tài)調(diào)整 Sliver 的布局和顯示篓像。

@override
Widget build(BuildContext context) {
  return CustomScrollView(
    // 為了能使CustomScrollView拉到頂部時(shí)還能繼續(xù)往下拉动知,必須讓 physics 支持彈性效果
    physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
    slivers: [
      // 自定義組件
      SliverFlexibleHeader(
        visibleExtent: 200,, // 初始狀態(tài)在列表中占用的布局高度
        // 為了能根據(jù)下拉狀態(tài)變化來定制顯示的布局,通過一個(gè) builder 來動(dòng)態(tài)構(gòu)建布局员辩。
        builder: (context, availableHeight, direction) {
          return GestureDetector(
            onTap: () => print('tap'), // 測(cè)試是否可以響應(yīng)事件
            child: Image(
              image: AssetImage("imgs/avatar.png"),
              width: 50.0,
              height: availableHeight,
              alignment: Alignment.bottomCenter,
              fit: BoxFit.cover,
            ),
          );
        },
      ),
      // 構(gòu)建一個(gè)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) {
    // 當(dāng)_SliverFlexibleHeader 中每次對(duì)子組件進(jìn)行布局時(shí)盒粮,都會(huì)觸發(fā) LayoutBuilder 來重新構(gòu)建子 widget ,LayoutBuilder 中收到的 constraints 就是 _SliverFlexibleHeader 中對(duì)子組件進(jìn)行布局時(shí) 傳入的 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) {
    // 可視長度發(fā)生變化,更新狀態(tài)并重新布局
    if (_visibleExtent != value) {
      _lastOverScroll = 0;
      _visibleExtent = value;
      markNeedsLayout();
    }
  }
  @override
  // 通過 Viewport 傳來的 SliverConstraints 結(jié)合子組件的高度宋税,最終確定了 _SliverFlexibleHeader 的布局摊崭、繪制等相關(guān)信息,它們被保存在了 geometry 中杰赛,之后呢簸,Viewport 就可以讀取 geometry 來確定 _SliverFlexibleHeader 在 Viewport 中的位置,然后進(jìn)行繪制。
  void performLayout() {
    // 滑動(dòng)距離大于_visibleExtent時(shí)則表示子節(jié)點(diǎn)已經(jīng)在屏幕之外了
    if (child == null || (constraints.scrollOffset > _visibleExtent)) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      return;
    }
    // 測(cè)試overlap,下拉過程中overlap會(huì)一直變化.
    double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
    var scrollOffset = constraints.scrollOffset;
    // 在Viewport中頂部的可視空間為該 Sliver 可繪制的最大區(qū)域根时。
    // 1. 如果Sliver已經(jīng)滑出可視區(qū)域則 constraints.scrollOffset 會(huì)大于 _visibleExtent瘦赫,
    //    這種情況我們?cè)谝婚_始就判斷過了。
    // 2. 如果我們下拉超出了邊界蛤迎,此時(shí) overScroll>0确虱,scrollOffset 值為0,所以最終的繪制區(qū)域?yàn)?    //    _visibleExtent + overScroll.
    double paintExtent = _visibleExtent + overScroll - constraints.scrollOffset;
    // 繪制高度不超過最大可繪制空間
    paintExtent = min(paintExtent, constraints.remainingPaintExtent);
    // 對(duì)子組件進(jìn)行布局忘苛,子組件通過 LayoutBuilder可以拿到這里我們傳遞的約束對(duì)象(ExtraInfoBoxConstraints)
    child!.layout(
      constraints.asBoxConstraints(maxExtent: paintExtent),
      parentUsesSize: false,
    );
    // 最大為_visibleExtent蝉娜,最小為 0
    double layoutExtent = min(_visibleExtent, paintExtent);
    // 設(shè)置geometry,Viewport 在布局時(shí)會(huì)用到
    geometry = SliverGeometry(
      scrollExtent: layoutExtent,
      paintOrigin: -overScroll,
      paintExtent: paintExtent,
      maxPaintExtent: paintExtent,
      layoutExtent: layoutExtent,
    );
  }
}
傳遞額外的布局信息

在實(shí)際使用 SliverFlexibleHeader 時(shí)扎唾,我們有時(shí)在構(gòu)建子 widget 時(shí)可能會(huì)依賴當(dāng)前列表的滑動(dòng)方向,當(dāng)然我們可以在 SliverFlexibleHeader 的 builder 中記錄前后的 availableHeight 的差來確定滑動(dòng)方向南缓,但是這樣比較麻煩胸遇,需要使用者來手動(dòng)處理。我們知道在滑動(dòng)時(shí)汉形,Sliver 的 SliverConstraints 中已經(jīng)包含了 userScrollDirection纸镊,如果我們能將它經(jīng)過統(tǒng)一的處理然后透?jìng)鹘o LayoutBuilder 的話就非常好好了,這樣就不需要開發(fā)者在使用時(shí)自己維護(hù)滑動(dòng)方向了概疆!按照這個(gè)思路我們來實(shí)現(xiàn)一下逗威。

首先我們遇到了第一個(gè)問題: LayoutBuilder 接收的參數(shù)我們沒法指定。兩種方案:
    1. 我們知道在上面的場(chǎng)景中岔冀,在對(duì)子組件進(jìn)行布局時(shí)我們傳給子組件的約束只使用了最大長度凯旭,最小長度是沒有用到的使套,那么我們可以將滑動(dòng)方向通過最小長度傳遞給 LayoutBuilder侦高,然后再 LayoutBuilder 中取出即可嫉柴。
    2(建議). 定義一個(gè)新類奉呛,讓它繼承自 BoxConstraints,然后再添加一個(gè)可以保存 scrollDirection 的屬性瞧壮。
方案 1 有一個(gè)副作用就是會(huì)影響子組件布局登馒。我們知道 LayoutBuilder 是在子組件 build 階段執(zhí)行的谊娇,當(dāng)我們?cè)O(shè)置了最小長度后,我們雖然在 build 階段沒有用到它赠堵,但是在子組件在布局階段仍然會(huì)應(yīng)用此約束法褥,所以最終還會(huì)影響子組件的布局半等。

按照方案 2 來實(shí)現(xiàn):定義一個(gè) ExtraInfoBoxConstraints 類杀饵,它可以攜帶約束之外的信息切距,為了盡可能通用谜悟,我們使用泛型:
class ExtraInfoBoxConstraints<T> 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);
  }
}

重載了“==”運(yùn)算符葡幸,這是因?yàn)?Flutter 在布局期間在特定的情況下會(huì)檢測(cè)前后兩次 constraints 是否相等然后來決定是否需要重新布局蔚叨,所以我們需要重載“==”運(yùn)算符,否則可能會(huì)在最大/最小寬高不變但 extra 發(fā)生變化時(shí)不會(huì)觸發(fā) child 重新布局悄泥,這時(shí)也就不會(huì)觸發(fā) LayoutBuilder弹囚,這明顯不符合預(yù)期鸥鹉,因?yàn)槲覀兿M?extra 發(fā)生變化時(shí)庶骄,會(huì)觸發(fā) LayoutBuilder 重新構(gòu)建 child。

首先我們修改 __FlexibleHeaderRenderSliver 的 performLayout 方法:
...
  //對(duì)子組件進(jìn)行布局灸异,子組件通過 LayoutBuilder可以拿到這里我們傳遞的約束對(duì)象(ExtraInfoBoxConstraints)
  child!.layout(
  ExtraInfoBoxConstraints(
    direction, //傳遞滑動(dòng)方向
    constraints.asBoxConstraints(maxExtent: paintExtent),
  ),
  parentUsesSize: false,
);
...

然后修改 SliverFlexibleHeader 實(shí)現(xiàn),在 LayoutBuilder 中就可以獲取到滑動(dòng)方向:
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,
            // 獲取滑動(dòng)方向
            (constraints as ExtraInfoBoxConstraints<ScrollDirection>).extra,
          );
        },
      ),
    );
  }
}

最后我們看一下 SliverFlexibleHeader 中確定滑動(dòng)方向的邏輯:
// 下拉過程中overlap會(huì)一直變化.
double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
var scrollOffset = constraints.scrollOffset;
_direction = ScrollDirection.idle;

// 根據(jù)前后的overScroll值之差確定列表滑動(dòng)方向。注意么伯,不能直接使用 constraints.userScrollDirection田柔,
// 這是因?yàn)樵搮?shù)只表示用戶滑動(dòng)操作的方向。比如當(dāng)我們下拉超出邊界時(shí)欣舵,然后松手邻遏,此時(shí)列表會(huì)彈回,即列表滾動(dòng)
// 方向是向上廷没,而此時(shí)用戶操作已經(jīng)結(jié)束颠黎,ScrollDirection 的方向是上一次的用戶滑動(dòng)方向(向下)滞项,這是便有問題文判。
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 變化時(shí),可以看到有一個(gè)突兀地跳動(dòng)戏仓,這是因?yàn)?visibleExtent 變化時(shí)會(huì)導(dǎo)致 layoutExtent 發(fā)生變化赏殃,也就是說 SliverFlexibleHeader 在屏幕中所占的布局高度會(huì)發(fā)生變化仁热,所以列表就出現(xiàn)跳動(dòng)。但這個(gè)跳動(dòng)效果太突兀了思劳,我們知道每一個(gè) Sliver 的高度是通過 scrollExtent 屬性預(yù)估出來的敢艰,因此我們需要修正一下 scrollExtent钠导,但是我們不能直接修改 scrollExtent 的值森瘪,直接修改不會(huì)有任何動(dòng)畫效果扼睬,仍然會(huì)跳動(dòng)窗宇,為此军俊,SliverGeometry 提供了一個(gè) scrollOffsetCorrection 屬性粪躬,它專門用于修正 scrollExtent 镰官,我們只需要將要修正差值傳給scrollOffsetCorrection泳唠,然后 Sliver 會(huì)自動(dòng)執(zhí)行一個(gè)動(dòng)畫效果過渡到我們期望的高度警检。

  // 是否需要修正scrollOffset. _visibleExtent 值更新后扇雕,
  // 為了防止突然的跳動(dòng),要先修正 scrollOffset础淤。
  double? _scrollOffsetCorrection;
  set visibleExtent(double value) {
    // 可視長度發(fā)生變化鸽凶,更新狀態(tài)并重新布局
    if (_visibleExtent != value) {
      _lastOverScroll = 0;
      _reported = false;
      // 計(jì)算修正值
      _scrollOffsetCorrection = value - _visibleExtent;
      _visibleExtent = value;
      markNeedsLayout();
    }
  }
  @override
  void performLayout() {
    // _visibleExtent 值更新后玻侥,為了防止突然的跳動(dòng)凑兰,先修正 scrollOffset
    if (_scrollOffsetCorrection != null) {
      geometry = SliverGeometry(
        //修正
        scrollOffsetCorrection: _scrollOffsetCorrection,
      );
      _scrollOffsetCorrection = null;
      return;
    }
    ...
  } 
邊界

在 SliverFlexibleHeader 構(gòu)建子組件時(shí)開發(fā)者可能會(huì)依賴“當(dāng)前的可用高度是否為0”來做一些特殊處理姑食,比如記錄是否子組件已經(jīng)離開了屏幕音半。但是根據(jù)上面的實(shí)現(xiàn)曹鸠,當(dāng)用戶滑動(dòng)非吵固遥快時(shí)叛薯,子組件離開屏幕時(shí)的最后一次布局時(shí)傳遞的約束的 maxExtent 可能不為 0耗溜,而當(dāng) constraints.scrollOffset 大于 _visibleExtent 時(shí)我們?cè)?performLayout 的一開始就返回了抖拴,因此 LayoutBuilder 的 builder 中就有可能收不到 maxExtent 為 0 時(shí)的回調(diào)腥椒。為了解決這個(gè)問題笼蛛,我們只需要在每次 Sliver 離開屏幕時(shí)調(diào)用一次 child.layout 同時(shí) 將maxExtent 指定為 0 即可往湿,為此我們修改一下:

void performLayout() {
    if (child == null) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      return;
    }
    //當(dāng)已經(jīng)完全滑出屏幕時(shí)
    if (constraints.scrollOffset > _visibleExtent) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      // 通知 child 重新布局领追,注意,通知一次即可棕孙,如果不通知蟀俊,滑出屏幕后欧漱,child 在最后
      // 一次構(gòu)建時(shí)拿到的可用高度可能不為 0误甚。因?yàn)槭褂谜咴跇?gòu)建子節(jié)點(diǎn)的時(shí)候窑邦,可能會(huì)依賴
      // "當(dāng)前的可用高度是否為0" 來做一些特殊處理冈钦,比如記錄是否子節(jié)點(diǎn)已經(jīng)離開了屏幕瞧筛,
      // 因此较幌,我們需要在離開屏幕時(shí)確保LayoutBuilder的builder會(huì)被調(diào)用一次(構(gòu)建子組件)乍炉。
      if (!_reported) {
        _reported = true;
        child!.layout(
          ExtraInfoBoxConstraints(
            _direction, //傳遞滑動(dòng)方向
            constraints.asBoxConstraints(maxExtent: 0),
          ),
          //我們不會(huì)使用自節(jié)點(diǎn)的 Size, 關(guān)于此參數(shù)更詳細(xì)的內(nèi)容見本書后面關(guān)于layout原理的介紹
          parentUsesSize: false,
        );
      }
      return;
    }
    //子組件回到了屏幕中,重置通知狀態(tài)
    _reported = false;
  ...
}

示例2

SliverPersistentHeaderToBox槐瑞,可以將任意 RenderBox 適配為可以固定到頂部的 Sliver 而不用顯式指定高度随珠,同時(shí)避免overlapsContent問題窗看。
在沒有使用 SliverAppBar 時(shí)显沈,用 SliverPersistentHeaderToBox拉讯,如果使用了 SliverAppBar 魔慷,用SliverPersistentHeader院尔。

第一步: 實(shí)現(xiàn)SliverPersistentHeaderToBox

typedef SliverPersistentHeaderToBoxBuilder = Widget Function(
  BuildContext context,
  double maxExtent, //當(dāng)前可用最大高度
  bool fixed, // 是否已經(jīng)固定
);
class SliverPersistentHeaderToBox extends StatelessWidget {
  // 默認(rèn)構(gòu)造函數(shù)邀摆,直接接受一個(gè) widget栋盹,不用顯式指定高度
  SliverPersistentHeaderToBox({
    Key? key,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        super(key: key);
 // builder 構(gòu)造函數(shù)例获,需要傳一個(gè) 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,
            //約束中需要傳遞的額外信息是一個(gè)bool類型,表示 Sliver 是否已經(jīng)固定到頂部
            (constraints as ExtraInfoBoxConstraints<bool>).extra,
          );
        },
      ),
    );
  }
}

第二步:實(shí)現(xiàn) _SliverPersistentHeaderToBox
class _RenderSliverPersistentHeaderToBox extends RenderSliverSingleBoxAdapter {
  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    child!.layout(
      ExtraInfoBoxConstraints(
        //只要 constraints.scrollOffset不為0啼器,則表示已經(jīng)有內(nèi)容在當(dāng)前Sliver下面了端壳,即已經(jīng)固定到頂部了
        constraints.scrollOffset != 0,
        constraints.asBoxConstraints(
          // 我們將剩余的可繪制空間作為 header 的最大高度約束傳遞給 LayoutBuilder
          maxExtent: constraints.remainingPaintExtent,
        ),
      ),
      //我們要根據(jù)child大小來確定Sliver大小损谦,所以后面需要用到child的大姓占瘛(size)信息
      parentUsesSize: true,
    );
    // 子節(jié)點(diǎn) 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, // 固定闯参,如果不想固定應(yīng)該傳` - constraints.scrollOffset`
      paintExtent: childExtent,
      maxPaintExtent: childExtent,
    );
  }
  // 重要鹿寨,必須重寫脚草。
  @override
  double childMainAxisPosition(RenderBox child) => 0.0;
}

上面代碼有四點(diǎn)需要注意:
    1. constraints.scrollOffset 不為 0 時(shí)馏慨,則表示已經(jīng)固定到頂部了熏纯。
    2. 在布局階段拿到子組件的 size 信息樟澜,然后通過通過子組件的大小來確定 Sliver 大兄确 (設(shè)置geometry)毒费。 這樣就不再需要我們顯式傳高度值了愈魏。
    3. 通過給 paintOrigin 設(shè)為 0 來實(shí)現(xiàn)頂部固定效果;不固定到頂部時(shí)應(yīng)該傳 - constraints.scrollOffset。
    4. 必須要重寫 childMainAxisPosition 牌柄,否則事件便會(huì)失效珊佣,該方法的返回值(paintOrigin 的位置)在“點(diǎn)擊測(cè)試”中會(huì)用到。

最后一步:測(cè)試
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),
      ],
    );
  }
  // 當(dāng) header 固定后顯示陰影
  Widget headerBuilder(context, maxExtent, fixed) {
    // 獲取當(dāng)前應(yīng)用主題
    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 開頭的函數(shù)代表是需要構(gòu)建一個(gè) Widget,這比 buildXX 會(huì)更簡潔
  Widget wTitle(String text) =>
      ListTile(title: Text(text), onTap: () => print(text));
}
創(chuàng)建兩個(gè) header:
    第一個(gè) header:當(dāng)沒有滑動(dòng)到頂部時(shí)胸梆,外觀和正常列表項(xiàng)一樣碰镜;當(dāng)固定到頂部后绪颖,顯示一個(gè)陰影柠横。為了實(shí)現(xiàn)這個(gè)效果我們需要通過 SliverPersistentHeaderToBox.builder 來動(dòng)態(tài)創(chuàng)建。
    第二個(gè) header: 一個(gè)普通的列表項(xiàng)搬俊,它接受一個(gè) widget唉擂。

4. NestedScrollView

如果CustomScrollView的子組件是一個(gè)可滾動(dòng)組件(通過SliverToBoxAdapter嵌入)且它們的滑動(dòng)方向一致時(shí)則會(huì)造成手勢(shì)沖突,不能正常工作空扎∽猓可使用NestedScrollView解決。

NestedScrollView({
  ...
  required this.headerSliverBuilder,  // 外部可滾動(dòng)組件:CustomScrollView
  required this.body,  // 內(nèi)部可滾動(dòng)組件:任意可滾動(dòng)組件 
  this.floatHeaderSlivers = false,  //
}) 

示例

Material(
  child: NestedScrollView(
    headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
      // 返回一個(gè) Sliver 數(shù)組給外部可滾動(dòng)組件勒魔。
      return <Widget>[
        SliverAppBar(
          title: const Text('嵌套ListView'),
          pinned: true, // 固定在頂部
          forceElevated: innerBoxIsScrolled,
        ),
        buildSliverList(5), // 構(gòu)建一個(gè) 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')),
        );
      },
    ),
  ),
);
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末抚吠,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子萧朝,更是在濱河造成了極大的恐慌检柬,老刑警劉巖何址,帶你破解...
    沈念sama閱讀 222,729評(píng)論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異偎血,居然都是意外死亡烁巫,警方通過查閱死者的電腦和手機(jī)亚隙,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,226評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來渣淳,“玉大人入愧,你說我怎么就攤上這事棺蛛¤胗唬” “怎么了籍胯?”我有些...
    開封第一講書人閱讀 169,461評(píng)論 0 362
  • 文/不壞的土叔 我叫張陵,是天一觀的道長术徊。 經(jīng)常有香客問我本刽,道長,這世上最難降的妖魔是什么赠涮? 我笑而不...
    開封第一講書人閱讀 60,135評(píng)論 1 300
  • 正文 為了忘掉前任子寓,我火速辦了婚禮,結(jié)果婚禮上笋除,老公的妹妹穿的比我還像新娘斜友。我一直安慰自己垃它,他們只是感情好鲜屏,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,130評(píng)論 6 398
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著国拇,像睡著了一般洛史。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上酱吝,一...
    開封第一講書人閱讀 52,736評(píng)論 1 312
  • 那天也殖,我揣著相機(jī)與錄音,去河邊找鬼务热。 笑死忆嗜,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的崎岂。 我是一名探鬼主播捆毫,決...
    沈念sama閱讀 41,179評(píng)論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼冲甘!你這毒婦竟也來了绩卤?” 一聲冷哼從身側(cè)響起途样,我...
    開封第一講書人閱讀 40,124評(píng)論 0 277
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎濒憋,沒想到半個(gè)月后娘纷,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,657評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡跋炕,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,723評(píng)論 3 342
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了律适。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片辐烂。...
    茶點(diǎn)故事閱讀 40,872評(píng)論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖捂贿,靈堂內(nèi)的尸體忽然破棺而出纠修,到底是詐尸還是另有隱情,我是刑警寧澤厂僧,帶...
    沈念sama閱讀 36,533評(píng)論 5 351
  • 正文 年R本政府宣布扣草,位于F島的核電站,受9級(jí)特大地震影響颜屠,放射性物質(zhì)發(fā)生泄漏辰妙。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,213評(píng)論 3 336
  • 文/蒙蒙 一甫窟、第九天 我趴在偏房一處隱蔽的房頂上張望密浑。 院中可真熱鬧,春花似錦粗井、人聲如沸尔破。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,700評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽懒构。三九已至,卻和暖如春耘擂,著一層夾襖步出監(jiān)牢的瞬間胆剧,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,819評(píng)論 1 274
  • 我被黑心中介騙來泰國打工梳星, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留赞赖,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 49,304評(píng)論 3 379
  • 正文 我出身青樓冤灾,卻偏偏與公主長得像前域,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子韵吨,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,876評(píng)論 2 361

推薦閱讀更多精彩內(nèi)容