【Flutter 3.3】初學ListView - 可愛的角落生物我來了

最近實際學了一下Flutter ~ 抖動?之後,個人發現它的想法不太像一般手機APP的想法,反而比較像網頁?,都是以Widget去組合,把元件切的很細,一直去做組合,而且反而懂元件的種類,遠比寫程式硬寫來得重要… 今天,續上集的熱烈回應之後,我們就來做一個角落生物的列表,那以下的圖片Link呢,都僅做為教學之用,Let’s Go

作業環境

項目 版本
macOS Big Sur 12.5.1 arm64
OpenJDK 18.0.1-Zulu arm64
Dart SDK 2.18.0 arm64
Flutter SDK 3.3.0 arm64
Xcode 13.4.1 arm64
Android Studio 2021.2.1 arm64
Visual Studio Code 1.70 arm64
Ruby 2.6.8 arm64
CocoaPods 1.11 x86_64 + Ruby-FFI (Foreign Function Interface)

首先,要做一支長的像這樣的APP

自訂ListView的長相

自訂Item的長相

  • 除了ListTitleCard,也可以自訂Item的長相…
  • 雖然看起來很簡單,但個人覺得是有點難度的,要知道一點Widget的功能
  • 全部的程式在這裡,後面分成一步步說明…
import 'package:flutter/material.dart';

class ProfilePage extends StatefulWidget {
  const ProfilePage({Key? key, required this.title}) : super(key: key);
  final String title;
  @override
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> {
  final _item = SizedBox(
    height: 128.0,
    child: Stack(
      fit: StackFit.expand,
      children: [
        Image.network(
          'https://pinkoi-wp-blog.s3.ap-southeast-1.amazonaws.com/wp-content/uploads/sites/6/2021/12/13155150/角落生物-1-1021x1024.webp',
          fit: BoxFit.fitWidth,
        ),
        Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Container(
              decoration: const BoxDecoration(
                color: Color.fromARGB(64, 0, 0, 0),
              ),
              child: const Padding(
                padding: EdgeInsets.all(8.0),
                child: Center(
                    child: Text(
                  'Title',
                  style: TextStyle(
                    fontSize: 24,
                    fontWeight: FontWeight.w900,
                    color: Colors.black87,
                  ),
                )),
              ),
            ),
            Expanded(
              child: Container(
                decoration: const BoxDecoration(
                  color: Color.fromARGB(32, 0, 0, 0),
                ),
                child: const Padding(
                  padding: EdgeInsets.all(8.0),
                  child: Text('DEMO'),
                ),
              ),
            ),
          ],
        ),
      ],
    ),
  );

  late ListView listView = ListView.separated(
    itemCount: 8,
    itemBuilder: ((context, index) {
      return _item;
    }),
    separatorBuilder: ((context, index) {
      return const Divider(
        height: 1,
        thickness: 2,
        color: Colors.blueGrey,
      );
    }),
  );

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text(
          'ListView',
          style: TextStyle(color: Colors.black),
        ),
        centerTitle: true,
        backgroundColor: Colors.transparent,
        elevation: 0,
      ),
      extendBodyBehindAppBar: true,
      backgroundColor: Colors.amber.shade100,
      body: listView,
    );
  }
}

SizedBox - 盒子

  • 首先,先做一個高度為128的item,利用SizedBox來做到這件事情,然後背景放張滿版的圖…
  • 其中,BoxFit.fitWidth,就有點像iOS中,UIImageView的contentMode的設定,可以設定圖片填充的樣式,大家可以自己改改看,比較有什麼不同…
  • 然後利用Image.network()去讀取網路上的圖片…

class _ProfilePageState extends State<ProfilePage> {

  final _item = SizedBox(
    height: 128.0,
    child: Image.network(
      'https://pinkoi-wp-blog.s3.ap-southeast-1.amazonaws.com/wp-content/uploads/sites/6/2021/12/13155150/角落生物-1-1021x1024.webp',
      fit: BoxFit.fitWidth,
    ),
  );
}

Stack - 疊疊樂

  • 再來就要組合內容的部分,如果要一層層的『疊上去』的話,就使用Stack來處理…
  • 簡單來說,就有點像趴趴熊的樣子,一個個壓上去…
  • 其中要注意的是,Stack中也有個填充的樣式可以設定,就是StackFit.expand這個值,大家也可以去試一下改變它會造成什麼樣的結果…
  • 我們就利用最單純的Container,一層層疊上去…

class _ProfilePageState extends State<ProfilePage> {
  final _item = SizedBox(
    height: 128.0,
    child: Stack(
      fit: StackFit.expand,
      children: [
        Image.network(
          'https://pinkoi-wp-blog.s3.ap-southeast-1.amazonaws.com/wp-content/uploads/sites/6/2021/12/13155150/角落生物-1-1021x1024.webp',
          fit: BoxFit.fitWidth,
        ),
        Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Container(
              decoration: const BoxDecoration(
                color: Color.fromARGB(64, 0, 0, 0),
              ),
              child: const Center(
                child: Text(
                  'Title',
                  style: TextStyle(
                    fontSize: 24.0,
                    fontWeight: FontWeight.w900,
                    color: Colors.black87,
                    backgroundColor: Colors.red,
                  ),
                ),
              ),
            ),
          ],
        ),
      ],
    ),
  );
}

Column - 千層糕

  • 如果要上下一個個放上去的話,就使用Column吧…
  • 當然,有上下放,就會有左右放的Row
  • 在視覺上,就有點像千層糕的樣子,一層一層的…
  • 這其實跟iOS的UIStack View滿像的…
  • 如果最後一個要填滿的話呢?就加上Expanded,讓它放到最滿吧…

class _ProfilePageState extends State<ProfilePage> {
  final _item = SizedBox(
    height: 128.0,
    child: Stack(
      fit: StackFit.expand,
      children: [
        Image.network(
          'https://pinkoi-wp-blog.s3.ap-southeast-1.amazonaws.com/wp-content/uploads/sites/6/2021/12/13155150/角落生物-1-1021x1024.webp',
          fit: BoxFit.fitWidth,
        ),
        Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Container(
              decoration: const BoxDecoration(
                color: Color.fromARGB(64, 0, 0, 0),
              ),
              child: const Padding(
                padding: EdgeInsets.all(8.0),
                child: Center(
                  child: Text(
                    'Title',
                    style: TextStyle(
                      fontSize: 24.0,
                      fontWeight: FontWeight.w900,
                      color: Colors.black87,
                    ),
                  ),
                ),
              ),
            ),
            Expanded(
              child: Container(
                decoration: const BoxDecoration(
                  color: Color.fromARGB(32, 0, 0, 0),
                ),
                child: const Padding(
                  padding: EdgeInsets.all(8.0),
                  child: Text('DEMO'),
                ),
              ),
            ),
          ],
        ),
      ],
    ),
  );
}

讀取JSON資料

準備Sample.json

  • 首先呢,先準備一下假資料…
{
    "result": [
      {
        "title": "角落小夥伴十週年",
        "content": "不經不覺,角落小夥伴已經快要誕生 10 週年了!角落小夥伴 Sumikko Gurashi(又名角落生物)是日本公司 San-X 於 2012 \n創立的卡通角色,描繪了一群喜歡躲藏在角落的小夥伴,每個角色都擁有自己的故事,軟萌樣子讓人憐愛。今天編輯就來帶大家逐一認識這群可愛的角落生物角色,拆解他們虜獲人心的秘密!",
        "imageUrl": "https://pinkoi-wp-blog.s3.ap-southeast-1.amazonaws.com/wp-content/uploads/sites/6/2021/12/13155150/角落生物-1-1021x1024.webp"
      },
      {
        "title": "角落生物是什麼?",
        "content": "早於 2012 年,設計師横溝友里為\n San-X 創立了喜歡躲藏在角落的小生物角色,並推出了首批商品,從此角落生物 Sumikko Gurashi 就誕生了!Sumikko \nGurashi(すみっコぐらし)有「生活在角落」的意思,中文翻譯成「角落生物」或「角落小夥伴」,是一群擬人化的動物及物品(像白熊、炸豬扒、雜草等等)。",
        "imageUrl": "https://i.pinimg.com/originals/93/a8/3b/93a83b7f915defcdcdff15f44d7250f9.jpg"
      },
      {
        "title": "角落生物爆紅原因?",
        "content": "2015 年之時,角落生物僅誕生四年已穩佔 San-X 整體營收的三成(若 50 億日元),2019 年更突破 200 億日元營收,獲得「日本虛擬角色大賞2019」最具人氣獎。小學生、中學生、成年人都在為角落生物瘋狂,到底角落生物為何如此成功?",
        "imageUrl": "https://cdn.hk01.com/di/media/images/dw/20201119/406075579901677568790862.jpeg/ZJhlgTJH9Z012VjiiPqKQtjiI5wfx9MZ7id3_e4nd_0"
      },
      {
        "title": "角落角落生物角色介紹",
        "content": "以下就來看看角落生物重點角色介紹吧!(資料來源:San-X、《角落小夥伴大圖鑑》、《角落生物的生活-這裏讓人好安心》、《角落小夥伴的生活-一直這樣就好》)",
        "imageUrl": "https://fupo.tw/webp/wp-content/uploads/2018/12/d4d5736a855e1c3c71716a901b60f3a2.jpg.webp"
      },
      {
        "title": "角落生物角色 #12 麻雀",
        "content": "麻雀是一隻普通的麻雀,對炸豬扒很感興趣,經常會偷偷啄走他頭上的麵衣。經常四處遊走,時飛時走。與貓頭鷹感情融洽,同時對裹布裏的東西很好奇。",
        "imageUrl": "https://img.4gamers.com.tw/news-image/4e0a86b6-ab5d-48ba-ab57-00d773b98cae.jpg"
      }
    ]
  }

資料的class

class Sample {
  String title;
  String content;
  String imageUrl;

  Sample({required this.title, required this.content, required this.imageUrl});

  factory Sample.fromJSON(Map<String, dynamic> json) {
    final record = Sample(
      title: json['title'],
      content: json['content'],
      imageUrl: json['imageUrl'],
    );

    return record;
  }

  static List<Sample> fromList(List<dynamic> jsonList) {
    List<Sample> list = [];

    for (var json in jsonList) {
      final sample = Sample.fromJSON(json);
      list.add(sample);
    }

    return list;
  }
}

讀取JSON文件

class _ProfilePageState extends State<ProfilePage> {

  ListView listViewMaker(int itemCount) {
    ListView listView = ListView.separated(
      itemCount: itemCount,
      itemBuilder: ((context, index) {
        return itemMaker(index);
      }),
      separatorBuilder: ((context, index) {
        return const Divider(
          height: 1,
          thickness: 2,
          color: Colors.blueGrey,
        );
      }),
    );

    return listView;
  }

  void downloadJSON(String assetsPath) {
    Utility.shared.readJSON(assetsPath: assetsPath).then((value) {
      final list = value['result'] as List<dynamic>;
      final sampleList = Sample.fromList(list);

      setState(() {
        _sampleList = sampleList;
      });
    });
  }
}

模擬網路下載資料

  • 一般來說,這種列表的資料一定是很多的,少說也有上百筆吧?所以不可能一次性的下載…
  • 這邊我們來模擬一下,上拉、下拉後,下載JSON資料,然後更新列表的功能…
  • 主要的Code都在這裡了,後面會分成一步步解說…
import 'package:flutter/material.dart';
import 'package:flutter_first_app/utility/widget/progressIndicator.dart';

import '/utility/model.dart';
import '/utility/utility.dart';

class ProfilePage extends StatefulWidget {
  const ProfilePage({Key? key, required this.title}) : super(key: key);
  final String title;
  @override
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> {
  final String _title = "萬能的滾動列表";
  final String _assetsPath = "./lib/assets/sample.json";
  final ScrollController _scrollController = ScrollController();

  bool isDownloading = false;
  List<Sample> _sampleList = [];

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

    downloadJSON(
      _assetsPath,
      action: (list) {
        setState(() {
          _sampleList.addAll(list);
        });
      },
    );

    _scrollController.addListener(() {
      final offset = _scrollController.offset;

      if (isDownloading) {
        return;
      }

      if (offset <= 0) {
        simulationReloadJSON();
      }

      if (offset >= _scrollController.position.maxScrollExtent) {
        simulationDownloadJSON();
      }
    });
  }

  @override
  void dispose() {
    super.dispose();
    _scrollController.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final bottomPadding = MediaQuery.of(context).padding.bottom;

    return Scaffold(
      appBar: AppBar(
        title: Text(
          _title,
          style: const TextStyle(
            color: Colors.black,
          ),
        ),
        centerTitle: true,
        backgroundColor: Colors.transparent,
        elevation: 0,
      ),
      extendBodyBehindAppBar: true,
      backgroundColor: Colors.amber.shade100,
      body: listViewBuilder(_sampleList.length),
      floatingActionButton: Padding(
        padding: EdgeInsets.only(bottom: bottomPadding),
        child: FloatingActionButton(
          onPressed: scrollToTop,
          tooltip: '回到第一個',
          child: const Icon(Icons.arrow_upward),
        ),
      ),
    );
  }

  ListView listViewBuilder(int itemCount) {
    Widget _itemMaker(int index) {
      final sample = _sampleList.elementAt(index);

      final widget = SizedBox(
        height: 200.0,
        child: Stack(
          fit: StackFit.expand,
          children: [
            Image.network(
              sample.imageUrl,
              fit: BoxFit.fitWidth,
            ),
            Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Container(
                  decoration: const BoxDecoration(
                    color: Color.fromARGB(96, 0, 0, 0),
                  ),
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Center(
                        child: Text(
                      '(${index + 1}) ${sample.title}',
                      style: const TextStyle(
                        fontSize: 24,
                        fontWeight: FontWeight.w900,
                        color: Colors.black87,
                      ),
                    )),
                  ),
                ),
                Expanded(
                  child: Container(
                    decoration: const BoxDecoration(
                      color: Color.fromARGB(64, 0, 0, 0),
                    ),
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Text(sample.content),
                    ),
                  ),
                ),
              ],
            ),
          ],
        ),
      );

      return widget;
    }

    Divider _dividerMaker(int index) {
      return const Divider(
        height: 1,
        thickness: 2,
        color: Colors.blueGrey,
      );
    }

    ListView _listViewMaker(int itemCount) {
      ListView listView = ListView.separated(
        itemCount: itemCount,
        itemBuilder: ((context, index) {
          return _itemMaker(index);
        }),
        controller: _scrollController,
        separatorBuilder: ((context, index) {
          return _dividerMaker(index);
        }),
      );

      return listView;
    }

    return _listViewMaker(itemCount);
  }

  void scrollToTop() {
    _scrollController.animateTo(
      0.1,
      duration: const Duration(milliseconds: 500),
      curve: Curves.fastOutSlowIn,
    );
  }

  void simulationReloadJSON() {
    WWProgressIndicator.shared.display(context);

    isDownloading = true;

    downloadJSON(
      _assetsPath,
      action: (list) {
        Future.delayed(const Duration(seconds: 3)).then((value) => {
              WWProgressIndicator.shared.dismiss(context),
              isDownloading = false,
              setState(() {
                _sampleList = list;
              }),
            });
      },
    );
  }

  void simulationDownloadJSON() {
    WWProgressIndicator.shared.display(context);

    isDownloading = true;

    downloadJSON(
      _assetsPath,
      action: (list) {
        Future.delayed(const Duration(seconds: 3)).then((value) => {
              WWProgressIndicator.shared.dismiss(context),
              isDownloading = false,
              setState(() {
                _sampleList.addAll(list);
              }),
            });
      },
    );
  }

  void downloadJSON(String assetsPath,
      {required Function(List<Sample>) action}) {
    Utility.shared.readJSON(assetsPath: assetsPath).then((value) {
      final list = value['result'] as List<dynamic>;
      final sampleList = Sample.fromList(list);

      action(sampleList);
    });
  }
}

滾動到底部後更新

  • 主要是靠ScrollController來處理滑動的位置訊息,進而判斷是不是到底部了…
  • 將ScrollController加上addListener()之後,可以試著log一下offset…
  • 主要的重點就是:
    1. offset <= 0:就是到最上方了
    2. offset >= _scrollController.position.maxScrollExtent:就是到最下方了
  • 這裡要比較注意的是,iOS跟Android預設在處理滾動回饋的方式是不同的,iOS的offset是可以小於0的,所以要加上isDownloading做為開關,不然會一直更新…
  • 這一點就很Android / Web了,不像iOS的delegate
  • 最後記得,在結束後,ScrollController要dispose()掉…
class _ProfilePageState extends State<ProfilePage> {

  final ScrollController _scrollController = ScrollController();
  List<Sample> _sampleList = [];

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

    _scrollController.addListener(() {
      final offset = _scrollController.offset;

      if (isDownloading) {
        return;
      }

      if (offset <= 0) {
        simulationReloadJSON();
      }

      if (offset >= _scrollController.position.maxScrollExtent) {
        simulationDownloadJSON();
      }
    });
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }
}

自動回到第一個

  • 其實,這個也滿單純的,就設定ScrollController的offset就可以了…
  • 這裡因為呢,滾到0的時候會觸發更新JSON,所以『暫時』設定offset = 0.1…
  • 不過如果有像iOS一樣,是直接設定IndexPath的話,相信會更方便的…
class _ProfilePageState extends State<ProfilePage> {

 Widget bodyMaker(BuildContext context) {
    final bottomPadding = MediaQuery.of(context).padding.bottom;

    final bodyWidget = Scaffold(
      appBar: PreferredSize(
        preferredSize: AppBar().preferredSize,
        child: WWAppBar(
          title: widget.title,
          color: Colors.black,
          backgroundColor: Colors.transparent,
        ),
      ),
      extendBodyBehindAppBar: true,
      backgroundColor: Colors.amber.shade100,
      body: listViewBuilder(_sampleList.length),
      floatingActionButton: Padding(
        padding: EdgeInsets.only(bottom: bottomPadding),
        child: FloatingActionButton(
          onPressed: scrollToTop,
          tooltip: '回到第一個',
          child: const Icon(Icons.arrow_upward),
        ),
      ),
    );

    return bodyWidget;
  }

  void scrollToTop() {
    _scrollController.animateTo(
      0.1,
      duration: const Duration(milliseconds: 500),
      curve: Curves.fastOutSlowIn,
    );
  }
}

加上loading動畫

import 'package:flutter/material.dart';

class WWProgressIndicator extends StatefulWidget {
  const WWProgressIndicator({Key? key}) : super(key: key);

  static const shared = WWProgressIndicator();

  @override
  State<WWProgressIndicator> createState() => _WWProgressIndicatorState();

  display(BuildContext context) {
    Navigator.of(context).push(
      PageRouteBuilder(
        opaque: false,
        pageBuilder: (BuildContext context, _, __) => this,
      ),
    );
  }

  void dismiss(BuildContext context) {
    if (!Navigator.canPop(context)) {
      return;
    }
    Navigator.of(context).pop();
  }
}

class _WWProgressIndicatorState extends State<WWProgressIndicator> {
  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black.withAlpha(64),
      body: const Center(
        child: SizedBox(
          width: 128,
          height: 128,
          child: CircularProgressIndicator(
            valueColor: AlwaysStoppedAnimation(Colors.blue),
          ),
        ),
      ),
    );
  }
}

點擊換頁

class _ProfilePageState extends State<ProfilePage> {

  void itemOnTap(int index) {
    final sample = _sampleList.elementAt(index);
    Navigator.push(context, MaterialPageRoute(builder: (context) => ProfileDetailPage(sample: sample)));
  }
}

開啟網頁

  • 這裡我們想要有點了圖片,然後在網頁開啟該Url
  • 因為一般圖片點擊下去是沒有反應的,只要加上InkWell,就可以給任意Widget增加點選事件…
  • 如果只是一般點一下的功能,只要用到GestureDetector就可以了…
  • 這裡我們刻意加上onDoubleTap的事件,安裝套件url_launcher
  • 其實url_launcher,也能利用Scheme,打電話、開第三方APP…
  • 我們可以使用指令安裝,或者是在pubspec.yaml上新增都是可以的…
flutter pub add url_launcher
flutter pub get
import 'dart:developer';

import 'package:flutter/material.dart';
import 'package:flutter_first_app/utility/model.dart';
import 'package:flutter_first_app/utility/utility.dart';
import 'package:flutter_first_app/utility/widget/appBar.dart';
import 'package:url_launcher/url_launcher_string.dart';

class ProfileDetailPage extends StatefulWidget {
  final Sample sample;

  const ProfileDetailPage({Key? key, required this.sample}) : super(key: key);

  @override
  State<ProfileDetailPage> createState() => _ProfileDetailPageState();
}

class _ProfileDetailPageState extends State<ProfileDetailPage> {
  late Sample _sample;

  @override
  void initState() {
    super.initState();
    _sample = widget.sample;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: PreferredSize(
          preferredSize: AppBar().preferredSize,
          child: WWAppBar(
            title: _sample.title,
            backgroundColor: Colors.greenAccent,
          ),
        ),
        body: Column(
          children: [
            InkWell(
              child: Utility.shared.webImage(
                _sample.imageUrl,
              ),
              onDoubleTap: () {
                gotoUrl(_sample.imageUrl);
              },
            ),
            Expanded(
              child: SingleChildScrollView(
                scrollDirection: Axis.vertical,
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Text(
                    _sample.content,
                    style: const TextStyle(
                      fontSize: 32,
                    ),
                  ),
                ),
              ),
            ),
          ],
        ));
  }

  Future<bool> gotoUrl(String url) async {
    String urlString = Uri.encodeFull(url);
    return await launchUrlString(urlString);
  }
}

非同步 / 異步

搜尋功能

切換TitleBar

  • 一般這有兩種顯示的方法:
    1. 點擊後去開新的一頁,在該頁上顯示搜尋列,像蝦皮APP / Google的APP都是這一類的,好像大多數的商城類APP都是這樣設計的…
    2. 另外就是,直接在該頁顯示搜尋列,這邊要做的就是這一種…
  • 利用切換AppBar(title:)的方式去處理,動態切換上面的Widget…
  • 簡單來說,就是點下去,然後更新Title的Widget…
  • 這是自己想的方法,所以…不保固…XD

class _WWAppBarState extends State<WWAppBar> {

@override
  Widget build(BuildContext context) {
    final appBar = AppBar(
      title: appBarTitleBar,
      titleSpacing: 0,
      centerTitle: true,
      backgroundColor: widget.backgroundColor,
      elevation: 0,
      actions: [
        IconButton(
          icon: searchIcon,
          color: Colors.blue,
          onPressed: () {
            setState(() {
              toggleTitleBar();
            });
          },
        ),
      ],
    );

    return appBar;
  }

  void toggleTitleBar() {
    if (!isSearchBar) {
      isSearchBar = true;
      searchIcon = const Icon(Icons.cancel);
      appBarTitleBar = searchBar();
      return;
    }

    isSearchBar = false;
    searchIcon = const Icon(Icons.search);
    appBarTitleBar = titleBar();
  }
}

實做搜尋功能

  • 主要是在SearchBar上,留了兩個Callback Function變數,讓利用它的元件去自訂它的功能…
  • 這就像是前面的onTap()一樣的功能…
  • 至於實作呢,目前就只是簡單的文字比對而已…
import 'package:flutter/material.dart';

class WWSearchBar extends StatefulWidget {
  final String title;
  final Color? color;
  final Color? backgroundColor;
  final Function(String) searchAction;
  final Function(bool) toggleAction;

  const WWSearchBar({
    Key? key,
    required this.title,
    required this.searchAction,
    required this.toggleAction,
    this.color,
    this.backgroundColor,
  }) : super(key: key);

  @override
  State<WWSearchBar> createState() => _WWSearchBarState();
}

  Widget bodyMaker(BuildContext context) {
    final bottomPadding = MediaQuery.of(context).padding.bottom;

    final bodyWidget = Scaffold(
      appBar: PreferredSize(
        preferredSize: AppBar().preferredSize,
        child: WWSearchBar(
          title: widget.title,
          color: Colors.black,
          backgroundColor: Colors.white,
          searchAction: (value) {
            List<Sample> list = [];

            for (var sample in _sampleList) {
              if (sample.title.contains(value.toLowerCase())) {
                list.add(sample);
              }
            }

            setState(() {
              _sampleList = list.toList();
            });
          },
          toggleAction: (isSearchBar) {
            if (!isSearchBar) {
              setState(() {
                _sampleList = _fullSampleList.toList();
              });
            } else {
              _fullSampleList = _sampleList.toList();
            }
          },
        ),
      ),
      extendBodyBehindAppBar: true,
      backgroundColor: Colors.amber.shade100,
      body: listViewBuilder(_sampleList.length),
      floatingActionButton: Padding(
        padding: EdgeInsets.only(bottom: bottomPadding),
        child: FloatingActionButton(
          onPressed: scrollToTop,
          tooltip: '回到第一個',
          child: const Icon(Icons.arrow_upward),
        ),
      ),
    );

    return bodyWidget;
  }

範例程式碼下載

後記