本節(jié)來介紹一下 APP 入口及首頁。
main
函數(shù)為 APP 入口函數(shù),實現(xiàn)如下:
void main() => Global.init().then((e) => runApp(MyApp()));
初始化完成后才會加載 UI(MyApp
),MyApp
是應(yīng)用的入口 Widget,實現(xiàn)如下:
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: <SingleChildCloneableWidget>[
ChangeNotifierProvider.value(value: ThemeModel()),
ChangeNotifierProvider.value(value: UserModel()),
ChangeNotifierProvider.value(value: LocaleModel()),
],
child: Consumer2<ThemeModel, LocaleModel>(
builder: (BuildContext context, themeModel, localeModel, Widget child) {
return MaterialApp(
theme: ThemeData(
primarySwatch: themeModel.theme,
),
onGenerateTitle: (context){
return GmLocalizations.of(context).title;
},
home: HomeRoute(), //應(yīng)用主頁
locale: localeModel.getLocale(),
//我們只支持美國英語和中文簡體
supportedLocales: [
const Locale('en', 'US'), // 美國英語
const Locale('zh', 'CN'), // 中文簡體
//其它Locales
],
localizationsDelegates: [
// 本地化的代理類
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GmLocalizationsDelegate()
],
localeResolutionCallback:
(Locale _locale, Iterable<Locale> supportedLocales) {
if (localeModel.getLocale() != null) {
//如果已經(jīng)選定語言,則不跟隨系統(tǒng)
return localeModel.getLocale();
} else {
Locale locale;
//APP語言跟隨系統(tǒng)語言,如果系統(tǒng)語言不是中文簡體或美國英語,
//則默認使用美國英語
if (supportedLocales.contains(_locale)) {
locale= _locale;
} else {
locale= Locale('en', 'US');
}
return locale;
}
},
// 注冊命名路由表
routes: <String, WidgetBuilder>{
"login": (context) => LoginRoute(),
"themes": (context) => ThemeChangeRoute(),
"language": (context) => LanguageRoute(),
},
);
},
),
);
}
}
在上面的代碼中:
MultiProvider
,它將主題、用戶、語言三種狀態(tài)綁定到了應(yīng)用的根上,如此一來,任何路由中都可以通過Provider.of()
來獲取這些狀態(tài),也就是說這三種狀態(tài)是全局共享的!HomeRoute
是應(yīng)用的主頁。MaterialApp
時,我們配置了 APP 支持的語言列表,以及監(jiān)聽了系統(tǒng)語言改變事件;另外MaterialApp
消費(依賴)了ThemeModel
和LocaleModel
,所以當 APP 主題或語言改變時MaterialApp
會重新構(gòu)建GmLocalizationsDelegate
,子 Widget 中都可以通過GmLocalizations
來動態(tài)獲取 APP 當前語言對應(yīng)的文案。關(guān)于GmLocalizationsDelegate
和GmLocalizations
的實現(xiàn)方式讀者可以參考“國際化”一章中的介紹,此處不再贅述。為了簡單起見,當 APP 啟動后,如果之前已登錄了 APP,則顯示該用戶項目列表;如果之前未登錄,則顯示一個登錄按鈕,點擊后跳轉(zhuǎn)到登錄頁。另外,我們實現(xiàn)一個抽屜菜單,里面包含當前用戶頭像及 APP 的菜單。下面我們先看看要實現(xiàn)的效果,如圖15-1、15-2所示:
我們在“l(fā)ib/routes”下創(chuàng)建一個“home_page.dart”文件,實現(xiàn)如下:
class HomeRoute extends StatefulWidget {
@override
_HomeRouteState createState() => _HomeRouteState();
}
class _HomeRouteState extends State<HomeRoute> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(GmLocalizations.of(context).home),
),
body: _buildBody(), // 構(gòu)建主頁面
drawer: MyDrawer(), //抽屜菜單
);
}
...// 省略
}
上面代碼中,主頁的標題(title)我們是通過GmLocalizations.of(context).home
來獲得,GmLocalizations
是我們提供的一個Localizations
類,用于支持多語言,因此當 APP 語言改變時,凡是使用GmLocalizations
動態(tài)獲取的文案都會是相應(yīng)語言的文案,這在前面“國際化”一章中已經(jīng)介紹過,讀者可以前翻查閱。
我們通過 _buildBody()
方法來構(gòu)建主頁內(nèi)容,_buildBody()
方法實現(xiàn)代碼如下:
Widget _buildBody() {
UserModel userModel = Provider.of<UserModel>(context);
if (!userModel.isLogin) {
//用戶未登錄,顯示登錄按鈕
return Center(
child: RaisedButton(
child: Text(GmLocalizations.of(context).login),
onPressed: () => Navigator.of(context).pushNamed("login"),
),
);
} else {
//已登錄,則展示項目列表
return InfiniteListView<Repo>(
onRetrieveData: (int page, List<Repo> items, bool refresh) async {
var data = await Git(context).getRepos(
refresh: refresh,
queryParameters: {
'page': page,
'page_size': 20,
},
);
//把請求到的新數(shù)據(jù)添加到items中
items.addAll(data);
// 如果接口返回的數(shù)量等于'page_size',則認為還有數(shù)據(jù),反之則認為最后一頁
return data.length==20;
},
itemBuilder: (List list, int index, BuildContext ctx) {
// 項目信息列表項
return RepoItem(list[index]);
},
);
}
}
}
上面代碼注釋很清楚:如果用戶未登錄,顯示登錄按鈕;如果用戶已登錄,則展示項目列表。這里項目列表使用了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)建邏輯較復雜,我們單獨封裝一個RepoItem
Widget 專門用于構(gòu)建列表項 UI。RepoItem
實現(xiàn)如下:
import '../index.dart';
class RepoItem extends StatefulWidget {
// 將`repo.id`作為RepoItem的默認key
RepoItem(this.repo) : super(key: ValueKey(repo.id));
final Repo repo;
@override
_RepoItemState createState() => _RepoItemState();
}
class _RepoItemState extends State<RepoItem> {
@override
Widget build(BuildContext context) {
var subtitle;
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Material(
color: Colors.white,
shape: BorderDirectional(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: .5,
),
),
child: Padding(
padding: const EdgeInsets.only(top: 0.0, bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ListTile(
dense: true,
leading: gmAvatar(
//項目owner頭像
widget.repo.owner.avatar_url,
width: 24.0,
borderRadius: BorderRadius.circular(12),
),
title: Text(
widget.repo.owner.login,
textScaleFactor: .9,
),
subtitle: subtitle,
trailing: Text(widget.repo.language ?? ""),
),
// 構(gòu)建項目標題和簡介
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.repo.fork
? widget.repo.full_name
: widget.repo.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
fontStyle: widget.repo.fork
? FontStyle.italic
: FontStyle.normal,
),
),
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 12),
child: widget.repo.description == null
? Text(
GmLocalizations.of(context).noDescription,
style: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.grey[700]),
)
: Text(
widget.repo.description,
maxLines: 3,
style: TextStyle(
height: 1.15,
color: Colors.blueGrey[700],
fontSize: 13,
),
),
),
],
),
),
// 構(gòu)建卡片底部信息
_buildBottom()
],
),
),
),
);
}
// 構(gòu)建卡片底部信息
Widget _buildBottom() {
const paddingWidth = 10;
return IconTheme(
data: IconThemeData(
color: Colors.grey,
size: 15,
),
child: DefaultTextStyle(
style: TextStyle(color: Colors.grey, fontSize: 12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Builder(builder: (context) {
var children = <Widget>[
Icon(Icons.star),
Text(" " +
widget.repo.stargazers_count
.toString()
.padRight(paddingWidth)),
Icon(Icons.info_outline),
Text(" " +
widget.repo.open_issues_count
.toString()
.padRight(paddingWidth)),
Icon(MyIcons.fork), //我們的自定義圖標
Text(widget.repo.forks_count.toString().padRight(paddingWidth)),
];
if (widget.repo.fork) {
children.add(Text("Forked".padRight(paddingWidth)));
}
if (widget.repo.private == true) {
children.addAll(<Widget>[
Icon(Icons.lock),
Text(" private".padRight(paddingWidth))
]);
}
return Row(children: children);
}),
),
),
);
}
}
上面代碼有兩點需要注意:
gmAvatar(…)
方法,該方法是是一個全局工具函數(shù),專門用于獲取頭像圖片,實現(xiàn)如下: Widget gmAvatar(String url, {
double width = 30,
double height,
BoxFit fit,
BorderRadius borderRadius,
}) {
var placeholder = Image.asset(
"imgs/avatar-default.png", //頭像占位圖,加載過程中顯示
width: width,
height: height
);
return ClipRRect(
borderRadius: borderRadius ?? BorderRadius.circular(2),
child: CachedNetworkImage(
imageUrl: url,
width: width,
height: height,
fit: fit,
placeholder: (context, url) =>placeholder,
errorWidget: (context, url, error) =>placeholder,
),
);
}
代碼中調(diào)用了CachedNetworkImage
是 cached_network_image 包中提供的一個 Widget,它不僅可以在圖片加載過程中指定一個占位圖,而且還可以對網(wǎng)絡(luò)請求的圖片進行緩存,更多詳情讀者可以自行查閱其文檔。
抽屜菜單分為兩部分:頂部頭像和底部功能菜單項。當用戶未登錄,則抽屜菜單頂部會顯示一個默認的灰色占位圖,若用戶已登錄,則會顯示用戶的頭像。抽屜菜單底部有“換膚”和“語言”兩個固定菜單,若用戶已登錄,則會多一個“注銷”菜單。用戶點擊“換膚”和“語言”兩個菜單項,會進入相應(yīng)的設(shè)置頁面。我們的抽屜菜單效果如圖15-3、15-4所示:
實現(xiàn)代碼如下:
class MyDrawer extends StatelessWidget {
const MyDrawer({
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Drawer(
//移除頂部padding
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildHeader(), //構(gòu)建抽屜菜單頭部
Expanded(child: _buildMenus()), //構(gòu)建功能菜單
],
),
),
);
}
Widget _buildHeader() {
return Consumer<UserModel>(
builder: (BuildContext context, UserModel value, Widget child) {
return GestureDetector(
child: Container(
color: Theme.of(context).primaryColor,
padding: EdgeInsets.only(top: 40, bottom: 20),
child: Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ClipOval(
// 如果已登錄,則顯示用戶頭像;若未登錄,則顯示默認頭像
child: value.isLogin
? gmAvatar(value.user.avatar_url, width: 80)
: Image.asset(
"imgs/avatar-default.png",
width: 80,
),
),
),
Text(
value.isLogin
? value.user.login
: GmLocalizations.of(context).login,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
)
],
),
),
onTap: () {
if (!value.isLogin) Navigator.of(context).pushNamed("login");
},
);
},
);
}
// 構(gòu)建菜單項
Widget _buildMenus() {
return Consumer<UserModel>(
builder: (BuildContext context, UserModel userModel, Widget child) {
var gm = GmLocalizations.of(context);
return ListView(
children: <Widget>[
ListTile(
leading: const Icon(Icons.color_lens),
title: Text(gm.theme),
onTap: () => Navigator.pushNamed(context, "themes"),
),
ListTile(
leading: const Icon(Icons.language),
title: Text(gm.language),
onTap: () => Navigator.pushNamed(context, "language"),
),
if(userModel.isLogin) ListTile(
leading: const Icon(Icons.power_settings_new),
title: Text(gm.logout),
onTap: () {
showDialog(
context: context,
builder: (ctx) {
//退出賬號前先彈二次確認窗
return AlertDialog(
content: Text(gm.logoutTip),
actions: <Widget>[
FlatButton(
child: Text(gm.cancel),
onPressed: () => Navigator.pop(context),
),
FlatButton(
child: Text(gm.yes),
onPressed: () {
//該賦值語句會觸發(fā)MaterialApp rebuild
userModel.user = null;
Navigator.pop(context);
},
),
],
);
},
);
},
),
],
);
},
);
}
}
用戶點擊“注銷”,userModel.user
會被置空,此時所有依賴userModel
的組件都會被rebuild
,如主頁會恢復成未登錄的狀態(tài)。
本小節(jié)我們介紹了APP入口MaterialApp
的一些配置,然后實現(xiàn)了 APP 的首頁。后面我們將展示登錄頁、換膚頁、語言切換頁。
更多建議: