Flutter實戰(zhàn) APP入門及主頁

2021-03-09 15:14 更新

本節(jié)來介紹一下 APP 入口及首頁。

#15.6.1 APP入口

main函數(shù)為 APP 入口函數(shù),實現(xiàn)如下:

  1. void main() => Global.init().then((e) => runApp(MyApp()));

初始化完成后才會加載 UI(MyApp),MyApp 是應(yīng)用的入口 Widget,實現(xiàn)如下:

  1. class MyApp extends StatelessWidget {
  2. // This widget is the root of your application.
  3. @override
  4. Widget build(BuildContext context) {
  5. return MultiProvider(
  6. providers: <SingleChildCloneableWidget>[
  7. ChangeNotifierProvider.value(value: ThemeModel()),
  8. ChangeNotifierProvider.value(value: UserModel()),
  9. ChangeNotifierProvider.value(value: LocaleModel()),
  10. ],
  11. child: Consumer2<ThemeModel, LocaleModel>(
  12. builder: (BuildContext context, themeModel, localeModel, Widget child) {
  13. return MaterialApp(
  14. theme: ThemeData(
  15. primarySwatch: themeModel.theme,
  16. ),
  17. onGenerateTitle: (context){
  18. return GmLocalizations.of(context).title;
  19. },
  20. home: HomeRoute(), //應(yīng)用主頁
  21. locale: localeModel.getLocale(),
  22. //我們只支持美國英語和中文簡體
  23. supportedLocales: [
  24. const Locale('en', 'US'), // 美國英語
  25. const Locale('zh', 'CN'), // 中文簡體
  26. //其它Locales
  27. ],
  28. localizationsDelegates: [
  29. // 本地化的代理類
  30. GlobalMaterialLocalizations.delegate,
  31. GlobalWidgetsLocalizations.delegate,
  32. GmLocalizationsDelegate()
  33. ],
  34. localeResolutionCallback:
  35. (Locale _locale, Iterable<Locale> supportedLocales) {
  36. if (localeModel.getLocale() != null) {
  37. //如果已經(jīng)選定語言,則不跟隨系統(tǒng)
  38. return localeModel.getLocale();
  39. } else {
  40. Locale locale;
  41. //APP語言跟隨系統(tǒng)語言,如果系統(tǒng)語言不是中文簡體或美國英語,
  42. //則默認使用美國英語
  43. if (supportedLocales.contains(_locale)) {
  44. locale= _locale;
  45. } else {
  46. locale= Locale('en', 'US');
  47. }
  48. return locale;
  49. }
  50. },
  51. // 注冊命名路由表
  52. routes: <String, WidgetBuilder>{
  53. "login": (context) => LoginRoute(),
  54. "themes": (context) => ThemeChangeRoute(),
  55. "language": (context) => LanguageRoute(),
  56. },
  57. );
  58. },
  59. ),
  60. );
  61. }
  62. }

在上面的代碼中:

  1. 我們的根 widget 是MultiProvider,它將主題、用戶、語言三種狀態(tài)綁定到了應(yīng)用的根上,如此一來,任何路由中都可以通過Provider.of()來獲取這些狀態(tài),也就是說這三種狀態(tài)是全局共享的!
  2. HomeRoute是應(yīng)用的主頁。
  3. 在構(gòu)建MaterialApp時,我們配置了 APP 支持的語言列表,以及監(jiān)聽了系統(tǒng)語言改變事件;另外MaterialApp消費(依賴)了ThemeModelLocaleModel,所以當 APP 主題或語言改變時MaterialApp會重新構(gòu)建
  4. 我們注冊了命名路由表,以便在 APP 中可以直接通過路由名跳轉(zhuǎn)。
  5. 為了支持多語言(本 APP 中我們支持美國英語和中文簡體兩種語言)我們實現(xiàn)了一個GmLocalizationsDelegate,子 Widget 中都可以通過GmLocalizations來動態(tài)獲取 APP 當前語言對應(yīng)的文案。關(guān)于GmLocalizationsDelegateGmLocalizations的實現(xiàn)方式讀者可以參考“國際化”一章中的介紹,此處不再贅述。

#15.6.2 主頁

為了簡單起見,當 APP 啟動后,如果之前已登錄了 APP,則顯示該用戶項目列表;如果之前未登錄,則顯示一個登錄按鈕,點擊后跳轉(zhuǎn)到登錄頁。另外,我們實現(xiàn)一個抽屜菜單,里面包含當前用戶頭像及 APP 的菜單。下面我們先看看要實現(xiàn)的效果,如圖15-1、15-2所示:

15-115-2

我們在“l(fā)ib/routes”下創(chuàng)建一個“home_page.dart”文件,實現(xiàn)如下:

  1. class HomeRoute extends StatefulWidget {
  2. @override
  3. _HomeRouteState createState() => _HomeRouteState();
  4. }
  5. class _HomeRouteState extends State<HomeRoute> {
  6. @override
  7. Widget build(BuildContext context) {
  8. return Scaffold(
  9. appBar: AppBar(
  10. title: Text(GmLocalizations.of(context).home),
  11. ),
  12. body: _buildBody(), // 構(gòu)建主頁面
  13. drawer: MyDrawer(), //抽屜菜單
  14. );
  15. }
  16. ...// 省略
  17. }

上面代碼中,主頁的標題(title)我們是通過GmLocalizations.of(context).home來獲得,GmLocalizations是我們提供的一個Localizations類,用于支持多語言,因此當 APP 語言改變時,凡是使用GmLocalizations動態(tài)獲取的文案都會是相應(yīng)語言的文案,這在前面“國際化”一章中已經(jīng)介紹過,讀者可以前翻查閱。

我們通過 _buildBody()方法來構(gòu)建主頁內(nèi)容,_buildBody()方法實現(xiàn)代碼如下:

  1. Widget _buildBody() {
  2. UserModel userModel = Provider.of<UserModel>(context);
  3. if (!userModel.isLogin) {
  4. //用戶未登錄,顯示登錄按鈕
  5. return Center(
  6. child: RaisedButton(
  7. child: Text(GmLocalizations.of(context).login),
  8. onPressed: () => Navigator.of(context).pushNamed("login"),
  9. ),
  10. );
  11. } else {
  12. //已登錄,則展示項目列表
  13. return InfiniteListView<Repo>(
  14. onRetrieveData: (int page, List<Repo> items, bool refresh) async {
  15. var data = await Git(context).getRepos(
  16. refresh: refresh,
  17. queryParameters: {
  18. 'page': page,
  19. 'page_size': 20,
  20. },
  21. );
  22. //把請求到的新數(shù)據(jù)添加到items中
  23. items.addAll(data);
  24. // 如果接口返回的數(shù)量等于'page_size',則認為還有數(shù)據(jù),反之則認為最后一頁
  25. return data.length==20;
  26. },
  27. itemBuilder: (List list, int index, BuildContext ctx) {
  28. // 項目信息列表項
  29. return RepoItem(list[index]);
  30. },
  31. );
  32. }
  33. }
  34. }

上面代碼注釋很清楚:如果用戶未登錄,顯示登錄按鈕;如果用戶已登錄,則展示項目列表。這里項目列表使用了InfiniteListView Widget,它是 flukit package 中提供的。InfiniteListView同時支持了下拉刷新和上拉加載更多兩種功能。onRetrieveData 為數(shù)據(jù)獲取回調(diào),該回調(diào)函數(shù)接收三個參數(shù):

參數(shù)名 類型 解釋
page int 當前頁號
items List 保存當前列表數(shù)據(jù)的List
refresh bool 是否是下拉刷新觸發(fā)

返回值類型為bool,為true時表示還有數(shù)據(jù),為false時則表示后續(xù)沒有數(shù)據(jù)了。onRetrieveData 回調(diào)中我們調(diào)用Git(context).getRepos(...)來獲取用戶項目列表,同時指定每次請求獲取20條。當獲取成功時,首先要將新獲取的項目數(shù)據(jù)添加到items中,然后根據(jù)本次請求的項目條數(shù)是否等于期望的20條來判斷還有沒有更多的數(shù)據(jù)。在此需要注意,Git(context).getRepos(…)方法中需要refresh參數(shù)來判斷是否使用緩存。

itemBuilder為列表項的 builder,我們需要在該回調(diào)中構(gòu)建每一個列表項 Widget。由于列表項構(gòu)建邏輯較復(fù)雜,我們單獨封裝一個RepoItem Widget 專門用于構(gòu)建列表項 UI。RepoItem 實現(xiàn)如下:

  1. import '../index.dart';
  2. class RepoItem extends StatefulWidget {
  3. // 將`repo.id`作為RepoItem的默認key
  4. RepoItem(this.repo) : super(key: ValueKey(repo.id));
  5. final Repo repo;
  6. @override
  7. _RepoItemState createState() => _RepoItemState();
  8. }
  9. class _RepoItemState extends State<RepoItem> {
  10. @override
  11. Widget build(BuildContext context) {
  12. var subtitle;
  13. return Padding(
  14. padding: const EdgeInsets.only(top: 8.0),
  15. child: Material(
  16. color: Colors.white,
  17. shape: BorderDirectional(
  18. bottom: BorderSide(
  19. color: Theme.of(context).dividerColor,
  20. width: .5,
  21. ),
  22. ),
  23. child: Padding(
  24. padding: const EdgeInsets.only(top: 0.0, bottom: 16),
  25. child: Column(
  26. crossAxisAlignment: CrossAxisAlignment.start,
  27. children: <Widget>[
  28. ListTile(
  29. dense: true,
  30. leading: gmAvatar(
  31. //項目owner頭像
  32. widget.repo.owner.avatar_url,
  33. width: 24.0,
  34. borderRadius: BorderRadius.circular(12),
  35. ),
  36. title: Text(
  37. widget.repo.owner.login,
  38. textScaleFactor: .9,
  39. ),
  40. subtitle: subtitle,
  41. trailing: Text(widget.repo.language ?? ""),
  42. ),
  43. // 構(gòu)建項目標題和簡介
  44. Padding(
  45. padding: const EdgeInsets.symmetric(horizontal: 16.0),
  46. child: Column(
  47. crossAxisAlignment: CrossAxisAlignment.start,
  48. children: <Widget>[
  49. Text(
  50. widget.repo.fork
  51. ? widget.repo.full_name
  52. : widget.repo.name,
  53. style: TextStyle(
  54. fontSize: 15,
  55. fontWeight: FontWeight.bold,
  56. fontStyle: widget.repo.fork
  57. ? FontStyle.italic
  58. : FontStyle.normal,
  59. ),
  60. ),
  61. Padding(
  62. padding: const EdgeInsets.only(top: 8, bottom: 12),
  63. child: widget.repo.description == null
  64. ? Text(
  65. GmLocalizations.of(context).noDescription,
  66. style: TextStyle(
  67. fontStyle: FontStyle.italic,
  68. color: Colors.grey[700]),
  69. )
  70. : Text(
  71. widget.repo.description,
  72. maxLines: 3,
  73. style: TextStyle(
  74. height: 1.15,
  75. color: Colors.blueGrey[700],
  76. fontSize: 13,
  77. ),
  78. ),
  79. ),
  80. ],
  81. ),
  82. ),
  83. // 構(gòu)建卡片底部信息
  84. _buildBottom()
  85. ],
  86. ),
  87. ),
  88. ),
  89. );
  90. }
  91. // 構(gòu)建卡片底部信息
  92. Widget _buildBottom() {
  93. const paddingWidth = 10;
  94. return IconTheme(
  95. data: IconThemeData(
  96. color: Colors.grey,
  97. size: 15,
  98. ),
  99. child: DefaultTextStyle(
  100. style: TextStyle(color: Colors.grey, fontSize: 12),
  101. child: Padding(
  102. padding: const EdgeInsets.symmetric(horizontal: 16),
  103. child: Builder(builder: (context) {
  104. var children = <Widget>[
  105. Icon(Icons.star),
  106. Text(" " +
  107. widget.repo.stargazers_count
  108. .toString()
  109. .padRight(paddingWidth)),
  110. Icon(Icons.info_outline),
  111. Text(" " +
  112. widget.repo.open_issues_count
  113. .toString()
  114. .padRight(paddingWidth)),
  115. Icon(MyIcons.fork), //我們的自定義圖標
  116. Text(widget.repo.forks_count.toString().padRight(paddingWidth)),
  117. ];
  118. if (widget.repo.fork) {
  119. children.add(Text("Forked".padRight(paddingWidth)));
  120. }
  121. if (widget.repo.private == true) {
  122. children.addAll(<Widget>[
  123. Icon(Icons.lock),
  124. Text(" private".padRight(paddingWidth))
  125. ]);
  126. }
  127. return Row(children: children);
  128. }),
  129. ),
  130. ),
  131. );
  132. }
  133. }

上面代碼有兩點需要注意:

  1. 在構(gòu)建項目擁有者頭像時調(diào)用了gmAvatar(…)方法,該方法是是一個全局工具函數(shù),專門用于獲取頭像圖片,實現(xiàn)如下:

  1. Widget gmAvatar(String url, {
  2. double width = 30,
  3. double height,
  4. BoxFit fit,
  5. BorderRadius borderRadius,
  6. }) {
  7. var placeholder = Image.asset(
  8. "imgs/avatar-default.png", //頭像占位圖,加載過程中顯示
  9. width: width,
  10. height: height
  11. );
  12. return ClipRRect(
  13. borderRadius: borderRadius ?? BorderRadius.circular(2),
  14. child: CachedNetworkImage(
  15. imageUrl: url,
  16. width: width,
  17. height: height,
  18. fit: fit,
  19. placeholder: (context, url) =>placeholder,
  20. errorWidget: (context, url, error) =>placeholder,
  21. ),
  22. );
  23. }

代碼中調(diào)用了CachedNetworkImage 是 cached_network_image 包中提供的一個 Widget,它不僅可以在圖片加載過程中指定一個占位圖,而且還可以對網(wǎng)絡(luò)請求的圖片進行緩存,更多詳情讀者可以自行查閱其文檔。

  1. 由于 Flutter 的 Material 圖標庫中沒有 fork 圖標,所以我們在 iconfont.cn 上找了一個 fork 圖標,然后根據(jù)“圖片和 Icon”一節(jié)中介紹的使用自定義字體圖標的方法集成到了我們的項目中。

#15.6.3 抽屜菜單

抽屜菜單分為兩部分:頂部頭像和底部功能菜單項。當用戶未登錄,則抽屜菜單頂部會顯示一個默認的灰色占位圖,若用戶已登錄,則會顯示用戶的頭像。抽屜菜單底部有“換膚”和“語言”兩個固定菜單,若用戶已登錄,則會多一個“注銷”菜單。用戶點擊“換膚”和“語言”兩個菜單項,會進入相應(yīng)的設(shè)置頁面。我們的抽屜菜單效果如圖15-3、15-4所示:

15-315-4

實現(xiàn)代碼如下:

  1. class MyDrawer extends StatelessWidget {
  2. const MyDrawer({
  3. Key key,
  4. }) : super(key: key);
  5. @override
  6. Widget build(BuildContext context) {
  7. return Drawer(
  8. //移除頂部padding
  9. child: MediaQuery.removePadding(
  10. context: context,
  11. removeTop: true,
  12. child: Column(
  13. crossAxisAlignment: CrossAxisAlignment.start,
  14. children: <Widget>[
  15. _buildHeader(), //構(gòu)建抽屜菜單頭部
  16. Expanded(child: _buildMenus()), //構(gòu)建功能菜單
  17. ],
  18. ),
  19. ),
  20. );
  21. }
  22. Widget _buildHeader() {
  23. return Consumer<UserModel>(
  24. builder: (BuildContext context, UserModel value, Widget child) {
  25. return GestureDetector(
  26. child: Container(
  27. color: Theme.of(context).primaryColor,
  28. padding: EdgeInsets.only(top: 40, bottom: 20),
  29. child: Row(
  30. children: <Widget>[
  31. Padding(
  32. padding: const EdgeInsets.symmetric(horizontal: 16.0),
  33. child: ClipOval(
  34. // 如果已登錄,則顯示用戶頭像;若未登錄,則顯示默認頭像
  35. child: value.isLogin
  36. ? gmAvatar(value.user.avatar_url, width: 80)
  37. : Image.asset(
  38. "imgs/avatar-default.png",
  39. width: 80,
  40. ),
  41. ),
  42. ),
  43. Text(
  44. value.isLogin
  45. ? value.user.login
  46. : GmLocalizations.of(context).login,
  47. style: TextStyle(
  48. fontWeight: FontWeight.bold,
  49. color: Colors.white,
  50. ),
  51. )
  52. ],
  53. ),
  54. ),
  55. onTap: () {
  56. if (!value.isLogin) Navigator.of(context).pushNamed("login");
  57. },
  58. );
  59. },
  60. );
  61. }
  62. // 構(gòu)建菜單項
  63. Widget _buildMenus() {
  64. return Consumer<UserModel>(
  65. builder: (BuildContext context, UserModel userModel, Widget child) {
  66. var gm = GmLocalizations.of(context);
  67. return ListView(
  68. children: <Widget>[
  69. ListTile(
  70. leading: const Icon(Icons.color_lens),
  71. title: Text(gm.theme),
  72. onTap: () => Navigator.pushNamed(context, "themes"),
  73. ),
  74. ListTile(
  75. leading: const Icon(Icons.language),
  76. title: Text(gm.language),
  77. onTap: () => Navigator.pushNamed(context, "language"),
  78. ),
  79. if(userModel.isLogin) ListTile(
  80. leading: const Icon(Icons.power_settings_new),
  81. title: Text(gm.logout),
  82. onTap: () {
  83. showDialog(
  84. context: context,
  85. builder: (ctx) {
  86. //退出賬號前先彈二次確認窗
  87. return AlertDialog(
  88. content: Text(gm.logoutTip),
  89. actions: <Widget>[
  90. FlatButton(
  91. child: Text(gm.cancel),
  92. onPressed: () => Navigator.pop(context),
  93. ),
  94. FlatButton(
  95. child: Text(gm.yes),
  96. onPressed: () {
  97. //該賦值語句會觸發(fā)MaterialApp rebuild
  98. userModel.user = null;
  99. Navigator.pop(context);
  100. },
  101. ),
  102. ],
  103. );
  104. },
  105. );
  106. },
  107. ),
  108. ],
  109. );
  110. },
  111. );
  112. }
  113. }

用戶點擊“注銷”,userModel.user 會被置空,此時所有依賴userModel的組件都會被rebuild,如主頁會恢復(fù)成未登錄的狀態(tài)。

本小節(jié)我們介紹了APP入口MaterialApp的一些配置,然后實現(xiàn)了 APP 的首頁。后面我們將展示登錄頁、換膚頁、語言切換頁。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號