【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的長相
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
 - 其中呢,factory關鍵字,就像是工廠方法,快速建立class用的,是一個static的方法…
 - 利用這個Sample.fromJSON()就可以建立一個class…
 - 然後再利用fromList()這個方法,來解JSON的Map,簡單來說…就是懶…
 
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文件
- 接著,再利用rootBundle.loadString()去讀取文件檔…
 - 然後使用JsonDecoder()去解JSON,變成MAP…
 - 最後,去更新ListView的相關資料,其實ListView很像iOS的UITableView…
 - 細節的Code就不列出來了,可以自行下載git看看…
 
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…
 - 主要的重點就是:
- offset <= 0:就是到最上方了
 - 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動畫
- 最後呢,再加上一個轉圈圈,就看起來更有feel了啦…
 - 這種提示型的功能,一般統稱做HUD (Head-Up Display),就是抬頭顯示器…
 - 這裡是利用CircularProgressIndicator + Navigator.of(context).push(),就是利用開啟下一頁的功能,只要讓Widget背景是透明的,看起來就很像了…
 - 這招也是從iOS上學來的,沒想到還真的可以用啊…
 
    
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),
          ),
        ),
      ),
    );
  }
}
點擊換頁
- 在這裡,利用Navigator來切換至下一頁…
 - 也利用ProfileDetailPage的Constructor - 建構子,把選中的值帶過去…
 
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);
  }
}
非同步 / 異步
- 大家可以看到有Future - 未來這個關鍵字…
 - 字如其義,就是要等一下才會有反應的東西,也就是非同步 / 異步的功能…
 - 所以它會伴隨著async / await這兩個關鍵字,也就是像js的Promise功能…
 - 在Swift 5.5中,也增加了async / await這兩個新特性…
 
搜尋功能
切換TitleBar
- 一般這有兩種顯示的方法:
- 點擊後去開新的一頁,在該頁上顯示搜尋列,像蝦皮APP / Google的APP都是這一類的,好像大多數的商城類APP都是這樣設計的…
 - 另外就是,直接在該頁顯示搜尋列,這邊要做的就是這一種…
 
 - 利用切換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;
  }