Flutter之可滑動Widget

RunTitan發表於2019-05-17

可滾動Widget

  • Flutter和Dart系列文章程式碼GitHub地址
  • Flutter中, 當內容超過顯示檢視時,如果沒有特殊處理,Flutter則會提示Overflow錯誤
  • Flutter提供了多種可滾動(Scrollable Widget)用於顯示列表和長佈局
  • 可滾動Widget都直接或間接包含一個Scrollable, 下面是常用的幾個可滾動的Widget
    • SingleChildScrollView
    • ListView
    • GridView
    • CustomScrollView
    • 滾動監聽及控制ScrollController

Scrollbar

  • Scrollbar是一個Material風格的滾動指示器(滾動條),如果要給可滾動widget新增滾動條,只需將Scrollbar作為可滾動widget的父widget即可
  • CupertinoScrollbariOS風格的滾動條,如果你使用的是Scrollbar,那麼在iOS平臺它會自動切換為CupertinoScrollbar
  • ScrollbarCupertinoScrollbar都是通過ScrollController來監聽滾動事件來確定滾動條位置,關於ScrollController詳細的內容我們將在後面專門一節介紹
  • 下面是ScrollbarCupertinoScrollbar的建構函式, 都只有一個child屬性, 用於接受一個可滾動的Widget
const Scrollbar({
    Key key,
    @required this.child,
})

const CupertinoScrollbar({
    Key key,
    @required this.child,
})
複製程式碼

主軸和縱軸

  • 在可滾動widget的座標描述中,通常將滾動方向稱為主軸,非滾動方向稱為縱軸。
  • 由於可滾動widget的預設方向一般都是沿垂直方向,所以預設情況下主軸就是指垂直方向,水平方向同理

SingleChildScrollView

SingleChildScrollView類似於開發中常用的ScrollView, 不再詳細介紹了, 下面看一下具體使用介紹吧

const SingleChildScrollView({
    Key key,
    // 設定滾動的方向, 預設垂直方向
    this.scrollDirection = Axis.vertical,
    // 設定顯示方式
    this.reverse = false,
    // 內邊距
    this.padding,
    // 是否使用預設的controller
    bool primary,
    // 設定可滾動Widget如何響應使用者操作
    this.physics,
    this.controller,
    this.child,
})
複製程式碼

scrollDirection

設定檢視的滾動方向(預設垂直方向), 需要對應的設定其子WidgetColumn或者Row, 否則會報Overflow錯誤

scrollDirection: Axis.vertical,

// 列舉值
enum Axis {
  /// 水平滾動
  horizontal,
  /// 垂直滾動
  vertical,
}
複製程式碼

reverse

  • 是否按照閱讀方向相反的方向滑動
  • 設定水平滾動時
    • reverse: false,則滾動內容頭部和左側對其, 那麼滑動方向就是從左向右
    • reverse: true時,則滾動內容尾部和右側對其, 那麼滑動方向就是從右往左。
  • 其實此屬性本質上是決定可滾動widget的初始滾動位置是在頭還是尾,取false時,初始滾動位置在頭,反之則在尾

physics

  • 此屬性接受一個ScrollPhysics物件,它決定可滾動Widget如何響應使用者操作
  • 比如使用者滑動完抬起手指後,繼續執行動畫;或者滑動到邊界時,如何顯示。
  • 預設情況下,Flutter會根據具體平臺分別使用不同的ScrollPhysics物件,應用不同的顯示效果,如當滑動到邊界時,繼續拖動的話,在iOS上會出現彈性效果,而在Android上會出現微光效果。
  • 如果你想在所有平臺下使用同一種效果,可以顯式指定,Flutter SDK中包含了兩個ScrollPhysics的子類可以直接使用:
    • ClampingScrollPhysics:安卓下微光效果。
    • BouncingScrollPhysicsiOS下彈性效果。

controller

  • 此屬性接受一個ScrollController物件
  • ScrollController的主要作用是控制滾動位置和監聽滾動事件。
  • 預設情況下,widget中會有一個預設的PrimaryScrollController,如果子widget中的可滾動widget沒有顯式的指定controller並且primary屬性值為true時(預設就為true),可滾動widget會使用這個預設的PrimaryScrollController
  • 這種機制帶來的好處是父widget可以控制子樹中可滾動widget的滾動,例如,Scaffold使用這種機制在iOS中實現了"回到頂部"的手勢

程式碼示例

class ScrollView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    return Scrollbar(
      child: SingleChildScrollView(
        scrollDirection: Axis.vertical,
        reverse: true,
        padding: EdgeInsets.all(0.0),
        physics: BouncingScrollPhysics(),
        child: Center(
          child: Column( 
            //動態建立一個List<Widget>  
            children: str.split("") 
                //每一個字母都用一個Text顯示,字型為原來的兩倍
                .map((c) => Text(c, textScaleFactor: 2.0)) 
                .toList(),
          ),
        ),
      ),
    );
  }
}
複製程式碼

ListView

  • ListView是最常用的可滾動widget,它可以沿一個方向線性排布所有子widget, 類似於ReactNative中的ListView
  • ListView共有四種建構函式
    • ListView()預設建構函式
    • ListView.builder()
    • ListView.separated()
    • ListView custom()
ListView({
    // 公共引數上面都介紹過了
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    EdgeInsetsGeometry padding,
    
    // 是否根據子widget的總長度來設定ListView的長度,預設值為false
    bool shrinkWrap = false,
    // cell高度
    this.itemExtent,
    // 子widget是否包裹在AutomaticKeepAlive中
    bool addAutomaticKeepAlives = true,
    // 子widget是否包裹在RepaintBoundary中
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    // 設定預載入的區域, moren 0.0
    double cacheExtent,
    //子widget列表
    List<Widget> children = const <Widget>[],
    // 子widget的個數
    int semanticChildCount,
})
複製程式碼

屬性介紹

shrinkWrap

  • 表示是否根據子widget的總長度來設定ListView的長度,預設值為false
  • 預設情況下,ListView的會在滾動方向儘可能多的佔用空間
  • ListView在一個無邊界(滾動方向上)的容器中時,shrinkWrap必須為true

itemExtent

  • 該引數如果不為null,則會強制children的"長度"為itemExtent的值
  • 這裡的"長度"是指滾動方向上子widget的長度,即如果滾動方向是垂直方向,則代表子widget的高度,如果滾動方向為水平方向,則代表子widget的長度
  • ListView中,指定itemExtent比讓子widget自己決定自身長度會更高效,這是因為指定itemExtent後,滾動系統可以提前知道列表的長度,而不是總是動態去計算,尤其是在滾動位置頻繁變化時

addAutomaticKeepAlives

  • 表示是否將列表項包裹在AutomaticKeepAlive
  • 在一個懶載入列表中,如果將列表項包裹在AutomaticKeepAlive中,在該列表項滑出視口時該列表項不會被GC,它會使用KeepAliveNotification來儲存其狀態
  • 如果列表項自己維護其KeepAlive狀態,那麼此引數必須置為false

addRepaintBoundaries

  • 性表示是否將列表項包裹在RepaintBoundary
  • 當可滾動widget滾動時,將列表項包裹在RepaintBoundary中可以避免列表項重繪,但是當列表項重繪的開銷非常小(如一個顏色塊,或者一個較短的文字)時,不新增RepaintBoundary反而會更高效
  • addAutomaticKeepAlive一樣,如果列表項自己維護其KeepAlive狀態,那麼此引數必須置為false

使用示例

class ScrollView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView(
      itemExtent: 60,
      cacheExtent: 100,
      addAutomaticKeepAlives: false,
      children: renderCell(),
    );
  }

  List<Widget> renderCell() {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    return str.split("")
    .map((item) => ListTile(
      title: Text('字母--$item'),
      subtitle: Text('這是字母列表'),
      leading: Icon(Icons.wifi),
    )).toList();
  }
}
複製程式碼

ListTile

  • ListTileFlutter給我們準備好的用於建立ListView的子widget
  • 提供非常常見的構造和定義方式,包括文字,icon,點選事件,一般是能夠滿足基本需求,但是就不能自己定義了
const ListTile({
    Key key,
    // 前置(左側)圖示, Widget型別
    this.leading,
    // 標題, Widget型別
    this.title,
    // 副標題, Widget型別
    this.subtitle,
    // 後置(右側)圖示, Widget型別
    this.trailing,
    // 是否三行顯示, subtitle不為空時才能使用
    this.isThreeLine = false,
    // 設定為true後字型變小
    this.dense,
    // 內容的內邊距
    this.contentPadding,
    // 是否可被點選
    this.enabled = true,
    // 點選事件
    this.onTap,
    // 長按操作事件
    this.onLongPress,
    // 是否是選中狀態
    this.selected = false,
})

// 使用示例
return ListTile(
  title: Text('index--$index'),
  subtitle: Text('我是一隻小鴨子, 咿呀咿呀喲; 我是一隻小鴨子, 咿呀咿呀喲; 我是一隻小鴨子, 咿呀咿呀喲;'),
  leading: Icon(Icons.wifi),
  trailing: Icon(Icons.keyboard_arrow_right),
  isThreeLine: true,
  dense: false,
  contentPadding: EdgeInsets.all(10),
  enabled: index % 3 != 0,
  onTap: () => print('index = $index'),
  onLongPress: () => print('long-Index = $index'),
  selected: index % 2 == 0,
);
複製程式碼

ListView.builder

  • ListView.builder適合列表項比較多(或者無限)的情況,因為只有當子Widget真正顯示的時候才會被建立
  • 適用於自定義子Widget且所有子Widget的樣式一樣
ListView.builder({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    this.itemExtent,
    // 
    @required IndexedWidgetBuilder itemBuilder,
    // 列表項的數量,如果為null,則為無限列表
    int itemCount,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    int semanticChildCount,
})
複製程式碼

itemCount

列表項的數量,如果為null,則為無限列表

itemBuilder

  • 它是列表項的構建器,型別為IndexedWidgetBuilder,返回值為一個widget
  • 當列表滾動到具體的index位置時,會呼叫該構建器構建列表項

程式碼示例

class ListBuild extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
      return ListView.builder(
        itemCount: 30,
        itemBuilder: (content, index) {
          return ListTile(
            title: Text('index--$index'),
            subtitle: Text('數字列表'),
            leading: Icon(Icons.wifi),
          );
        },
      );
    }
}
複製程式碼

ListView.separated

ListView.separated可以生成列表項之間的分割器,它除了比ListView.builder多了一個separatorBuilder引數外, 其他引數都一樣

ListView.separated({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required IndexedWidgetBuilder itemBuilder,
    // 一個分割生成器
    @required IndexedWidgetBuilder separatorBuilder,
    @required int itemCount,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
})
複製程式碼

separatorBuilder

該引數是一個分割生成器, 同樣是一個IndexedWidgetBuilder型別的引數

typedef IndexedWidgetBuilder = Widget Function(BuildContext context, int index);
複製程式碼

程式碼示例

奇數行新增一條紅色下劃線,偶數行新增一條藍色下劃線。

lass SeparatedList extends StatelessWidget {
  //下劃線widget預定義以供複用。  
  Widget lineView1 = Divider(color: Colors.red, height: 2, indent: 10,);
  Widget lineView2 = Divider(color: Colors.blue, height: 5, indent: 30);

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.separated(
      itemCount: 30,
        itemBuilder: (content, index) {
          return ListTile(
            title: Text('index--$index'),
            subtitle: Text('數字列表'),
            leading: Icon(Icons.wifi),
          );
        },
        separatorBuilder: (context, index) {
          return index % 2 == 0 ? lineView1 : lineView2;
        },
    );
  }
}
複製程式碼

Divider

設定每一個子WIdget的分割線

const Divider({
    Key key,
    // 分割線所在的SizedBox的高度, 除內邊距之外的距離上面的間距
    this.height = 16.0,
    // 分割線左側間距
    this.indent = 0.0,
    // 分割線顏色
    this.color
})
複製程式碼

ListView.custom

  • 大家可能對前兩種比較熟悉,分別是傳入一個子元素列表或是傳入一個根據索引建立子元素的函式。
  • 其實前兩種方式都是custom方式的“快捷方式”
  • ListView內部是靠這個childrenDelegate屬性動態初始化子元素的
  • 我們使用builderseparated比較多,這個custom相對來說就比較少了
const ListView.custom({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    this.itemExtent,
    // 動態初始化子元素
    @required this.childrenDelegate,
    double cacheExtent,
    int semanticChildCount,
})
複製程式碼

childrenDelegate

其實在ListView的前面幾種建構函式中, 都預設設定了childrenDelegate這個屬性, 更多可參考官方文件

// ListView
ListView({
    // ...
  }) : childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ), super();

// ListView.builder
ListView.builder({
    // ...
  }) : childrenDelegate = SliverChildBuilderDelegate(
         itemBuilder,
         childCount: itemCount,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ), super();

// ListView.separated
ListView.separated({
    // ...
  }) : childrenDelegate = SliverChildBuilderDelegate(
         // ...
       ), super();
複製程式碼
  • 上面程式碼中可見,這裡自動幫我們建立了一個SliverChildListDelegate的例項
  • SliverChildListDelegate是抽象類SliverChildDelegate的子類
  • SliverChildListDelegate中主要邏輯就是實現了SliverChildDelegate中定義的build方法
Widget build(BuildContext context, int index) {
    assert(builder != null);
    if (index < 0 || (childCount != null && index >= childCount))
      return null;
    Widget child;
    try {
      child = builder(context, index);
    } catch (exception, stackTrace) {
      child = _createErrorWidget(exception, stackTrace);
    }
    if (child == null)
      return null;
    if (addRepaintBoundaries)
      child = RepaintBoundary.wrap(child, index);
    if (addSemanticIndexes) {
      final int semanticIndex = semanticIndexCallback(child, index);
      if (semanticIndex != null)
        child = IndexedSemantics(index: semanticIndex + semanticIndexOffset, child: child);
    }
    if (addAutomaticKeepAlives)
      child = AutomaticKeepAlive(child: child);
    return child;
}
複製程式碼
  • 從上面程式碼的邏輯可以看出, 就是根據傳入的索引返回children列表中對應的元素
  • 每當ListView的底層實現需要載入一個元素時,就會把該元素的索引傳遞給SliverChildDelegatebuild方法,由該方法返回具體的元素
  • 另外在SliverChildDelegate內部,除了定義了build方法外,還定義了 一個名為didFinishLayout的方法
void didFinishLayout() {
    assert(debugAssertChildListLocked());
    final int firstIndex = _childElements.firstKey() ?? 0;
    final int lastIndex = _childElements.lastKey() ?? 0;
    widget.delegate.didFinishLayout(firstIndex, lastIndex);
}
複製程式碼
  • 每當ListView完成一次layout之後都會呼叫該方法, 同時傳入兩個索引值
  • 這兩個值分別是此次layout中第一個元素和最後一個元素在ListView所有子元素中的索引值, 也就是可視區域內的元素在子元素列表中的位置
  • 然而不論是SliverChildListDelegate還是SliverChildBuilderDelegate的程式碼中,都沒有didFinishLayout的具體實現。所以我們需要編寫一個它們的子類
class MySliverBuilderDelegate extends SliverChildBuilderDelegate {
  MySliverBuilderDelegate(
    Widget Function(BuildContext, int) builder, {
    int childCount,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
  }) : super(builder,
            childCount: childCount,
            addAutomaticKeepAlives: addAutomaticKeepAlives,
            addRepaintBoundaries: addRepaintBoundaries);

  @override
  void didFinishLayout(int firstIndex, int lastIndex) {
    print('firstIndex: $firstIndex, lastIndex: $lastIndex');
  }
}
複製程式碼

然後我們建立一個ListView.custom的列表檢視

class CustomList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.custom(
      childrenDelegate: MySliverBuilderDelegate(
        (BuildContext context, int index) {
          return ListTile(
            title: Text('index--$index'),
            subtitle: Text('數字列表'),
            leading: Icon(Icons.wifi),
          );
        }, childCount: 30,
      ),
    );
  }
}
複製程式碼

GridView

GridView可以構建二維網格列表, 系統給出了五中建構函式

  • GridView()
  • GridView.count
  • GridView.extent
  • GridView.builder
  • GridView.custom
// 預設建構函式
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,
    bool addSemanticIndexes = true,
    double cacheExtent,
    List<Widget> children = const <Widget>[],
    int semanticChildCount,
})
複製程式碼
  • 可以看到, 除了gridDelegate屬性外, 其他屬性和ListView的屬性都一樣, 含義也都相同
  • gridDelegate引數的型別是SliverGridDelegate,它的作用是控制GridViewwidget如何排列
  • SliverGridDelegate是一個抽象類,定義了GridView排列相關介面,子類需要通過實現它們來實現具體的佈局演算法
  • Flutter中提供了兩個SliverGridDelegate的子類SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent, 下面我們分別介紹

SliverGridDelegateWithFixedCrossAxisCount

該子類實現了一個橫軸為固定數量子元素的排列演算法,其建構函式為:

const SliverGridDelegateWithFixedCrossAxisCount({
    // 橫軸子元素的數量,此屬性值確定後子元素在橫軸的長度就確定了,即ViewPort橫軸長度/crossAxisCount。
    @required this.crossAxisCount,
    // 主軸方向的間距
    this.mainAxisSpacing = 0.0,
    // 側軸方向子元素的間距
    this.crossAxisSpacing = 0.0,
    // 子元素在側軸長度和主軸長度的比例, 由於crossAxisCount指定後子元素橫軸長度就確定了,然後通過此引數值就可以確定子元素在主軸的長度
    this.childAspectRatio = 1.0,
})
複製程式碼

從上面的個屬性可以發現,子元素的大小是通過crossAxisCountchildAspectRatio兩個引數共同決定的。注意,這裡的子元素指的是子widget的最大顯示空間,注意確保子widget的實際大小不要超出子元素的空間, 程式碼示例如下

class ScrollView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GridView(
      padding: EdgeInsets.all(10),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3,
        childAspectRatio: 1,
        mainAxisSpacing: 10,
        crossAxisSpacing: 10
      ),
      children: <Widget>[
        Container(color: Colors.orange),
        Container(color: Colors.blue),
        Container(color: Colors.orange),
        Container(color: Colors.yellow),
        Container(color: Colors.pink)
      ],
    );
  }
}
複製程式碼

GridView.count

GridView.count建構函式內部使用了SliverGridDelegateWithFixedCrossAxisCount,我們通過它可以快速的建立橫軸固定數量子元素的GridView

GridView.count({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required int crossAxisCount,
    double mainAxisSpacing = 0.0,
    double crossAxisSpacing = 0.0,
    double childAspectRatio = 1.0,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    List<Widget> children = const <Widget>[],
    int semanticChildCount,
})
複製程式碼

上面SliverGridDelegateWithFixedCrossAxisCount中給出的示例程式碼等價於:

class CountGridView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return GridView.count(
      padding: EdgeInsets.all(10),
      crossAxisCount: 3,
      mainAxisSpacing: 10,
      crossAxisSpacing: 10,
      childAspectRatio: 1,
      children: <Widget>[
        Container(color: Colors.orange),
        Container(color: Colors.blue),
        Container(color: Colors.orange),
        Container(color: Colors.yellow),
        Container(color: Colors.pink)
      ],
    );
  }
}
複製程式碼

SliverGridDelegateWithMaxCrossAxisExtent

該子類實現了一個側軸子元素為固定最大長度的排列演算法,其建構函式為:

const SliverGridDelegateWithMaxCrossAxisExtent({
    @required this.maxCrossAxisExtent,
    this.mainAxisSpacing = 0.0,
    this.crossAxisSpacing = 0.0,
    this.childAspectRatio = 1.0,
})
複製程式碼
  • maxCrossAxisExtent為子元素在側軸上的最大長度,之所以是“最大”長度,是因為橫軸方向每個子元素的長度仍然是等分的
  • 同樣側軸上子Widget的個數, 也是由該屬性決定
  • 其它引數和SliverGridDelegateWithFixedCrossAxisCount相同
class ExtentScrollView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GridView(
      padding: EdgeInsets.all(10),
      gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 100,
        childAspectRatio: 1,
        mainAxisSpacing: 10,
        crossAxisSpacing: 10
      ),
      children: <Widget>[
        Container(color: Colors.orange),
        Container(color: Colors.blue),
        Container(color: Colors.orange),
        Container(color: Colors.yellow),
        Container(color: Colors.pink)
      ],
    );
  }
}
複製程式碼

GridView.extent

同樣GridView.extent建構函式內部使用了SliverGridDelegateWithMaxCrossAxisExtent,我們通過它可以快速的建立側軸子元素為固定最大長度的的GridView

GridView.extent({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required double maxCrossAxisExtent,
    double mainAxisSpacing = 0.0,
    double crossAxisSpacing = 0.0,
    double childAspectRatio = 1.0,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    List<Widget> children = const <Widget>[],
    int semanticChildCount,
})
複製程式碼

上面SliverGridDelegateWithMaxCrossAxisExtent中給出的示例程式碼等價於:

class ExtentScrollView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GridView.extent(
      padding: EdgeInsets.all(10),
      maxCrossAxisExtent: 100,
      childAspectRatio: 1,
      mainAxisSpacing: 10,
      crossAxisSpacing: 10,
      children: <Widget>[
        Container(color: Colors.orange),
        Container(color: Colors.blue),
        Container(color: Colors.orange),
        Container(color: Colors.yellow),
        Container(color: Colors.pink)
      ],
    );
  }
}
複製程式碼

GridView.builder

  • 上面我們介紹的GridView都需要一個Widget陣列作為其子元素,這些方式都會提前將所有子widget都構建好,所以只適用於子Widget數量比較少時
  • 當子widget比較多時,我們可以通過GridView.builder來動態建立子Widget
GridView.builder({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required this.gridDelegate,
    @required IndexedWidgetBuilder itemBuilder,
    int itemCount,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    int semanticChildCount,
})
複製程式碼
  • 可以看出GridView.builder必須指定的引數有兩個,其中gridDelegate之前已經介紹過了
  • 屬性itemBuilder在之前ListView中也有介紹過類似的, 用於構建子Widget
  • 使用示例如下
class BuilderGridView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      itemCount: 50,
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 4,
        mainAxisSpacing: 10,
        crossAxisSpacing: 10
      ),
      itemBuilder: (content, index) {
        return Container(
          color: Colors.orange,
          child: Center(
            child: Text('$index'),
          ),
        );
      },
    );
  }
}
複製程式碼

GridView.custom

ListView.custom一樣, 用於構建自定義子Widget, 有兩個必須指定的引數, 這裡就不在贅述了

const GridView.custom({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required this.gridDelegate,
    @required this.childrenDelegate,
    double cacheExtent,
    int semanticChildCount,
})
複製程式碼

CustomScrollView

  • CustomScrollView使用sliver來自定義滾動模型(效果, 它可以包含多種滾動模型
  • 假設有一個頁面,頂部需要一個GridView,底部需要一個ListView,而要求整個頁面的滑動效果是統一的,即它們看起來是一個整體
  • 如果使用GridView+ListView來實現的話,就不能保證一致的滑動效果,因為它們的滾動效果是分離的,所以這時就需要一個"膠水",把這些彼此獨立的可滾動widget"粘"起來,而CustomScrollView的功能就相當於“膠水”
const CustomScrollView({
    Key key,
    // 滑動方向
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    double cacheExtent,
    this.slivers = const <Widget>[],
    int semanticChildCount,
})
複製程式碼

上述屬性除了slivers之外, 前面都有提到過, 接受一個Widget陣列, 但是這裡的Widget必須是Sliver型別的, 至於原因, 下面會詳解

什麼是`Sliver` ??

  • Flutter中,Sliver通常指具有特定滾動效果的可滾動塊
  • 可滾動widget,如ListViewGridView等都有對應的Sliver實現如SliverListSliverGrid
  • 對於大多數Sliver來說,它們和可滾動Widget最主要的區別是Sliver不會包含Scrollable,也就是說Sliver本身不包含滾動互動模型
  • 正因如此,CustomScrollView才可以將多個Sliver"粘"在一起,這些Sliver共用CustomScrollViewScrollable,最終實現統一的滑動效果
  • 前面之所以說“大多數“Sliver都和可滾動Widget對應,是由於還有一些如SliverPaddingSliverAppBar等是和可滾動Widget無關的
  • 它們主要是為了結合CustomScrollView一起使用,這是因為CustomScrollView的子widget必須都是Sliver

SliverAppBar

  • AppBarSliverAppBarMaterial Design中的導航欄
  • AppBarSliverAppBar都是繼承StatefulWidget類,二者的區別在於AppBar位置的固定的應用最上面的;而SliverAppBar是可以跟隨內容滾動的
  • 其中大部分的屬性和AppBar都一樣
const SliverAppBar({
    Key key,
    // 導航欄左側weidget
    this.leading,
    // 如果leading為null,是否自動實現預設的leading按鈕
    this.automaticallyImplyLeading = true,
    // 導航欄標題
    this.title,
    // 導航欄右側按鈕, 接受一個陣列
    this.actions,
    // 一個顯示在AppBar下方的控制元件,高度和AppBar高度一樣,可以實現一些特殊的效果,該屬性通常在SliverAppBar中使用
    this.flexibleSpace,
    // 一個AppBarBottomWidget物件, 設定TabBar
    this.bottom,
    //中控制元件的z座標順序,預設值為4,對於可滾動的SliverAppBar,當 SliverAppBar和內容同級的時候,該值為0,當內容滾動 SliverAppBar 變為 Toolbar 的時候,修改elevation的值
    this.elevation = 4.0,
    // 背景顏色,預設值為 ThemeData.primaryColor。改值通常和下面的三個屬性一起使用
    this.backgroundColor,
    // 狀態列的顏色, 黑白兩種, 取值: Brightness.dark
    this.brightness,
    // 設定導航欄上圖示的顏色、透明度、和尺寸資訊
    this.iconTheme,
    // 設定導航欄上文字樣式
    this.textTheme,
    // 導航欄的內容是否顯示在頂部, 狀態列的下面
    this.primary = true,
    // 標題是否居中顯示,預設值根據不同的作業系統,顯示方式不一樣
    this.centerTitle,
    // 標題間距,如果希望title佔用所有可用空間,請將此值設定為0.0
    this.titleSpacing = NavigationToolbar.kMiddleSpacing,
    // 展開的最大高度
    this.expandedHeight,
    // 是否隨著華東隱藏標題
    this.floating = false,
    // 是否固定在頂部
    this.pinned = false,
    // 只跟floating相對應,如果為true,floating必須為true,也就是向下滑動一點兒,整個大背景就會動畫顯示全部,網上滑動整個導航欄的內容就會消失
    this.snap = false,
})
複製程式碼

使用示例

class CustomScrollViewTestRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //因為本路由沒有使用Scaffold,為了讓子級Widget(如Text)使用
    //Material Design 預設的樣式風格,我們使用Material作為本路由的根。
    return Material(
      child: CustomScrollView(
        slivers: <Widget>[
          //AppBar,包含一個導航欄
          SliverAppBar(
            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, //Grid按兩列顯示
                mainAxisSpacing: 10.0,
                crossAxisSpacing: 10.0,
                childAspectRatio: 4.0,
              ),
              delegate: new SliverChildBuilderDelegate(
                    (BuildContext context, int index) {
                  //建立子widget      
                  return new Container(
                    alignment: Alignment.center,
                    color: Colors.cyan[100 * (index % 9)],
                    child: new Text('grid item $index'),
                  );
                },
                childCount: 20,
              ),
            ),
          ),
          //List
          new SliverFixedExtentList(
            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個列表項
            ),
          ),
        ],
      ),
    );
  }
}
複製程式碼

ScrollController

  • ScrollController用於控制可滾動widget的滾動位置,這裡以ListView為例,展示一下ScrollController的具體用法
  • 最後再介紹一下路由切換時如何來儲存滾動位置
  • 下面先看一下ScrollController的建構函式
ScrollController({
    // 初始滾動位置
    double initialScrollOffset = 0.0,
    // 是否儲存滾動位置
    this.keepScrollOffset = true,
    // 除錯使用的輸出標籤
    this.debugLabel,
})
複製程式碼

相關屬性和方法

offset

可滾動Widget當前滾動的位置

jumpTo()

跳轉到指定的位置, 沒有動畫效果

void jumpTo(double value) {
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
    for (ScrollPosition position in List<ScrollPosition>.from(_positions))
      position.jumpTo(value);
}
複製程式碼

animateTo()

跳轉到指定的位置, 跳轉時會有一個動畫效果

Future<void> animateTo(double offset, {
    @required Duration duration,
    @required Curve curve,
  }) {
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
    final List<Future<void>> animations = List<Future<void>>(_positions.length);
    for (int i = 0; i < _positions.length; i += 1)
      animations[i] = _positions[i].animateTo(offset, duration: duration, curve: curve);
    return Future.wait<void>(animations).then<void>((List<void> _) => null);
}
複製程式碼

positions

  • 一個ScrollController可以同時被多個Scrollable使用,ScrollController會為每一個Scrollable建立一個ScrollPosition物件,這些ScrollPosition儲存在ScrollControllerpositions屬性中(是一個陣列)
  • ScrollPosition是真正儲存滑動位置資訊的物件,offset只是一個便捷屬性, 其他更多屬性可檢視相關官方文件
  • 一個ScrollController雖然可以對應多個Scrollable,但是有一些操作,如讀取滾動位置offset,則需要一對一,但是我們仍然可以在一對多的情況下,通過其它方法讀取滾動位置
// controller的offset屬性
double get offset => position.pixels;

// 讀取相關的滾動位置
controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels
複製程式碼

滾動監聽

ScrollController間接繼承自Listenable,我們可以根據ScrollController來監聽滾動事件。如:

controller.addListener(()=>print(controller.offset))
複製程式碼

ScrollController控制原理

先看一下ScrollController另外幾個方法的實現

// 建立一個儲存位置資訊的ScrollPosition
 ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition oldPosition,
  ) {
    return ScrollPositionWithSingleContext(
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
 }

 // 註冊位置資訊
 void attach(ScrollPosition position) {
    assert(!_positions.contains(position));
    _positions.add(position);
    position.addListener(notifyListeners);
  }

  // 登出位置資訊
  void detach(ScrollPosition position) {
    assert(_positions.contains(position));
    position.removeListener(notifyListeners);
    _positions.remove(position);
  }

  // 銷燬ScrollController
  @override
  void dispose() {
    for (ScrollPosition position in _positions)
      position.removeListener(notifyListeners);
    super.dispose();
  }
複製程式碼
  • ScrollControllerScrollable關聯時,Scrollable首先會呼叫ScrollControllercreateScrollPosition()方法來建立一個ScrollPosition來儲存滾動位置資訊
  • 然後Scrollable會呼叫attach()方法,將建立的ScrollPosition新增到ScrollControllerpositions屬性中,這一步稱為“註冊位置”,只有註冊後animateTo()jumpTo()才可以被呼叫
  • Scrollable銷燬時,會呼叫ScrollControllerdetach()方法,將其ScrollPosition物件從ScrollControllerpositions屬性中移除,這一步稱為“登出位置”,登出後animateTo()jumpTo()將不能再被呼叫
  • 需要注意的是,ScrollControlleranimateTo()jumpTo()內部會呼叫所有ScrollPositionanimateTo()jumpTo(),以實現所有和該ScrollController關聯的Scrollable都滾動到指定的位置

程式碼示例

建立一個ListView,當滾動位置發生變化時,我們先列印出當前滾動位置,然後判斷當前位置是否超過1000畫素,如果超過則在螢幕右下角顯示一個“返回頂部”的按鈕,該按鈕點選後可以使ListView恢復到初始位置;如果沒有超過1000畫素,則隱藏“返回頂部”按鈕。程式碼如下

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

class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
  ScrollController _controller = new ScrollController();
  bool showToTopBtn = false; //是否顯示“返回到頂部”按鈕

  @override
  void 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
            );
          }
      ),
    );
  }
}
複製程式碼

參考文獻


歡迎關注我的微信公眾號,訂閱我的部落格!

歡迎關注我的微信公眾號,訂閱我的部落格!

相關文章