Flutter 入門指北(Part 7)之滑動部件

該文已授權公眾號 「碼個蛋」,轉載請指明出處

前面的小節基本上講完了常用的部件和容器部件,也可以完成很多的界面,但是又一個問題,假如我們要顯示一段文字,比如將 一段又臭又長的文字 在界面上顯示 1000 次,不難完成吧

// ..省略一些無關代碼
body: Text('一段又臭又長的文字' * 1000, softWrap: true)

很簡單,運行到手機...「誒誒誒,**,怎么只顯示了一部分,剩下的怎么畫不下去」

日常開發中,會遇到很多這種情況,許多界面不是一頁就能夠顯示完的。那么這里提下可滑動的容器部件

SingleChildScrollView

這個部件非常簡單,不貼源碼了。最簡單的使用方式只需要提供一個 child 即可。現在給前面寫的 Text 包裹上一層 SingleChildScrollView 然后再運行,文字全部都展示出來了。

如果需要實現一個垂直的滾動列表,可以直接通過 SingleChildScrollView 包裹 Column 來實現,列表內容全部塞到 Column 即可

class SingleChildScrollDemoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    /// letters 自由發揮吧...一定要大量,大量,大量
    List<String> letters = [......];

    return Scaffold(
      appBar: AppBar(
        title: Text('Single Child Demo'),
      ),
      body: SingleChildScrollView(
          child: Center(
        child: Column(
          children: List.generate(
              letters.length,
              (index) => Padding(
                    padding: const EdgeInsets.symmetric(vertical: 4.0),
                    child: Text(letters[index], style: TextStyle(fontSize: 18.0)),
                  )),
        ),
      )),
    );
  }
}

運行結果會根據你的 letters 不同而不同,這邊就不貼效果圖了,反正你可以看到一串列表...

那么如果需要實現橫向滾動列表呢,稍稍做下修改就行了

body: SingleChildScrollView(
    // 設置滾動方向
    scrollDirection: Axis.horizontal,
    child: Center(
      // 修改為 `Row` 即可
      child: Row(
        children: List.generate(
            letters.length,
            // 如果你的 letters 數量比較少,推薦加個 `Container` 把寬度指定大點
            (index) => Container(
                child: Padding(
                    padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 6.0),
                    child: Text(letters[index], style: TextStyle(fontSize: 18.0)),
                    ),
                    width: 30.0)),
      ),
    ))

效果圖也不貼了,都比較簡單。

該部分代碼查看 single_child_scroll_main.dart 文件*

ListView

平時開發 Android 的時候,如果有相同格式的列表要實現,一般會使用 ListView 或者 RecyclerView 來實現,Flutter 也提供了類似的部件 ListView

實現 ListView 的方法主要有

  1. 通過 ListView 設置 children 屬性實現
  2. 通過 ListView.custom 實現
  3. 通過 ListView.builder 實現
  4. 通過 ListView.separated 實現帶分割線列表
ListView children

第一種方法實現列表,和通過 SingleChildScrollView + Column / Row 的方法比較類似,不過可以直接通過指定 ListViewscrollDirection 就可以了。

body: ListView(
    // 通過修改滑動方向設置水平或者垂直方向滾動
    scrollDirection: Axis.vertical,
    // 通過 iterable.map().toList 和 List.generate 方法效果是一樣的
    children: letters
        .map((s) =>
        Padding(
            padding: const EdgeInsets.symmetric(vertical: 8.0),
            child: Center(
                child: Text(s))))
        .toList()),
ListView.custom
body: ListView.custom(
    // 指定 item 的高度,可以加快渲染的速度
    itemExtent: 40.0,
    // item 代理
    childrenDelegate: SliverChildBuilderDelegate(
      // IndexedWidgetBuilder,根據 index 設置 item 中需要變化的數據
      (_, index) => Center(child: Text(letters[index], style: TextStyle(color: Colors.red))),
      // 指定 item 的數量
      childCount: letters.length,
    )),

如果每個 item 的高度可以確定,那么推薦通過 itemExtent 來設置 item 的高度/寬度,能夠加快 ListView 的渲染速度。如果不指定高度/寬度,ListView 需要根據每個 item 來計算 ListView 的高度,這個計算過程是需要消耗時間和資源的

ListView.builder

該方法同 custom 類似,custom 需要通過一個 Delegate 生成 item,該方法直接通過 builder 生成,同時也可以直接指定 item 的高度

body: ListView.builder(
    itemBuilder: (_, index) => Center(child: Text(letters[index], style: TextStyle(color: Colors.green))),
    itemExtent: 40.0,
    itemCount: letters.length),

相對比較簡單,代碼也比較少...就沖這點,我也愿意用這個方法

ListView.separated

如果需要在每個 item 之間添加分割線,那么通過以上的方式實現就比較困難了,所以 Flutter 提供了 separated 方法用來快速構建帶有分割線的 ListView

加入我們的 item 之間的分割線需要如下樣式:奇數位和偶數位之間用黑色分割線,偶數位和奇數位之間用紅色分割線

// 需要分割線的時候才使用,不能指定 item 的高度
body: ListView.separated(
    itemBuilder: (_, index) => Padding(
        padding: const EdgeInsets.symmetric(vertical: 20.0),
        child: Center(child: Text(letters[index], style: TextStyle(color: Colors.blue))),
      ),
    // 這里用來定義分割線
    separatorBuilder: (_, index) => Divider(height: 1.0, color: index % 2 == 0 ? Colors.black : Colors.red),
    itemCount: letters.length),

最終的效果如下

ListView 展示.png

以上代碼查看 listview_main.dart 文件

總結下:如果 item 的高度能夠準確獲取,一定要指定 itemExtent 的值,這樣會更加高效,至于要通過哪種方式來生成,完全看個人喜好吧。

ExpansionTile

既然講到了 ListView,在日常開發中,折疊列表也是一個比較常用的,所以這邊要提下 ExpansionTile 這個部件,因為相對比較簡單,所以直接上代碼了

class ExpansionTilesDemoPage extends StatelessWidget {
    
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ExpansionTile Demo'),
      ),
      body: ExpansionTile(
        // 最前面的 widget
        leading: Icon(Icons.phone_android),
        // 替換默認箭頭
//        trailing: Icon(Icons.phone_iphone),
        title: Text('Parent'),
        // 默認是否展開
        initiallyExpanded: true,
        // 展開時候的背景色
        backgroundColor: Colors.yellow[100],
        // 展開或者收縮的回調,true 表示展開
        onExpansionChanged: (expanded) => print('ExpansionTile is ${expanded ? 'expanded' : 'collapsed'}'),
        children: List.generate(
            10,
                (position) =>
                Container(
                  padding: const EdgeInsets.only(left: 80.0),
                  child: Text('Children ${position + 1}'),
                  height: 50.0,
                  alignment: Alignment.centerLeft,
                )),
      ),
    );
  }
}

這樣就完成了一個折疊部件,看下最后的效果

expansion_tile.gif

那么實現折疊列表也就是通過 ListView 創建一個 ExpansionTile 列表即可,先準備下模擬的數據

final _keys = ['ParentA', 'ParentB', 'ParentC', 'ParentD', 'ParentE', 'ParentF'];
  final Map<String, List<String>> _data = {
    'ParentA': ['Child A0', 'Child A1', 'Child A2', 'Child A3', 'Child A4', 'Child A5'],
    'ParentB': ['Child B0', 'Child B1', 'Child B2', 'Child B3', 'Child B4', 'Child B5'],
    'ParentC': ['Child C0', 'Child C1', 'Child C2', 'Child C3', 'Child C4', 'Child C5'],
    'ParentD': ['Child D0', 'Child D1', 'Child D2', 'Child D3', 'Child D4', 'Child D5'],
    'ParentE': ['Child E0', 'Child E1', 'Child E2', 'Child E3', 'Child E4', 'Child E5'],
    'ParentF': ['Child F0', 'Child F1', 'Child F2', 'Child F3', 'Child F4', 'Child F5']
  };

在平時開發過程中,后臺返回的數據應該是列表嵌套列表的形式比較多,我這邊主要就是為了偷懶就隨便弄了,接著修改下 body 的代碼

body: ListView(
          children: _keys
              .map((key) => ExpansionTile(
                    title: Text(key),
                    children: _data[key]
                        .map((value) => InkWell(
                            child: Container(
                              child: Text(value),
                              padding: const EdgeInsets.only(left: 80.0),
                              height: 50.0,
                              alignment: Alignment.centerLeft,
                            ),
                            onTap: () {}))
                        .toList(),
                  ))
              .toList()),

最終的效果就是個折疊列表了

expansion_tile_list.gif

該部分代碼查看 expansion_tile_main.dart 文件

當然了,只要數據到位,別說兩層折疊,三層,四層甚至更多層都能夠實現,源碼中有實現四層的 demo,這邊就不貼代碼了,有需要的小伙伴可以查看源碼

GridView

生成列表可以通過 ListView 來實現,那么同樣,實現網格列表 Flutter 也提供了 GridView 來實現,實現 GridView 的方法也很多...我數了下,大概有 10 種..對你沒看錯,就是那么多,(誒誒誒,別走啊...雖然方法有點多,但是,大同小異)

GridView

GridView 需要一個 gridDelegategridDelegate 目前有兩種

  1. SliverGridDelegateWithFixedCrossAxisCount 看命名就知道,值固定數量的,這個數量是只單排的數量
  2. SliverGridDelegateWithMaxCrossAxisExtent 這個是設置最大寬度/高度,在這個值范圍內取最大值,比如一排能給你排下 6 個,但是遠不到設置的最大值,它絕不給你排 6 個

那么接下來的使用就比較簡單了

class GridViewDemoPage extends StatelessWidget {
  // 自行設置
  final List<String> letters = [ ..... ];

  // 用于區分網格單元
  final List<Color> colors = [Colors.red, Colors.green, Colors.blue, Colors.pink];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('GridView Demo'),
      ),
        body: GridView(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 5, // 單行的個數
            mainAxisSpacing: 10.0, // 同 scrollDirection 掛鉤,item 之間在主軸方向的間隔
            crossAxisSpacing: 10.0, // item 之間在副軸方法的間隔
            childAspectRatio: 1.0 // item 的寬高比
            ),
        // 需要根據 index 設置不同背景色,所以使用 List.generate,如果不設置背景色,也可用 iterable.map().toList
        children: List.generate(
            letters.length,
            (index) => Container(
                  alignment: Alignment.center,
                  child: Text(letters[index]),
                  color: colors[index % 4],
                )),
      ),
    );
  }
}

關鍵地方已經添加了注釋,跑下運行效果

gridview 展示1.png

接下來換一種 delegate 試試效果,當然這個最大值可以根據個人喜好來設置

    body: GridView(
        // 通過設置 `maxCrossAxisExtent` 來指定最大的寬度,在這個值范圍內,會選取相對較大的值
        gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
            maxCrossAxisExtent: 60.0, crossAxisSpacing: 10.0, mainAxisSpacing: 10.0, childAspectRatio: 1.0),
        children: List.generate(
            letters.length,
            (index) => Container(
                  alignment: Alignment.center,
                  child: Text(letters[index]),
                  color: colors[index % 4],
                )),
      )

最后效果:

gridview 展示2.png

為了方便寫法呢,Flutter 對以上的兩種方式進行了封裝,省略了 delegate

GridView.count/GridView.extent

直接看下如何修改

    // 這種情況簡化了 `GridView` 使用 `SliverGridDelegateWithFixedCrossAxisCount` 代理的方法
    body: GridView.count(
          crossAxisSpacing: 10.0,
          mainAxisSpacing: 10.0,
          childAspectRatio: 1.0,
          crossAxisCount: 5,
          childAspectRatio: 2.0,
          children: List.generate(
              letters.length,
              (index) => Container(
                    alignment: Alignment.center,
                    color: colors[index % 4],
                    child: Text(letters[index]),
                  ))),
      // 這種情況簡化了 `GridView` 使用 `SliverGridDelegateWithMaxCrossAxisExtent` 代理的方法
      body: GridView.extent(
          crossAxisSpacing: 10.0,
          mainAxisSpacing: 10.0,
          childAspectRatio: 1.0,
          maxCrossAxisExtent: 60.0,
          children: List.generate(
              letters.length,
                  (index) =>
                  Container(
                    alignment: Alignment.center,
                    color: colors[index % 4],
                    child: Text(letters[index]),
                  ))),

運行的效果入和前面的相同

GridView.custom

這種生成方式,比 GridView 多了一個 childrenDelegatechildrenDelegate 主要分為兩種,一種是通過 IndexedWidgetBuilder 來構建 itemSliverChildBuilderDelegate,還有一種是通過 List 來構建 itemSliverChildListDelegate,所以...這邊直接有 4 中生成方式,當然,我們只需要了解 childrenDelegate 如何使用即可

    body: GridView.custom(
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 5, mainAxisSpacing: 10.0, 
              crossAxisSpacing: 10.0, childAspectRatio: 1.0),
          // item 通過 delegate 來生成,內部實現還是 `IndexedWidgetBuilder`
          childrenDelegate: SliverChildBuilderDelegate(
              (_, index) => Container(
                    alignment: Alignment.center,
                    color: colors[index % 4],
                    child: Text(letters[index]),
                  ),
              childCount: letters.length)),
    body: GridView.custom(
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 5, mainAxisSpacing: 10.0, 
              crossAxisSpacing: 10.0, childAspectRatio: 1.0),
          // 內部通過返回控件列表實現
          childrenDelegate: SliverChildListDelegate(
            List.generate(
                letters.length,
                (index) => Container(
                      child: Text(letters[index]),
                      alignment: Alignment.center,
                      color: colors[index % 4],
                    )),
          )),

運行效果也同上面,不多帖了。

GridView.builder

前面介紹的方法中,生成 item 的方式基本上是通過 List 進行轉換的,在 custom 提到了 IndexWidgetBuilder 的生成方式,當然,在 ListView 的時候也用到了這種生成方式,當然 GridView 也有啊,要「雨露均沾」你說是吧

// 通過 `IndexedWidgetBuilder` 來構建 item,別的參數同上
      body: GridView.builder(
          // 這里又需要分兩種 `gridDelegate`
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 5, crossAxisSpacing: 10.0, mainAxisSpacing: 10.0, childAspectRatio: 1.0),
          itemCount: letters.length,
          itemBuilder: (_, index) =>
              Container(color: colors[index % 4], child: Text(letters[index]), alignment: Alignment.center)),

到這 10 種方式就說完了。終于可以歇一口氣了。

該部分代碼查看 gridview_main.dart 文件

CustomScrollView

在平時的開發中,應該會遇到這么種情況,頭部是一個 GridView 接下來拼接一些別的部件,然后再拼接一個列表,例如下圖

CustomScroll展示.png

因為 GridViewListView 亮著都是可滑動的部件,直接拼接肯定會有「滑動沖突」,所以 Flutter 就提供了一個粘合劑,CustomScrollView,那么 Flutter 如何實現呢,因為會涉及到 Sliver 系列部件,所以這邊先看下大概的代碼,下節會補充 Sliver 系列部件的內容

class CustomScrollDemoPage extends StatelessWidget {
  // 這邊用的 A-Z 字母
  final List<String> letters = [ ..... ];

  final List<Color> colors = [Colors.red, Colors.green, Colors.blue, Colors.pink, Colors.yellow, Colors.deepPurple];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('CustomScrollDemo'),
      ),
      body: CustomScrollView(
        // 這里需要傳入 `Sliver` 部件,下節課填坑
        slivers: <Widget>[
          // SliverGrid 實現同 GridView 實現方式一樣
          // 同樣 SliverGrid 有提供 `count`, `entent` 方法便于快速生成 SliverGrid
          SliverGrid(
              delegate: SliverChildBuilderDelegate(
                  (_, index) => InkWell(
                        child: Image.asset('images/ali.jpg'),
                        onTap: () {},
                      ),
                  childCount: 8),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 4, mainAxisSpacing: 10.0, crossAxisSpacing: 10.0)),
          // 這里下節講
          SliverToBoxAdapter(
              child: Container(
                  color: Colors.black12,
                  margin: const EdgeInsets.symmetric(vertical: 10.0),
                  child: Column(children: <Widget>[
                    Divider(height: 2.0, color: Colors.black54),
                    Stack(
                      alignment: Alignment.center,
                      children: <Widget>[
                        Image.asset('images/app_bar_hor.jpg', fit: BoxFit.cover),
                        Text('我是一些別的東西..例如廣告', textScaleFactor: 1.5, style: TextStyle(color: Colors.red))
                      ],
                    ),
                    Divider(height: 2.0, color: Colors.black54),
                  ], mainAxisAlignment: MainAxisAlignment.spaceBetween),
                  alignment: Alignment.center)),
          // SliverFixedExtentList 實現同 List.custom 實現類似
          SliverFixedExtentList(
              delegate: SliverChildBuilderDelegate(
                  (_, index) => InkWell(
                        child: Container(
                          child: Text(letters[index] * 10,
                              style: TextStyle(color: colors[index % colors.length], letterSpacing: 2.0),
                              textScaleFactor: 1.5),
                          alignment: Alignment.center,
                        ),
                        onTap: () {},
                      ),
                  childCount: letters.length),
              itemExtent: 60.0)
        ],
      ),
    );
  }
}

該部分代碼查看 custom_scroll_main.dart 文件

滑動部件其實還有好幾個,但是以上介紹的在平時開發過程中夠用了,如果后期發現還需要別的部件,我會繼續補上。在結束前,我們再說下如何通過 ScrollController 來控制 Scrollable 的滾動位置。例如我們需要實現,當滾動的距離大于一定距離的時候顯示一個回到頂部的按鈕,有了 ScrollController 就能夠非常方便的實現

ScrollController

因為需要根據滑動的距離顯示回到頂部按鈕,那么就需要通過一個狀態位來控制按鈕顯隱

class ScrollControllerDemoPage extends StatefulWidget {
  @override
  _ScrollControllerDemoPageState createState() => _ScrollControllerDemoPageState();
}

class _ScrollControllerDemoPageState extends State<ScrollControllerDemoPage> {
  var _scrollController = ScrollController();
  var _showBackTop = false;

  @override
  void initState() {
    super.initState();

    // 對 scrollController 進行監聽
    _scrollController.addListener(() {
      // _scrollController.position.pixels 獲取當前滾動部件滾動的距離
      // window.physicalSize.height 獲取屏幕高度
      // 當滾動距離大于 800 后,顯示回到頂部按鈕
      setState(() => _showBackTop = _scrollController.position.pixels >= 800);
    });
  }

  @override
  void dispose() {
    // 記得銷毀對象
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ScrollController Demo'),
      ),
      body: ListView(
        controller: _scrollController,
        children: List.generate(
            20, (index) => Container(height: 50.0, alignment: Alignment.center, child: Text('Item ${index + 1}'))),
      ),
      floatingActionButton: _showBackTop // 當需要顯示的時候展示按鈕,不需要的時候隱藏,設置 null
          ? FloatingActionButton(
              onPressed: () {
                // scrollController 通過 animateTo 方法滾動到某個具體高度
                // duration 表示動畫的時長,curve 表示動畫的運行方式,flutter 在 Curves 提供了許多方式
                _scrollController.animateTo(0.0, duration: Duration(milliseconds: 500), curve: Curves.decelerate);
              },
              child: Icon(Icons.vertical_align_top),
            )
          : null,
    );
  }
}

最后的效果圖

scroll_controller.gif

好啦,這節就到這,下節繼續填這節課留下的坑。

最后代碼的地址還是要的:

  1. 文章中涉及的代碼:demos

  2. 基于郭神 cool weather 接口的一個項目,實現 BLoC 模式,實現狀態管理:flutter_weather

  3. 一個課程(當時買了想看下代碼規范的,代碼更新會比較慢,雖然是跟著課上的一些寫代碼,但是還是做了自己的修改,很多地方看著不舒服,然后就改成自己的實現方式了):flutter_shop

如果對你有幫助的話,記得給個 Star,先謝過,你的認可就是支持我繼續寫下去的動力~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。