寫在前面
之前發了一篇文章——《用Flutter山寨一下掘金》,由於是自己學習Flutter時的練手專案,文中完全沒有寫過程,只將原始碼上傳到了GitHub。現受掘金邀請,將文章寫成入門教程,讓對Flutter感興趣的小夥伴都能看懂。
我把專案分成四個小節,按照改版後的掘金app重新寫成了教程,供大家一起學習和交流。由於是入門教程,文中內容不會很深入,對於已經學習過Flutter一段時間的小夥伴,略過就好啦。我是前端開發一枚,也是初學Flutter,文中出現的錯誤,還請小夥伴們指出。
現在開始今天的學習。
正式開始
一. 首先新建專案,名稱隨意
生成的專案結構如下:
在此專案中,我們的業務程式碼都在 lib
下,包配置都在 pubspec.yaml
中。
點選右上角的模擬器按鈕,選擇已經配置好的模擬器,再點選旁邊的綠色三角形,稍等片刻,當你在模擬器中看到下面的效果,恭喜,專案跑起來了:
Tip:
- Flutter的安裝和配置小夥伴們就自己完成吧,我使用的是Windows和intellij,參照的是ios版掘金app,小夥伴們後面看到模擬器不要笑啊,因為我買不起蘋果啊,哈哈!
- 上圖中的
screenshots
和articles
資料夾是我寫文章用的,小夥伴們不用看。
二、改造根元件
開啟 lib
中的 main.dart
檔案,會看到已經有一些程式碼,有註釋,小夥伴們可以閱讀一下(截圖有點長,貼程式碼有點多,小夥伴們就自己看了哈)。刪掉原有的程式碼,我們開始寫自己的程式碼:
import 'package:flutter/material.dart';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
theme: new ThemeData(
highlightColor: Colors.transparent, //將點選高亮色設為透明
splashColor: Colors.transparent, //將噴濺顏色設為透明
bottomAppBarColor: new Color.fromRGBO(244, 245, 245, 1.0), //設定底部導航的背景色
scaffoldBackgroundColor: new Color.fromRGBO(244, 245, 245, 1.0), //設定頁面背景顏色
primaryIconTheme: new IconThemeData(color: Colors.blue), //主要icon樣式,如頭部返回icon按鈕
indicatorColor: Colors.blue, //設定tab指示器顏色
iconTheme: new IconThemeData(size: 18.0), //設定icon樣式
primaryTextTheme: new TextTheme( //設定文字樣式
title: new TextStyle(color: Colors.black, fontSize: 16.0))),
);
}
}
複製程式碼
小夥伴們會發現和之前的程式碼有些不一樣,不用驚訝,寫法可以有很多種,以後就明白了。你要是現在點底部的 Hot Reload
或者 Hot Restart
會發現啥也沒有,當然啦,我們啥都還沒寫呢:
Tip:
- 頭部的
import
是引入我們需要用的包等東西,這裡引入了material.dart
,這是一個包含了大量material
風格的元件的包。 - Flutter中的
Widget
(元件)有兩類,StatelessWidget
是無狀態的,StatefulWidget
是有狀態的,當你的頁面會隨著狀態的改變發生變化時使用。兩者中必有build
方法,用於建立內容。 MaterialApp
是應用的根元件,這是實現了material
風格的WidgetsApp
,後面所有的頁面、元件都會在其中。theme
中是對元件做一些全域性配置。
小夥伴們一定要多看文件哈,雖然文件很多,但要是你不看,你可能會懵逼的,尤其是做前端開發的同志,dart是新語言,語法這些是必須要學習的,我不可能在文中逐行解釋哈,切記!
三、實現app介面結構
在 lib
資料夾下新建 pages
資料夾,用於存放我們的頁面。然後再 pages
資料夾下新建 index.dart
、 home.dart
、 discovery.dart
、 hot.dart
、 book.dart
、 mine.dart
,對應底部的每個tab,這是我們專案中主要會用到的檔案了。
在 index.dart
檔案中寫入如下內容:
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'home.dart';
import 'hot.dart';
import 'discovery.dart';
import 'book.dart';
import 'mine.dart';
class IndexPage extends StatefulWidget {
@override
createState() => new IndexPageState();
}
class IndexPageState extends State<IndexPage> {
// 定義底部導航列表
final List<BottomNavigationBarItem> bottomTabs = [
new BottomNavigationBarItem(
icon: new Icon(CupertinoIcons.home),
title: new Text('首頁'),
),
new BottomNavigationBarItem(
icon: new Icon(CupertinoIcons.conversation_bubble),
title: new Text('沸點')),
new BottomNavigationBarItem(
icon: new Icon(CupertinoIcons.search), title: new Text('發現')),
new BottomNavigationBarItem(
icon: new Icon(CupertinoIcons.book), title: new Text('小冊')),
new BottomNavigationBarItem(
icon: new Icon(CupertinoIcons.profile_circled), title: new Text('我'))
];
final List<Widget> tabBodies = [
new HomePage(),
new HotPage(),
new DiscoveryPage(),
new BookPage(),
new MinePage()
];
int currentIndex = 0; //當前索引
Widget currentPage; //當前頁面
@override
void initState() {
super.initState();
currentPage = tabBodies[currentIndex];
}
@override
Widget build(BuildContext context) {
// TODO: implement build
return new Scaffold(
bottomNavigationBar: new BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: currentIndex,
items: bottomTabs,
onTap: (index) {
setState(() {
currentIndex = index;
currentPage = tabBodies[currentIndex];
});
}),
body: currentPage,
);
}
}
複製程式碼
上面的程式碼建立了一個即底部有tab按鈕的基本頁面結構,用於切換不同頁面。通過點選事件改變當前索引,來顯示相應的頁面。bottomTabs
可以封裝一下,就留給小夥伴們自己弄了哈,當是練習。
頂部我們引入了一個 Cupertino.dart
,這是ios風格的元件,我們還用到了ios的圖示,引入前我們需要到 pubspec.yaml
中配置一下,然後點選 Packages get
:
Tip:
- 因為我們的頁面內容是會切換的,換句話說,狀態會發生改變,所以這裡使用
StatefulWidget
。 final
關鍵字用於申明一個常量,List<BottomNavigationBarItem>
中的List
用於申明一個陣列,相當於js中的Array
,後面的BottomNavigationBarItem
指的是元素的型別。Scaffold
可能是用得最多的元件了,它對頁面實現了一些結構劃分,其餘的屬性部分,小夥伴們就自己看文件了哈,不難,記住就行。
再次強調,小夥伴們一定要多看文件,不然你真的會懵逼的!
接著我們在其餘檔案中寫入下面的程式碼,只修改頁面名字:
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
class HotPage extends StatefulWidget {
@override
HotPageState createState() => new HotPageState();
}
class HotPageState extends State<HotPage> {
@override
Widget build(BuildContext context) {
// TODO: implement build
return new Center(child: new Text('沸點'),);
}
}
複製程式碼
儲存一下,如果你在模擬器上看到下面的內容,就成功了:
tabs也可以用ios風格的CupertinoTabBar
實現,此元件的表現和ios原生的一模一樣,留給小夥伴們當練習哈。
四、首頁實現
現在我們來實現首頁,先在 lib
資料夾下新建一個 config
資料夾,並在其中建立 httpHeaders.dart
檔案,寫入下列程式碼:
const httpHeaders = {
'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Connection': 'keep-alive',
'Host': 'gold-tag-ms.juejin.im',
'Origin': 'https://juejin.im',
'Referer': 'https://juejin.im/timeline',
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
'X-Juejin-Client': '1532136021731',
'X-Juejin-Src': 'web',
'X-Juejin-Token':
'eyJhY2Nlc3NfdG9rZW4iOiJWUmJ2dDR1RFRzY1JUZXFPIiwicmVmcmVzaF90b2tlbiI6IjBqdXhYSzA3dW9mSTJWUEEiLCJ0b2tlbl90eXBlIjoibWFjIiwiZXhwaXJlX2luIjoyNTkyMDAwfQ==',
'X-Juejin-Uid': '59120a711b69e6006865dd7b'
};
複製程式碼
這是掘金的請求頭資訊,後面會用到,先定義在這裡,需要注意的是其中的 X-Juejin-Client
會變化,如果小夥伴們在看文章的時候發現值變了,改一下就行(好像不改也還是能用)。
開啟 home.dart
,在頂部寫入下列程式碼:
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:convert';
import 'dart:async';
import 'package:http/http.dart' as http;
import '../config/httpHeaders.dart';
複製程式碼
我們新引入了三個包,用來做網路請求。dart:convert
用來做資料轉換,dart:async
做非同步,package:http/http.dart
做請求。接著:
/*接著寫*/
class HomePage extends StatefulWidget {
@override
HomePageState createState() => new HomePageState();
}
class HomePageState extends State<HomePage> {
// 獲取分類
Future getCategories() async {
final response = await http.get(
'https://gold-tag-ms.juejin.im/v1/categories',
headers: httpHeaders);
if (response.statusCode == 200) {
return json.decode(response.body);
} else {
throw Exception('Failed to load categories');
}
}
@override
Widget build(BuildContext context) {
// TODO: implement build
return FutureBuilder(
future: getCategories(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var tabList = snapshot.data['d']['categoryList'];
return new CreatePage(
tabList: tabList,
);
} else if (snapshot.hasError) {
return Text("error1>>>>>>>>>>>>>>>:${snapshot.error}");
}
return new Container(
color: new Color.fromRGBO(244, 245, 245, 1.0),
);
},
);
}
}
複製程式碼
這部分我們先獲取獲取掘金頂部的分類列表,Future
類似於 Promise
,用來做非同步請求, FutureBuilder
函式用來在請求返回後構建頁面,返回的狀態、資料等資訊都在 snapshot
中(前端的同志們看到 async
和 await
是不是很眼熟?)。這裡我們把構建頁面的程式碼提取出來,不然巢狀太多讓人崩潰,並把獲取的tabs傳下去。我這裡用的 FutureBuilder
,小夥伴們也可以用文件中的寫法,看上去還會更簡潔,不過既然是學習嘛,寫寫也無妨。
/*接著寫*/
//建立頁面
class CreatePage extends StatefulWidget {
final List tabList;
@override
CreatePage({Key key, this.tabList}) : super(key: key);
CreatePageState createState() => new CreatePageState();
}
class CreatePageState extends State<CreatePage>
with SingleTickerProviderStateMixin {
@override
Widget build(BuildContext context) {
//TODO: implement build
return new DefaultTabController(
length: widget.tabList.length,
child: new Scaffold(
appBar: new AppBar(
backgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),
automaticallyImplyLeading: false,
titleSpacing: 0.0,
title: new TabBar(
indicatorSize: TabBarIndicatorSize.label,
isScrollable: true,
labelColor: Colors.blue,
unselectedLabelColor: Colors.grey,
tabs: widget.tabList.map((tab) {
return new Tab(
text: tab['name'],
);
}).toList()),
actions: <Widget>[
new IconButton(
icon: new Icon(
Icons.add,
color: Colors.blue,
),
onPressed: () {
Navigator.pushNamed(context, '/shareArticle');
})
],
),
body: new TabBarView(
children: widget.tabList.map((cate) {
return ArticleLists(
categories: cate,
);
}).toList()),
));
}
}
複製程式碼
這部分用於建立tab選項和tab頁面,DefaultTabController
是建立 tabBarView
的一個簡單元件,以後小夥伴們可以自己實現個性化的 tabBarView
,action
裡我已經把路由寫進去了,等我們把頁面寫完,再去實現路由。我們把構建文章列表的程式碼也提出來,當每點選一個tab,就把對應的tab資訊傳入,查詢文章會需要tab項中的 id
。
/*接著寫*/
class ArticleLists extends StatefulWidget {
final Map categories;
@override
ArticleLists({Key key, this.categories}) : super(key: key);
ArticleListsState createState() => new ArticleListsState();
}
class ArticleListsState extends State<ArticleLists> {
List articleList;
Future getArticles({int limit = 20, String category}) async {
final String url =
'https://timeline-merger-ms.juejin.im/v1/get_entry_by_rank?src=${httpHeaders['X-Juejin-Src']}&uid=${httpHeaders['X-Juejin-Uid']}&device_id=${httpHeaders['X-Juejin-Client']}&token=${httpHeaders['X-Juejin-Token']}&limit=$limit&category=$category';
final response = await http.get(Uri.encodeFull(url));
if (response.statusCode == 200) {
return json.decode(response.body);
} else {
throw Exception('Failed to load post');
}
}
@override
Widget build(BuildContext context) {
// TODO: implement build
return new FutureBuilder(
future: getArticles(category: widget.categories['id']),
builder: (context, snapshot) {
if (snapshot.hasData) {
articleList = snapshot.data['d']['entrylist'];
return new ListView.builder(
itemCount: articleList.length,
itemBuilder: (context, index) {
var item = articleList[index];
return createItem(item);
});
} else if (snapshot.hasError) {
return new Center(
child: new Text("error2>>>>>>>>>>>>>>>:${snapshot.error}"),
);
}
return new CupertinoActivityIndicator();
});
}
}
複製程式碼
我們把單個文章的構建程式碼也提出來,讓程式碼看著舒服點。
class ArticleListsState extends State<ArticleLists> {
/*接著寫*/
//單個文章
Widget createItem(articleInfo) {
var objectId = articleInfo['originalUrl']
.substring(articleInfo['originalUrl'].lastIndexOf('/') + 1);
var tags = articleInfo['tags'];
return new Container(
margin: new EdgeInsets.only(bottom: 10.0),
padding: new EdgeInsets.only(top: 10.0, bottom: 10.0),
child: new FlatButton(
padding: new EdgeInsets.all(0.0),
onPressed: () {
Navigator.push(
context,
new CupertinoPageRoute(
builder: (context) => ArticleDetail(
objectId: objectId,
articleInfo: articleInfo,
)));
},
child: new Column(
children: <Widget>[
new Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
new FlatButton(
onPressed: null,
child: new Row(
children: <Widget>[
new CircleAvatar(
backgroundImage: new NetworkImage(
articleInfo['user']['avatarLarge']),
),
new Padding(padding: new EdgeInsets.only(right: 5.0)),
new Text(
articleInfo['user']['username'],
style: new TextStyle(color: Colors.black),
)
],
)),
//控制是否顯示tag,及顯示多少個
tags.isNotEmpty
? (tags.length >= 2
? new Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
new FlatButton(
onPressed: null,
child: new Text(
tags[0]['title'].toString(),
style: new TextStyle(fontSize: 14.0),
)),
new Text('/'),
new FlatButton(
onPressed: null,
child: new Text(
tags[1]['title'].toString(),
style: new TextStyle(fontSize: 14.0),
))
],
)
: new FlatButton(
onPressed: null,
child: new Text(
tags[0]['title'].toString(),
style: new TextStyle(fontSize: 14.0),
)))
: new Container(
width: 0.0,
height: 0.0,
)
],
),
new ListTile(
title: new Text(articleInfo['title']),
subtitle: new Text(
articleInfo['summaryInfo'],
maxLines: 2,
),
),
new Row(
children: <Widget>[
new FlatButton(
onPressed: null,
child: new Row(
children: <Widget>[
new Icon(Icons.favorite),
new Padding(padding: new EdgeInsets.only(right: 5.0)),
new Text(articleInfo['collectionCount'].toString())
],
)),
new FlatButton(
onPressed: null,
child: new Row(
children: <Widget>[
new Icon(Icons.message),
new Padding(padding: new EdgeInsets.only(right: 5.0)),
new Text(articleInfo['commentsCount'].toString())
],
))
],
)
],
),
),
color: Colors.white,
);
}
}
複製程式碼
每個文章中的互動我這裡就不做那麼全了,不然篇幅太大,樣式小夥伴們也自己調吧,這個花時間。
在單個文章的按鈕裡我已經寫好了跳轉函式,就是 onPressed
中的程式碼,裡面用到的 CupertinoPageRoute
主要是ios風格的滑動動畫,我們來實現詳情頁。
五、實現文章詳情頁
在 pages
資料夾下新建 articleDetail.dart
檔案,flutter目前還不支援渲染 html
,因此我們這裡需要引入一個外掛 flutter_html_view
,這個外掛支援的標籤也不是很多,但目前差不多夠用了,為作者點個贊。開啟 pubspec.yaml
檔案,在 dependencies
下寫入依賴:
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
flutter_html_view: "^0.5.1"
複製程式碼
然後在 articleDetail.dart
頂部引入:
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:convert';
import 'dart:async';
import 'package:http/http.dart' as http;
import '../config/httpHeaders.dart';
import 'package:flutter_html_view/flutter_html_view.dart';
複製程式碼
接著就是寫頁面了:
class ArticleDetail extends StatefulWidget {
final String objectId;
final Map articleInfo;
@override
ArticleDetail({Key key, this.objectId, this.articleInfo}) : super(key: key);
@override
ArticleDetailState createState() => new ArticleDetailState();
}
class ArticleDetailState extends State<ArticleDetail> {
Future getContent() async {
final String url =
'https://post-storage-api-ms.juejin.im/v1/getDetailData?uid=${httpHeaders['X-Juejin-Src']}&device_id=${httpHeaders['X-Juejin-Client']}&token=${httpHeaders['X-Juejin-Token']}&src=${httpHeaders['X-Juejin-Src']}&type=entryView&postId=${widget
.objectId}';
final response = await http.get(Uri.encodeFull(url));
if (response.statusCode == 200) {
return json.decode(response.body)['d'];
} else {
throw Exception('Failed to load content');
}
}
@override
Widget build(BuildContext context) {
// TODO: implement build
var articleInfo = widget.articleInfo;
return new FutureBuilder(
future: getContent(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var content = snapshot.data['content'];
return new Scaffold(
appBar: new AppBar(
backgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),
leading: new IconButton(
padding: new EdgeInsets.all(0.0),
icon: new Icon(
Icons.chevron_left,
),
onPressed: () {
Navigator.pop(context);
}),
title: new Row(
children: <Widget>[
new CircleAvatar(
backgroundImage: new NetworkImage(
articleInfo['user']['avatarLarge']),
),
new Padding(padding: new EdgeInsets.only(right: 5.0)),
new Text(articleInfo['user']['username'])
],
),
actions: <Widget>[
new IconButton(
icon: new Icon(
Icons.file_upload,
color: Colors.blue,
),
onPressed: null)
],
),
bottomNavigationBar: new Container(
height: 50.0,
padding: new EdgeInsets.only(left: 10.0, right: 10.0),
decoration: new BoxDecoration(
color: new Color.fromRGBO(244, 245, 245, 1.0),
border: new Border(
top: new BorderSide(width: 0.2, color: Colors.grey))),
child: new Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
new Row(
children: <Widget>[
new Icon(
Icons.favorite_border,
color: Colors.green,
size: 24.0,
),
new Padding(
padding: new EdgeInsets.only(right: 20.0)),
new Icon(
Icons.message,
color: Colors.grey,
size: 24.0,
),
new Padding(
padding: new EdgeInsets.only(right: 20.0)),
new Icon(
Icons.playlist_add,
color: Colors.grey,
size: 24.0,
)
],
),
new Text(
'喜歡 ${articleInfo['collectionCount']} · 評論 ${articleInfo['commentsCount']}')
],
),
),
);
} else if (snapshot.hasError) {
return new Container(
color: Colors.white,
child: new Text("error2>>>>>>>>>>>>>>>:${snapshot.error}"),
);
}
return new Container(
color: new Color.fromRGBO(244, 245, 245, 1.0),
child: new CupertinoActivityIndicator(),
);
});
}
}
複製程式碼
將html
寫入頁面的就是下面這段程式碼:
body: new ListView(
children: <Widget>[
new Container(
color: Colors.white,
child: new HtmlView(
data: content,
))
],
)
複製程式碼
細心的小夥伴會發現,bottomNavigationBar
中傳入的是一個有高度的 Container
,這個很重要,flutter中的元件其實是很靈活的,不要被官網提供的元件限制了,只要滿足條件(比如bottomNavigationBar
必須傳入 PreferredSizeWidget
),各種各樣的自定義元件都可以用。
點贊、評論啥的我們先不做,用過掘金app的小夥伴都知道,這些功能是需要登入後才能用的,所以我們放到後面來實現。
結尾叨叨
自己是初學flutter,最近也很忙,文中的錯誤和不足,還請大家原諒,歡迎指出,一起學習,今天就到這裡了。原始碼點這裡。