Flutter之可滑動Widget

可滾動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,
})
複製代碼

主軸和縱軸git

  • 在可滾動widget的座標描述中,一般將滾動方向稱爲主軸,非滾動方向稱爲縱軸。
  • 因爲可滾動widget的默認方向通常都是沿垂直方向,因此默認狀況下主軸就是指垂直方向,水平方向同理

SingleChildScrollView

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

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類型的參數markdown

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

代碼示例

奇數行添加一條紅色下劃線,偶數行添加一條藍色下劃線。app

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的分割線less

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這個屬性, 更多可參考官方文檔ide

// 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
            );
          }
      ),
    );
  }
}
複製代碼

參考文獻


歡迎關注個人微信公衆號,訂閱個人博客!

歡迎關注個人微信公衆號,訂閱個人博客!
相關文章
相關標籤/搜索