本文是我在学习 Flutter 时的练手项目,现整理成入门教程,有需要的道友可以看看。由于是入门教程,文中内容不会很深入,对于已经学习 Flutter 一段时间的道友,略过即可。

实现过程

新建项目

生成的项目结构如下:

structure

在此项目中,我们的业务代码都在 lib 下,包配置都在 pubspec.yaml 中。

emulator

点击右上角的模拟器按钮,选择已经配置好的模拟器,再点击旁边的绿色三角形,稍等片刻,当你在模拟器中看到下面的效果,恭喜,项目跑起来了:

start

Flutter的安装和配置小伙伴们就自己完成吧,我使用的是 Windows 和 intellij,参照的是 ios 版掘金 app,小伙伴们后面看到模拟器不要笑啊,因为我买不起苹果啊,哈哈!

上图中的 screenshotsarticles 文件夹是我写文章用的,小伙伴们不用看。

改造根组件

打开 lib 中的 main.dart 文件,会看到已经有一些代码,有注释,小伙伴们可以阅读一下(截图有点长,贴代码有点多,小伙伴们就自己看了)。删掉原有的代码,我们开始写自己的代码:

import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  
  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 会发现啥也没有,当然啦,我们啥都还没写呢:

hot_reload

头部的 import 是引入我们需要用的包等东西,这里引入了 material.dart ,这是一个包含了大量 material 风格的组件的包。

Flutter中 的 Widget (组件)有两类, StatelessWidget 是无状态的, StatefulWidget 是有状态的,当你的页面会随着状态的改变发生变化时使用。两者中必有 build 方法,用于创建内容。

MaterialApp 是应用的根组件,这是实现了 material 风格的 WidgetsApp,后面所有的页面、组件都会在其中。

theme 中是对组件做一些全局配置。

WARNING

小伙伴们一定要多看文档哈,虽然文档很多,但要是你不看,你可能会懵逼的,尤其是做前端开发的同志,dart 是新语言,语法这些是必须要学习的,我不可能在文中逐行解释,切记!

实现 App 界面结构

lib 文件夹下新建 pages 文件夹,用于存放我们的页面。然后再 pages 文件夹下新建 index.darthome.dartdiscovery.darthot.dartbook.dartmine.dart ,对应底部的每个tab,这是我们项目中主要会用到的文件了。

.
└─ lib
   ├─ index.dart
   ├─ home.dart
   ├─ discovery.dart
   ├─ hot.dart
   ├─ book.dart
   └─ mine.dart

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 {
  
  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; //当前页面

  
  void initState() {
    super.initState();
    currentPage = tabBodies[currentIndex];
  }

  
  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:

package

因为我们的页面内容是会切换的,换句话说,状态会发生改变,所以这里使用 StatefulWidget

final 关键字用于申明一个常量,List<BottomNavigationBarItem> 中的 List 用于申明一个数组,相当于 js 中的 Array,后面的 BottomNavigationBarItem 指的是元素的类型。

Scaffold 可能是用得最多的组件了,它对页面实现了一些结构划分,其余的属性部分,小伙伴们就自己看文档了,不难,记住就行。

接着我们在其余文件中写入下面的代码,只修改页面名字:

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

class HotPage extends StatefulWidget {
  
  HotPageState createState() => new HotPageState();
}

class HotPageState extends State<HotPage> {
  
  Widget build(BuildContext context) {
    // TODO: implement build
    return new Center(child: new Text('沸点'),);
  }
}

保存一下,如果你在模拟器上看到下面的内容,就成功了:

app_structure

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 {
  
  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');
    }
  }

  
  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 中(前端的同志们看到 asyncawait 是不是很眼熟?)。这里我们把构建页面的代码提取出来,不然嵌套太多让人崩溃,并把获取的tabs传下去。我这里用的 FutureBuilder,小伙伴们也可以用文档中的写法,看上去还会更简洁,不过既然是学习嘛,写写也无妨。

/*接着写*/
//创建页面
class CreatePage extends StatefulWidget {
  final List tabList;

  
  CreatePage({Key key, this.tabList}) : super(key: key);

  CreatePageState createState() => new CreatePageState();
}

class CreatePageState extends State<CreatePage>
    with SingleTickerProviderStateMixin {
  
  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 的一个简单组件,以后小伙伴们可以自己实现个性化的 tabBarViewaction 里我已经把路由写进去了,等我们把页面写完,再去实现路由。我们把构建文章列表的代码也提出来,当每点击一个tab,就把对应的tab信息传入,查询文章会需要tab项中的 id

/*接着写*/
class ArticleLists extends StatefulWidget {
  final Map categories;

  
  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');
    }
  }

  
  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,
    );
  }
}

每个文章中的交互我这里就不做那么全了,不然篇幅太大,样式小伙伴们也自己调吧,这个花时间。

tab

在单个文章的按钮里我已经写好了跳转函数,就是 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;

  
  ArticleDetail({Key key, this.objectId, this.articleInfo}) : super(key: key);

  
  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');
    }
  }

  
  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的小伙伴都知道,这些功能是需要登录后才能用的,所以我们放到后面来实现。

article_detail

实现发现页

打开 discovery.dart ,可以删掉之前写的代码,或者在原来的基础上改造也可以,看大家喜欢,首先在顶部引入需要用的包和其他文件:

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../utils/countTime.dart';
import '../config/httpHeaders.dart';

在这里我引入了一个 countTime.dart 文件,这个是我们用来计算文章发布时间与当前的差值的,我们先把这个小工具实现一下。在 lib 文件夹下新建 utils 文件夹,并在其中新建 countTime.dart 文件,写入以下代码:

//计算发布时间间隔
String countTime(String timestamp) {
  var now = new DateTime.now();
  var publicTime = DateTime.parse(timestamp);
  var diff = now.difference(publicTime);
  if (diff.inDays > 0) {
    return '${diff.inDays}天前';
  } else if (diff.inHours > 0) {
    return '${diff.inHours}小时前';
  } else if (diff.inMinutes > 0) {
    return '${diff.inMinutes}分钟前';
  } else if (diff.inSeconds > 0) {
    return '${diff.inSeconds}秒前';
  }
  return timestamp.substring(0, timestamp.indexOf('T'));
}

上面的代码通过传入的时间戳来计算差值,并返回不同的文本,比较简单,只要小伙伴们熟悉一下语法就会了。

回到 discovery.dart 继续我们的代码,将上一篇文章中网络请求的写法改一下:

/*接着写*/
class DiscoveryPage extends StatefulWidget {
  
  DiscoveryPageState createState() => new DiscoveryPageState();
}

class DiscoveryPageState extends State<DiscoveryPage> {
  List hotArticles;

  Future getHotArticles() {
    return http.get(Uri.encodeFull(
        '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=20&category=all&recomment=1'));
  }

  
  void initState() {
    super.initState();
    this.getHotArticles().then((response) {
      setState(() {
        hotArticles = json.decode(response.body)['d']['entrylist'];
      });
    }, onError: (e) {
      throw Exception('Failed to load data');
    });
  }
}

initState 用来做初始化,写过 react 的同志应该很熟悉了。接着是 then ,是不是和 Promise 很像?

之前我们构建页面用的主要是 ListView ,既然是入门教程,我们今天就用新的组件,多熟悉一些东西。接着写:

class DiscoveryPageState extends State<DiscoveryPage> {
  /*接着写*/
    
    Widget build(BuildContext context) {
      // TODO: implement build
      return CustomScrollView(
        slivers: <Widget>[
          new SliverAppBar(
            pinned: true,
            title: new Card(
                color: new Color.fromRGBO(250, 250, 250, 0.6),
                child: new FlatButton(
                  onPressed: () {
                    Navigator.pushNamed(context, '/search');
                  },
                  child: new Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: <Widget>[
                      new Icon(
                        Icons.search,
                        color: Colors.black,
                      ),
                      new Padding(padding: new EdgeInsets.only(right: 5.0)),
                      new Text('搜索')
                    ],
                  ),
                )),
            titleSpacing: 5.0,
            backgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),
          ),
          new SliverList(
              delegate: new SliverChildBuilderDelegate((context, index) {
            return new Container(
              color: Colors.white,
              padding: new EdgeInsets.only(top: 15.0,bottom: 15.0),
              margin: new EdgeInsets.only(bottom: 20.0),
              child: new Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  new FlatButton(
                      onPressed: null,
                      child: new Column(
                        children: <Widget>[
                          new Icon(
                            Icons.whatshot,
                            color: Colors.red,
                            size: 30.0,
                          ),
                          new Text('本周最热')
                        ],
                      )),
                  new FlatButton(
                      onPressed: null,
                      child: new Column(
                        children: <Widget>[
                          new Icon(
                            Icons.collections,
                            color: Colors.green,
                            size: 30.0,
                          ),
                          new Text('收藏集')
                        ],
                      )),
                  new FlatButton(
                      onPressed: () {
                        Navigator.pushNamed(context, '/activities');
                      },
                      child: new Column(
                        children: <Widget>[
                          new Icon(
                            Icons.toys,
                            color: Colors.yellow,
                            size: 30.0,
                          ),
                          new Text('活动')
                        ],
                      )),
                ],
              ),
            );
          }, childCount: 1)),
          new SliverList(
              delegate: new SliverChildBuilderDelegate((context, index) {
            return new Container(
              padding: new EdgeInsets.all(10.0),
              decoration: new BoxDecoration(
                  border: new Border(
                      bottom: new BorderSide(width: 0.2, color: Colors.grey)),
                  color: Colors.white),
              child: new Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  new Row(
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: <Widget>[
                      new Icon(
                        Icons.whatshot,
                        color: Colors.red,
                      ),
                      new Padding(padding: new EdgeInsets.only(right: 5.0)),
                      new Text(
                        '热门文章',
                        style: new TextStyle(fontSize: 14.0),
                      )
                    ],
                  ),
                  new Row(
                    children: <Widget>[
                      new Icon(
                        Icons.settings,
                        color: Colors.grey,
                      ),
                      new Padding(padding: new EdgeInsets.only(right: 5.0)),
                      new Text(
                        '定制热门',
                        style: new TextStyle(fontSize: 14.0, color: Colors.grey),
                      )
                    ],
                  )
                ],
              ),
            );
          }, childCount: 1)),
          new SliverFixedExtentList(
              itemExtent: 100.0,
              delegate: new SliverChildBuilderDelegate((context, index) {
                var itemInfo = hotArticles[index];
                return createItem(itemInfo);
              }, childCount: hotArticles == null ? 0 : hotArticles.length)),
        ],
      );
    }
}

这里我们用的 CustomScrollViewSliver,语法啥的小伙伴们自己看文档了哈,就不解释了。对于搜索按钮和活动按钮,我这里已经写了跳转路由,不急,我们一会儿就去实现。我们把单个文章的构建代码提出来,让整体简洁一点。

class DiscoveryPageState extends State<DiscoveryPage> {
  /*接着写*/
  //单个热门文章
    Widget createItem(itemInfo) {
      var publicTime = countTime(itemInfo['createdAt']);
      return new Container(
        padding: new EdgeInsets.only(top: 10.0, bottom: 10.0),
        decoration: new BoxDecoration(
            color: Colors.white,
            border: new Border(
                bottom: new BorderSide(width: 0.2, color: Colors.grey))),
        child: new FlatButton(
            onPressed: null,
            child: new Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                new Expanded(
                  child: new Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      new Text(
                        itemInfo['title'],
                        textAlign: TextAlign.left,
                        style: new TextStyle(
                          color: Colors.black,
                        ),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                      new Text(
                        '${itemInfo['collectionCount']}人喜欢 · ${itemInfo['user']['username']} · $publicTime',
                        textAlign: TextAlign.left,
                        style: new TextStyle(color: Colors.grey, fontSize: 12.0),
                        softWrap: true,
                      )
                    ],
                  ),
                ),
                itemInfo['screenshot'] != null
                    ? new Image.network(
                        itemInfo['screenshot'],
                        width: 100.0,
                      )
                    : new Container(
                        width: 0.0,
                        height: 0.0,
                      )
              ],
            )),
      );
    }
}

这里的单个文章有可能没有截图,所以写个判断。现在运行一下,如果你看到的界面长这样,就 OK 了:

discovery

实现搜索页

我们先实现搜索页,在 pages 下新建 search.dart ,写入下列代码:

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:async';
import '../utils/countTime.dart';

class SearchPage extends StatefulWidget {
  
  SearchPageState createState() => new SearchPageState();
}

class SearchPageState extends State<SearchPage> {
  String searchContent;
  List searchResult;

  Future search(String query) {
    return http.get(
        'https://search-merger-ms.juejin.im/v1/search?query=$query&page=0&raw_result=false&src=web');
  }

  final TextEditingController controller = new TextEditingController();
}

这里我们申明两个变量 searchContentsearchResult ,前者是搜索内容,后者是结果列表,再申明一个 controller 用于控制输入框。

接着构建页面:

class SearchPageState extends State<SearchPage> {
/*接着写*/
  
  Widget build(BuildContext context) {
    // TODO: implement build
    return new CustomScrollView(
      slivers: <Widget>[
        new SliverAppBar(
            pinned: true,
            leading: new IconButton(
                icon: new Icon(Icons.chevron_left),
                onPressed: () {
                  Navigator.pop(context);
                }),
            title: new Text(
              '搜索',
              style: new TextStyle(fontWeight: FontWeight.normal),
            ),
            centerTitle: true,
            iconTheme: new IconThemeData(color: Colors.blue),
            backgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),
            bottom: new PreferredSize(
                child: new Container(
                  color: Colors.white,
                  padding: new EdgeInsets.all(5.0),
                  child: new Card(
                      color: new Color.fromRGBO(252, 252, 252, 0.6),
                      child: new Padding(
                        padding: new EdgeInsets.all(5.0),
                        child: new Row(
                          crossAxisAlignment: CrossAxisAlignment.center,
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: <Widget>[
                            new Expanded(
                              child: new TextField(
                                autofocus: true,
                                style: new TextStyle(
                                    fontSize: 14.0, color: Colors.black),
                                decoration: new InputDecoration(
                                  contentPadding: new EdgeInsets.all(0.0),
                                  border: InputBorder.none,
                                  hintText: '搜索',
                                  prefixIcon: new Icon(
                                    Icons.search,
                                    size: 16.0,
                                    color: Colors.grey,
                                  ),
                                ),
                                onChanged: (String content) {
                                  setState(() {
                                    searchContent = content;
                                  });
                                },
                                onSubmitted: (String content) {
                                  search(content).then((response) {
                                    setState(() {
                                      searchResult =
                                          json.decode(response.body)['d'];
                                    });
                                  }, onError: (e) {
                                    throw Exception('Failed to load data');
                                  });
                                },
                                controller: controller,
                              ),
                            ),
                            searchContent == ''
                                ? new Container(
                                    height: 0.0,
                                    width: 0.0,
                                  )
                                : new InkResponse(
                                    child: new Icon(
                                      Icons.close,
                                    ),
                                    onTap: () {
                                      setState(() {
                                        searchContent = '';
                                        controller.text = '';
                                      });
                                    })
                          ],
                        ),
                      )),
                ),
                preferredSize: new Size.fromHeight(40.0))),
        searchResult == null
            ? new SliverFillRemaining(
                child: new Container(
                  color: Colors.white,
                ),
              )
            : new SliverList(
                delegate: new SliverChildBuilderDelegate((context, index) {
                var resultInfo = searchResult[index];
                return showResult(resultInfo);
              }, childCount: searchResult.length))
      ],
    );
  }
}

这里没什么特别的,小伙伴们看看代码就懂了,我们还是把搜索结果单独提出来:

class SearchPageState extends State<SearchPage> {
/*接着写*/
//显示搜索结果
  Widget showResult(resultInfo) {
    var publicTime = countTime(resultInfo['createdAt']);
    return new Container(
      alignment: Alignment.centerLeft,
      padding: new EdgeInsets.all(10.0),
      decoration: new BoxDecoration(
          color: Colors.white,
          border: new Border(
              bottom: new BorderSide(width: 0.2, color: Colors.grey))),
      child: new FlatButton(
          onPressed: null,
          child: new Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisAlignment: MainAxisAlignment.start,
            children: <Widget>[
              new Text(
                resultInfo['title'],
                style: new TextStyle(color: Colors.black),
              ),
              new Text(
                '${resultInfo['collectionCount']}人喜欢 · ${resultInfo['user']['username']} · $publicTime',
                textAlign: TextAlign.left,
                style: new TextStyle(color: Colors.grey, fontSize: 12.0),
                softWrap: true,
              )
            ],
          )),
    );
  }
}

至此,搜索页面写完了,别忙运行啊,还没写路由呢。打开 main.dart,引入 search.dart,然后配置一下路由:

import 'package:flutter/material.dart';
import 'pages/index.dart';
import 'pages/search.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new IndexPage(),
      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))),
      routes: <String, WidgetBuilder>{
        '/search': (BuildContext context) => SearchPage()
      },
    );
  }
}

现在可以运行了,效果如下:点击进入搜索详情页我就不做了,这些都留给小伙伴们练手吧:

search

实现活动页

活动页的实现和首页一模一样,代码我就不贴了,在 main.dart 配置一下就行:

import 'package:flutter/material.dart';
import 'pages/index.dart';
import 'pages/search.dart';
import 'pages/activities.dart';
void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new IndexPage(),
      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))),
      routes: <String, WidgetBuilder>{
        '/search': (BuildContext context) => SearchPage(),
        '/activities': (BuildContext context) => ActivitiesPage(),
      },
    );
  }
}

效果如下:

activity

引入 Redux

写过 react 的小伙伴对 redux 一定不陌生,我们这里引入 flutter_redux 这个插件来管理登录状态,它是国外的牛人写的,小伙伴们之后自己了解吧,这里为作者点个赞。

打开 pubspec.yaml 写入依赖,并 get 一下:

dependencies:
  flutter_redux: ^0.5.2

然后打开 main.dart ,引入 redux

import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';

接着,我们在 lib 下新建 reducers 文件夹,并在其中新建 reducers.dart ,写入下列代码:

Map getUserInfo(Map userInfo, dynamic action) {
  if (action.type == 'SETUSERINFO') {
    userInfo = action.userInfo;
  } else if (action.type == 'GETUSERINFO') {}
  print(action.type);
  return userInfo;
}

接着在 lib 下新建 actions 文件夹,并在其中新建 actions.dart ,写入下列代码:

class UserInfo {
  String type;
  final Map userInfo;

  UserInfo(this.type,this.userInfo);
}

小伙伴们一看就知道就是做获取用户信息及修改用户信息的,就不多做解释。

回到 main.dart ,引入 actionsreducers 并改造之前的代码:

import 'actions/actions.dart';
import 'reducers/reducers.dart';

void main() {
  final userInfo = new Store<Map>(getUserInfo, initialState: {});

  runApp(new MyApp(
    store: userInfo,
  ));
}

class MyApp extends StatelessWidget {
  final Store<Map> store;

  MyApp({Key key, this.store}) : super(key: key);

  
  Widget build(BuildContext context) {
    return new StoreProvider(
        store: store,
        child: new MaterialApp(
          home: new IndexPage(),
          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))),
          routes: <String, WidgetBuilder>{
            '/search': (BuildContext context) => SearchPage(),
            '/activities': (BuildContext context) => ActivitiesPage(),
          },
        ));
  }
}

我们用 StoreProvider 将根组件 MaterialApp 包裹起来,因为其他页面都是在根组件下的,所以其他所有页面都能获取到 store 。到此我们就算是引入 redux 了。

实现登录页

我们这里做的是用户登录状态的管理,所以我们先实现登录页。

pages 下新建 signin.dart ,先引入所需要的东西:

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';
import '../actions/actions.dart';
import '../reducers/reducers.dart';

接着,我们先定义一下变量啥的,后面会用到:

/*接着写*/

class SignInPage extends StatefulWidget {
  
  SignInPageState createState() => new SignInPageState();
}

class SignInPageState extends State<SignInPage> {
  String account; //账号
  String password; //密码
  Map userInfo; //用户信息
  List signMethods = [ //其他登录方式
    'lib/assets/icon/weibo.png',
    'lib/assets/icon/wechat.png',
    'lib/assets/icon/github.png'
  ];
  RegExp phoneNumber = new RegExp(
      r"(0|86|17951)?(13[0-9]|15[0-35-9]|17[0678]|18[0-9]|14[57])[0-9]{8}"); //验证手机正则表达式
  final TextEditingController accountController = new TextEditingController();
  final TextEditingController passwordController = new TextEditingController();

  //显示提示信息
  void showAlert(String value) {
    showDialog(
        context: context,
        builder: (context) {
          return new AlertDialog(
            content: new Text(value),
          );
        });
  }
}

这里只需注意两个 controller ,因为我这里用的是 TextField ,所以需要它们俩来对输入框做一些控制。当然,小伙伴们也可以用 TextForm

class SignInPageState extends State<SignInPage> {
/*接着写*/
  
  Widget build(BuildContext context) {
    // TODO: implement build
    return new Scaffold(
        appBar: new AppBar(
          backgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),
          titleSpacing: 0.0,
          leading: new IconButton(
              icon: new Icon(Icons.chevron_left),
              onPressed: (() {
                Navigator.pop(context);
              })),
        ),
        body: new Container(
          child: new Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              new Container(
                child: new Column(
                  children: <Widget>[
                    new Container(
                        height: 80.0,
                        margin: new EdgeInsets.only(top: 30.0, bottom: 30.0),
                        child: new ClipRRect(
                          borderRadius: new BorderRadius.circular(15.0),
                          child: new Image.asset(
                            'lib/assets/img/juejin.jpg',
                          ),
                        )),
                    new Container(
                      decoration: new BoxDecoration(
                          border: new Border(
                              top: new BorderSide(
                                  width: 0.5, color: Colors.grey),
                              bottom: new BorderSide(
                                  width: 0.5, color: Colors.grey))),
                      margin: new EdgeInsets.only(bottom: 20.0),
                      child: new Column(
                        children: <Widget>[
                          new TextField(
                            decoration: new InputDecoration(
                                hintText: '邮箱/手机',
                                border: new UnderlineInputBorder(
                                    borderSide: new BorderSide(
                                        color: Colors.grey, width: 0.2)),
                                prefixIcon: new Padding(
                                    padding: new EdgeInsets.only(right: 20.0))),
                            controller: accountController,
                            onChanged: (String content) {
                              setState(() {
                                account = content;
                              });
                            },
                          ),
                          new TextField(
                            decoration: new InputDecoration(
                                border: InputBorder.none,
                                hintText: '密码',
                                prefixIcon: new Padding(
                                    padding: new EdgeInsets.only(right: 20.0))),
                            controller: passwordController,
                            onChanged: (String content) {
                              setState(() {
                                password = content;
                              });
                            },
                          ),
                        ],
                      ),
                    ),
                    new Container(
                        padding: new EdgeInsets.only(left: 20.0, right: 20.0),
                        child: new Column(
                          children: <Widget>[
                            new StoreConnector<Map, VoidCallback>(
                              converter: (store) {
                                return () => store.dispatch(
                                    UserInfo('SETUSERINFO', userInfo));
                              },
                              builder: (context, callback) {
                                return new Card(
                                  color: Colors.blue,
                                  child: new FlatButton(
                                      onPressed: () {
                                        if (account == null) {
                                          showAlert('请输入账号');
                                        } else if (password == null) {
                                          showAlert('请输入密码');
                                        } else if (phoneNumber
                                            .hasMatch(account)) {
                                          String url =
                                              "https://juejin.im/auth/type/phoneNumber";
                                          http.post(url, body: {
                                            "phoneNumber": account,
                                            "password": password
                                          }).then((response) {
                                            if (response.statusCode == 200) {
                                              userInfo =
                                                  json.decode(response.body);
                                              callback();
                                              Navigator.pop(context);
                                            }
                                          });
                                        } else {
                                          showAlert('请输入正确的手机号码');
                                        }
                                      },
                                      child: new Row(
                                        mainAxisAlignment:
                                            MainAxisAlignment.center,
                                        children: <Widget>[
                                          new Text(
                                            '登录',
                                            style: new TextStyle(
                                                color: Colors.white),
                                          )
                                        ],
                                      )),
                                );
                              },
                            ),
                            new Row(
                              mainAxisAlignment: MainAxisAlignment.spaceBetween,
                              children: <Widget>[
                                new FlatButton(
                                  onPressed: () {},
                                  child: new Text(
                                    '忘记密码?',
                                    style: new TextStyle(color: Colors.grey),
                                  ),
                                ),
                                new FlatButton(
                                    onPressed: () {},
                                    child: new Text(
                                      '注册账号',
                                      style: new TextStyle(color: Colors.blue),
                                    )),
                              ],
                            )
                          ],
                        )),
                  ],
                ),
              ),
              new Container(
                child: new Column(
                  children: <Widget>[
                    new Text('其他登录方式'),
                    new Row(
                        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                        children: signMethods.map((item) {
                          return new IconButton(
                              icon: new Image.asset(
                                item,
                                color: Colors.blue,
                              ),
                              onPressed: null);
                        }).toList()),
                    new Text(
                      '掘金 · juejin.im',
                      style: new TextStyle(
                        color: Colors.grey,
                        fontSize: 12.0,
                      ),
                    ),
                    new Row(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
                        new Icon(
                          Icons.check_circle,
                          color: Colors.grey,
                          size: 14.0,
                        ),
                        new Text(
                          '已阅读并同意',
                          style:
                              new TextStyle(color: Colors.grey, fontSize: 12.0),
                        ),
                        new FlatButton(
                            onPressed: null,
                            child: new Text(
                              '软件许可服务协议',
                              style: new TextStyle(
                                  decoration: TextDecoration.underline,
                                  decorationColor: const Color(0xff000000),
                                  fontSize: 12.0),
                            ))
                      ],
                    )
                  ],
                ),
              )
            ],
          ),
        ));
  }
}

页面长这个样子:

login

这部分内容稍微有点复杂,嵌套也比较多,我说一下关键点。

首先是 Image.asset ,这个组件是用来从我们的项目中引入图片,但使用前需要写入依赖。在 lib 下新建一个文件夹用于存放图片:

images_folder

然后到 pubspec.yaml 下写依赖:

pubspec

这样才能使用。

其次是在需要和 store 通信的地方用 StoreConnector 将组件包裹起来,我们这里主要是下面这一段:

  new StoreConnector<Map, VoidCallback>(
       converter: (store) {
         return () => store.dispatch(
             UserInfo('SETUSERINFO', userInfo));
       },
       builder: (context, callback) {
         return new Card(
           color: Colors.blue,
           child: new FlatButton(
               onPressed: () {
                 if (account == null) {
                   showAlert('请输入账号');
                 } else if (password == null) {
                   showAlert('请输入密码');
                 } else if (phoneNumber
                     .hasMatch(account)) {
                   String url =
                       "https://juejin.im/auth/type/phoneNumber";
                   http.post(url, body: {
                     "phoneNumber": account,
                     "password": password
                   }).then((response) {
                     if (response.statusCode == 200) {
                       userInfo =
                           json.decode(response.body);
                       callback();
                       Navigator.pop(context);
                     }
                   });
                 } else {
                   showAlert('请输入正确的手机号码');
                 }
               },
               child: new Row(
                 mainAxisAlignment:
                     MainAxisAlignment.center,
                 children: <Widget>[
                   new Text(
                     '登录',
                     style: new TextStyle(
                         color: Colors.white),
                   )
                 ],
               )),
         );
       },
     ),

converter 返回一个函数,内容就是对 store 进行的操作,我们这里是登录,需要把登录信息写入 store ,所以这里是 SETUSERINFO 。这个返回的函数会被 builder 作为第二个参数,我们在调用掘金接口并登录成功后调用此函数将登录信息写入 store 。我这里做的是登录成功后回到之前的页面。

我们回到 main.dart ,添加一下路由:

import 'pages/signin.dart';
/*略过*/
routes: <String, WidgetBuilder>{
        '/search': (BuildContext context) => SearchPage(),
        '/activities': (BuildContext context) => ActivitiesPage(),
        '/signin': (BuildContext context) => SignInPage(),
      },

其实页面写完,登录功能也就可以用了,但是我们得有一个入口进入到登录页面,所以我们接下来实现我的页面。

实现我的页面

打开 mine.dart ,先引入需要的东西并定义一些变量:

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';
import '../actions/actions.dart';
import '../reducers/reducers.dart';

class MinePage extends StatefulWidget {
  
  MinePageState createState() => new MinePageState();
}

class MinePageState extends State<MinePage> {
  List infoList = [
    {
      'key': 'msgCenter',
      'content': {
        'title': '消息中心',
        'icon': Icons.notifications,
        'color': Colors.blue,
        'path': '/msgCenter'
      }
    },
    {
      'key': 'collectedEntriesCount',
      'content': {
        'title': '我喜欢的',
        'icon': Icons.favorite,
        'color': Colors.green,
        'path': '/like'
      }
    },
    {
      'key': 'collectionSetCount',
      'content': {
        'title': '收藏集',
        'icon': Icons.collections,
        'color': Colors.blue,
        'path': '/collections'
      }
    },
    {
      'key': 'postedEntriesCount',
      'content': {
        'title': '已购小册',
        'icon': Icons.shop,
        'color': Colors.orange,
        'path': '/myBooks'
      }
    },
    {
      'key': 'collectionSetCount',
      'content': {
        'title': '我的钱包',
        'icon': Icons.account_balance_wallet,
        'color': Colors.blue,
        'path': '/myWallet'
      }
    },
    {
      'key': 'likedPinCount',
      'content': {
        'title': '赞过的沸点',
        'icon': Icons.thumb_up,
        'color': Colors.green,
        'path': '/pined'
      }
    },
    {
      'key': 'viewedEntriesCount',
      'content': {
        'title': '阅读过的文章',
        'icon': Icons.remove_red_eye,
        'color': Colors.grey,
        'path': '/read'
      }
    },
    {
      'key': 'subscribedTagsCount',
      'content': {
        'title': '标签管理',
        'icon': Icons.picture_in_picture,
        'color': Colors.grey,
        'path': '/tags'
      }
    },
  ];
}

这里的 infoList 就是一些选项,提出来写是为了让整体代码看着舒服点。路由我也写在里面了,等之后有空再慢慢完善吧。接着:

class MinePageState extends State<MinePage> {
  
  Widget build(BuildContext context) {
    // TODO: implement build
    return new StoreConnector<Map, Map>(
        converter: (store) => store.state,
        builder: (context, info) {
          Map userInfo = info;
          if (userInfo.isNotEmpty) {
            infoList.map((item) {
              item['content']['count'] = userInfo['user'][item['key']];
            }).toList();
          }
          return new Scaffold(
            appBar: new AppBar(
              title: new Text('我'),
              centerTitle: true,
              backgroundColor: new Color.fromRGBO(244, 245, 245, 1.0),
            ),
            body: new ListView(
              children: <Widget>[
                new StoreConnector<Map, Map>(
                  converter: (store) => store.state,
                  builder: (context, info) {
                    if(info.isEmpty){}else{}
                    return new Container(
                      child: new ListTile(
                        leading: info.isEmpty?
                          new CircleAvatar(
                          child: new Icon(Icons.person, color: Colors.white),
                          backgroundColor: Colors.grey,
                        ):new CircleAvatar(backgroundImage: new NetworkImage(info['user']['avatarLarge']),),
                        title: info.isEmpty
                            ? new Text('登录/注册')
                            : new Text(info['user']['username']),
                        subtitle: info.isEmpty
                            ? new Container(
                                width: 0.0,
                                height: 0.0,
                              )
                            : new Text(
                                '${info['user']['jobTitle']} @ ${info['user']['company']}'),
                        enabled: true,
                        trailing: new Icon(Icons.keyboard_arrow_right),
                        onTap: () {
                          Navigator.pushNamed(context, '/signin');
                        },
                      ),
                      padding: new EdgeInsets.only(top: 15.0, bottom: 15.0),
                      margin: const EdgeInsets.only(top: 15.0, bottom: 15.0),
                      decoration: const BoxDecoration(
                          border: const Border(
                            top: const BorderSide(
                                width: 0.2,
                                color:
                                    const Color.fromRGBO(215, 217, 220, 1.0)),
                            bottom: const BorderSide(
                                width: 0.2,
                                color:
                                    const Color.fromRGBO(215, 217, 220, 1.0)),
                          ),
                          color: Colors.white),
                    );
                  },
                ),
                new Column(
                    children: infoList.map((item) {
                  Map itemInfo = item['content'];
                  return new Container(
                    decoration: new BoxDecoration(
                        color: Colors.white,
                        border: new Border(bottom: new BorderSide(width: 0.2))),
                    child: new ListTile(
                      leading: new Icon(
                        itemInfo['icon'],
                        color: itemInfo['color'],
                      ),
                      title: new Text(itemInfo['title']),
                      trailing: itemInfo['count'] == null
                          ? new Container(
                              width: 0.0,
                              height: 0.0,
                            )
                          : new Text(itemInfo['count'].toString()),
                      onTap: () {
                        Navigator.pushNamed(context, itemInfo['path']);
                      },
                    ),
                  );
                }).toList()),
                new Column(
                  children: <Widget>[
                    new Container(
                      margin: new EdgeInsets.only(top: 15.0),
                      decoration: new BoxDecoration(
                          color: Colors.white,
                          border: new Border(
                              top: new BorderSide(width: 0.2),
                              bottom: new BorderSide(width: 0.2))),
                      child: new ListTile(
                        leading: new Icon(Icons.insert_drive_file),
                        title: new Text('意见反馈'),
                      ),
                    ),
                    new Container(
                      margin: new EdgeInsets.only(bottom: 15.0),
                      decoration: new BoxDecoration(
                          color: Colors.white,
                          border:
                              new Border(bottom: new BorderSide(width: 0.2))),
                      child: new ListTile(
                        leading: new Icon(Icons.settings),
                        title: new Text('设置'),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          );
        });
  }
}

这里也是一样,因为我们整个页面都会用到 store ,所以我们在最外层使用 StoreConnector ,代码中有很多三元表达式,这个是为了在是否有登陆信息两种状态下显示不同内容的,完成后的页面长这个样子:

mine

为什么显示的是登录/注册呢?因为我们没登录啊,哈哈!放一张完成后的联动图:

login_activity

小伙伴们可以看到,登录后会显示用户的一些信息,细心的小伙伴会发现输入账号密码的时候会提示超出了,我个人觉得这个应该是正常的吧,毕竟底部键盘弹起来肯定会遮挡部分页面。其他需要用到登录状态的地方也是一样的写法。

结语

至此,此入门教程就完结了。由于文章篇幅,沸点和小册两个 tab 页面我就不贴了,相信如果是从第一篇文章看到现在的小伙伴都会写了。

总结一下我们学习的东西,主要涉及的知识点如下:

  • 基础组件的使用
  • 网络请求
  • 路由配置及传参
  • html 代码的渲染
  • 使用 redux 做状态管理

总结完了感觉没多少东西,不过我也是初学者,水平有限,文中的不足及错误还请指出,一起学习、交流。之后的话项目我会不时更新,不过是在 GitHub 上以代码的形式了,喜欢的小伙伴可以关注一下。源码点这里open in new window

最近更新:
作者: MeFelixWang