从0开始写一个基于Flutter的开源中国客户端(6)——各个静态页面的实现

5,596 阅读11分钟

上一篇中我记录了基于Flutter的开源中国客户端的整体布局框架的搭建,本篇记录的是每个页面的静态实现,关于具体的数据加载和存储,放在下一篇中记录,希望自己在温故知新的同时,能给Flutter初学者一些帮助。

索引 文章
1 从0开始写一个基于Flutter的开源中国客户端(1)
Flutter简介及开发环境搭建 | 掘金技术征文
2 从0开始写一个基于Flutter的开源中国客户端(2)
Dart语法基础
3 从0开始写一个基于Flutter的开源中国客户端(3)
初识Flutter & 常用的Widgets
4 从0开始写一个基于Flutter的开源中国客户端(4)
Flutter布局基础
5 从0开始写一个基于Flutter的开源中国客户端(5)
App整体布局框架搭建
👉6 从0开始写一个基于Flutter的开源中国客户端(6)
各个静态页面的实现
7 从0开始写一个基于Flutter的开源中国客户端(7)
App网络请求和数据存储
8 从0开始写一个基于Flutter的开源中国客户端(8)
插件的使用

在基于Flutter的开源中国客户端中,使用得最多的就是ListView组件了,基本上80%的页面都需要用列表展示,下面分别说明每个页面的实现过程。

侧滑菜单页面的实现

上一篇中我们仅仅在侧滑菜单中放置了一个Center组件并显示了一行文本,这一篇中需要实现的侧滑菜单效果如下图:

侧滑菜单的头部是一个封面图,下面是一个菜单列表,我们可以将封面图和各个菜单都当作ListView的Item,所以这里涉及到了ListView的子Item的多布局。

上一篇的代码里我们是直接为MaterialApp添加了一个drawer参数并new了一个Drawer对象,为了合理组织代码,这里我们在lib/目录下新建一个widgets/目录,用于存放我们自定义的一些组件,并新建dart文件MyDrawer.dart,由于该页面不需要刷新,所以我们在MyDrawer.dart中定义无状态的组件MyDrawer,在该组件中定义需要用到的如下几个变量:

class MyDrawer extends StatelessWidget {
  // 菜单文本前面的图标大小
  static const double IMAGE_ICON_WIDTH = 30.0;
  // 菜单后面的箭头的图标大小
  static const double ARROW_ICON_WIDTH = 16.0;
  // 菜单后面的箭头图片
  var rightArrowIcon = new Image.asset(
    'images/ic_arrow_right.png',
    width: ARROW_ICON_WIDTH,
    height: ARROW_ICON_WIDTH,
  );
  // 菜单的文本
  List menuTitles = ['发布动弹', '动弹小黑屋', '关于', '设置'];
  // 菜单文本前面的图标
  List menuIcons = [
    './images/leftmenu/ic_fabu.png',
    './images/leftmenu/ic_xiaoheiwu.png',
    './images/leftmenu/ic_about.png',
    './images/leftmenu/ic_settings.png'
  ];
  // 菜单文本的样式
  TextStyle menuStyle = new TextStyle(
    fontSize: 15.0,
  );
  // 省略后续代码
  // ...
 }

MyDrawer类的build方法中,返回一个ListView组件即可:

  @override
  Widget build(BuildContext context) {
    return new ConstrainedBox(
      constraints: const BoxConstraints.expand(width: 304.0),
      child: new Material(
        elevation: 16.0,
        child: new Container(
          decoration: new BoxDecoration(
            color: const Color(0xFFFFFFFF),
          ),
          child: new ListView.builder(
            itemCount: menuTitles.length * 2 + 1,
            itemBuilder: renderRow,
          ),
        ),
      ),
    );
  }

build方法中的ConstraintedBox组件和Material组件都是直接参考的Drawer类的源码,constraints参数指定了侧滑菜单的宽度,elevation参数控制的是Drawer后面的阴影的大小,默认值就是16(所以这里可以不指定elevation参数),最主要的是ListView的命名构造方法build,itemCount参数代表item的个数,这里之所以是menuTitles.length * 2 + 1,其中的*2是将分割线算入到item中了,+1则是把顶部的封面图算入到item中了。下面是关键的renderRow方法:

  Widget renderRow(BuildContext context, int index) {
    if (index == 0) {
      // render cover image
      var img = new Image.asset(
        'images/cover_img.jpg',
        width: 304.0,
        height: 304.0,
      );
      return new Container(
        width: 304.0,
        height: 304.0,
        margin: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 10.0),
        child: img,
      );
    }
    // 舍去之前的封面图
    index -= 1;
    // 如果是奇数则渲染分割线
    if (index.isOdd) {
      return new Divider();
    }
    // 偶数,就除2取整,然后渲染菜单item
    index = index ~/ 2;
    // 菜单item组件
    var listItemContent = new Padding(
      // 设置item的外边距
      padding: const EdgeInsets.fromLTRB(10.0, 15.0, 10.0, 15.0),
      // Row组件构成item的一行
      child: new Row(
        children: <Widget>[
          // 菜单item的图标
          getIconImage(menuIcons[index]),
          // 菜单item的文本
          new Expanded(
            child: new Text(
              menuTitles[index],
              style: menuStyle,
            )
          ),
          rightArrowIcon
        ],
      ),
    );

    return new InkWell(
      child: listItemContent,
      onTap: () {
        print("click list item $index");
      },
    );
  }

renderRow方法体较长,主要是因为涉及到3个不同布局的渲染:头部封面图、分割线、菜单item。以上代码中已有相关注释,其中有几点需要注意:

  1. 在渲染菜单item文本时用到了Expanded组件,该组件类似于在Android中布局时添加android:layout_weight="1"属性,上面使用Expanded包裹的Text组件在水平方向上会占据除icon和箭头图标外的剩余的所有空间;
  2. 最后返回了一个InkWell组件,用于给菜单item添加点击事件,但是在Drawer中点击菜单时并没有水波纹扩散的效果(不知道是什么原因)。

资讯列表页面的实现

本篇要实现的资讯列表页面如下图所示:

资讯列表的头部是一个轮播图,可以左右滑动切换不同的资讯,下面是一个列表,显示了资讯的标题,发布时间,评论数,资讯图等信息。

轮播图的实现

轮播图主要使用了Flutter内置的TabBarView组件,该组件类似于Android中的ViewPager,可以左右滑动切换页面。为了合理组织代码,我们将轮播图单独抽出来作为一个自定义组件,在widgets/目录下新建SlideView.dart文件并添加如下代码:

import 'package:flutter/material.dart';

class SlideView extends StatefulWidget {
  var data;

  // data表示轮播图中的数据
  SlideView(data) {
    this.data = data;
  }

  @override
  State<StatefulWidget> createState() {
    // 可以在构造方法中传参供SlideViewState使用
    // 或者也可以不传参数,直接在SlideViewState中通过this.widget.data访问SlideView中的data变量
    return new SlideViewState(data);
  }
}

class SlideViewState extends State<SlideView> with SingleTickerProviderStateMixin {
  // TabController为TabBarView组件的控制器
  TabController tabController;
  List slideData;

  SlideViewState(data) {
    slideData = data;
  }

  @override
  void initState() {
    super.initState();
    // 初始化控制器
    tabController = new TabController(length: slideData == null ? 0 : slideData.length, vsync: this);
  }

  @override
  void dispose() {
    // 销毁
    tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    List<Widget> items = [];
    if (slideData != null && slideData.length > 0) {
      for (var i = 0; i < slideData.length; i++) {
        var item = slideData[i];
        // 图片URL
        var imgUrl = item['imgUrl'];
        // 资讯标题
        var title = item['title'];
        // 资讯详情URL
        var detailUrl = item['detailUrl'];
        items.add(new GestureDetector(
          onTap: () {
            // 点击页面跳转到详情
          },
          child: new Stack( // Stack组件用于将资讯标题文本放置到图片上面
            children: <Widget>[
              // 加载网络图片
              new Image.network(imgUrl),
              new Container(
                // 标题容器宽度跟屏幕宽度一致
                width: MediaQuery.of(context).size.width,
                // 背景为黑色,加入透明度
                color: const Color(0x50000000),
                // 标题文本加入内边距
                child: new Padding(
                  padding: const EdgeInsets.all(6.0),
                  // 字体大小为15,颜色为白色
                  child: new Text(title, style: new TextStyle(color: Colors.white, fontSize: 15.0)),
                )
              )
            ],
          ),
        ));
      }
    }
    return new TabBarView(
      controller: tabController,
      children: items,
    );
  }

}

TabBarView组件主要的参数是controller和children,controller代表这个TabBarView的控制器,children表示这个组件中的各个页面。SliderView中的data是在new这个对象时通过构造方法传入的,data是一个map数组,map中包含imgUrl title detailUrl3个字段。

注意:本项目的轮播图里没有加入小圆点页面指示器,小伙伴们可自行添加相关代码。

轮播图和列表的组合

上面实现了自定义的轮播图组件,下面就需要将这个组件和列表组合起来。

由于资讯列表的item布局稍微有些复杂,所以这里有必要进行拆分,整体上可以将item分为左右两部分,左边展示了资讯标题,时间,评论数等信息,右边展示了资讯的图片。所以整体是一个Row组件,而左边又是一个Column组件,Column组件的第一列是标题,第二列又是一个Row组件,其中有时间、作者头像、评论数等信息。下面直接上NewsListPage.dart的代码,在代码中做详细的注释:

import 'package:flutter/material.dart';
import 'package:flutter_osc/widgets/SlideView.dart';

// 资讯列表页面
class NewsListPage extends StatelessWidget {

  // 轮播图的数据
  var slideData = [];
  // 列表的数据(轮播图数据和列表数据分开,但是实际上轮播图和列表中的item同属于ListView的item)
  var listData = [];
  // 列表中资讯标题的样式
  TextStyle titleTextStyle = new TextStyle(fontSize: 15.0);
  // 时间文本的样式
  TextStyle subtitleStyle = new TextStyle(color: const Color(0xFFB5BDC0), fontSize: 12.0);

  NewsListPage() {
    // 这里做数据初始化,加入一些测试数据
    for (int i = 0; i < 3; i++) {
      Map map = new Map();
      // 轮播图的资讯标题
      map['title'] = 'Python 之父透露退位隐情,与核心开发团队产生隔阂';
      // 轮播图的详情URL
      map['detailUrl'] = 'https://www.oschina.net/news/98455/guido-van-rossum-resigns';
      // 轮播图的图片URL
      map['imgUrl'] = 'https://static.oschina.net/uploads/img/201807/30113144_1SRR.png';
      slideData.add(map);
    }
    for (int i = 0; i < 30; i++) {
      Map map = new Map();
      // 列表item的标题
      map['title'] = 'J2Cache 2.3.23 发布,支持 memcached 二级缓存';
      // 列表item的作者头像URL
      map['authorImg'] = 'https://static.oschina.net/uploads/user/0/12_50.jpg?t=1421200584000';
      // 列表item的时间文本
      map['timeStr'] = '2018/7/30';
      // 列表item的资讯图片
      map['thumb'] = 'https://static.oschina.net/uploads/logo/j2cache_N3NcX.png';
      // 列表item的评论数
      map['commCount'] = 5;
      listData.add(map);
    }
  }

  @override
  Widget build(BuildContext context) {
    return new ListView.builder(
      // 这里itemCount是将轮播图组件、分割线和列表items都作为ListView的item算了
      itemCount: listData.length * 2 + 1,
      itemBuilder: (context, i) => renderRow(i)
    );
  }

  // 渲染列表item
  Widget renderRow(i) {
    // i为0时渲染轮播图
    if (i == 0) {
      return new Container(
        height: 180.0,
        child: new SlideView(slideData),
      );
    }
    // i > 0时
    i -= 1;
    // i为奇数,渲染分割线
    if (i.isOdd) {
      return new Divider(height: 1.0);
    }
    // 将i取整
    i = i ~/ 2;
    // 得到列表item的数据
    var itemData = listData[i];
    // 代表列表item中的标题这一行
    var titleRow = new Row(
      children: <Widget>[
        // 标题充满一整行,所以用Expanded组件包裹
        new Expanded(
          child: new Text(itemData['title'], style: titleTextStyle),
        )
      ],
    );
    // 时间这一行包含了作者头像、时间、评论数这几个
    var timeRow = new Row(
      children: <Widget>[
        // 这是作者头像,使用了圆形头像
        new Container(
          width: 20.0,
          height: 20.0,
          decoration: new BoxDecoration(
            // 通过指定shape属性设置图片为圆形
            shape: BoxShape.circle,
            color: const Color(0xFFECECEC),
            image: new DecorationImage(
              image: new NetworkImage(itemData['authorImg']), fit: BoxFit.cover),
            border: new Border.all(
              color: const Color(0xFFECECEC),
              width: 2.0,
            ),
          ),
        ),
        // 这是时间文本
        new Padding(
          padding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 0.0),
          child: new Text(
            itemData['timeStr'],
            style: subtitleStyle,
          ),
        ),
        // 这是评论数,评论数由一个评论图标和具体的评论数构成,所以是一个Row组件
        new Expanded(
          flex: 1,
          child: new Row(
            // 为了让评论数显示在最右侧,所以需要外面的Expanded和这里的MainAxisAlignment.end
            mainAxisAlignment: MainAxisAlignment.end,
            children: <Widget>[
              new Text("${itemData['commCount']}", style: subtitleStyle),
              new Image.asset('./images/ic_comment.png', width: 16.0, height: 16.0),
            ],
          ),
        )
      ],
    );
    var thumbImgUrl = itemData['thumb'];
    // 这是item右侧的资讯图片,先设置一个默认的图片
    var thumbImg = new Container(
      margin: const EdgeInsets.all(10.0),
      width: 60.0,
      height: 60.0,
      decoration: new BoxDecoration(
        shape: BoxShape.circle,
        color: const Color(0xFFECECEC),
        image: new DecorationImage(
          image: new ExactAssetImage('./images/ic_img_default.jpg'),
          fit: BoxFit.cover),
        border: new Border.all(
          color: const Color(0xFFECECEC),
          width: 2.0,
        ),
      ),
    );
    // 如果上面的thumbImgUrl不为空,就把之前thumbImg默认的图片替换成网络图片
    if (thumbImgUrl != null && thumbImgUrl.length > 0) {
      thumbImg = new Container(
        margin: const EdgeInsets.all(10.0),
        width: 60.0,
        height: 60.0,
        decoration: new BoxDecoration(
          shape: BoxShape.circle,
          color: const Color(0xFFECECEC),
          image: new DecorationImage(
              image: new NetworkImage(thumbImgUrl), fit: BoxFit.cover),
          border: new Border.all(
            color: const Color(0xFFECECEC),
            width: 2.0,
          ),
        ),
      );
    }
    // 这里的row代表了一个ListItem的一行
    var row = new Row(
      children: <Widget>[
        // 左边是标题,时间,评论数等信息
        new Expanded(
          flex: 1,
          child: new Padding(
            padding: const EdgeInsets.all(10.0),
            child: new Column(
              children: <Widget>[
                titleRow,
                new Padding(
                  padding: const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 0.0),
                  child: timeRow,
                )
              ],
            ),
          ),
        ),
        // 右边是资讯图片
        new Padding(
          padding: const EdgeInsets.all(6.0),
          child: new Container(
            width: 100.0,
            height: 80.0,
            color: const Color(0xFFECECEC),
            child: new Center(
              child: thumbImg,
            ),
          ),
        )
      ],
    );
    // 用InkWell包裹row,让row可以点击
    return new InkWell(
      child: row,
      onTap: () {
      },
    );
  }
}

动弹列表页面的实现

动弹列表要实现的效果如下图:

为了区分普通的动弹和热门动弹,需要使用两个Tab来分别展示不同的页面,这里使用的是Flutter提供的DefaultTabController组件,该组件的用法也比较简单,下面是TweetsList.dart的build方法的代码:

  @override
  Widget build(BuildContext context) {
    // 获取屏幕宽度
    screenWidth = MediaQuery.of(context).size.width;
    return new DefaultTabController(
      length: 2,
      child: new Scaffold(
        appBar: new TabBar(
          tabs: <Widget>[
            new Tab(text: "动弹列表"),
            new Tab(text: "热门动弹")
          ],
        ),
        body: new TabBarView(
          children: <Widget>[getNormalListView(), getHotListView()],
        )),
    );
  }
  
    // 获取普通动弹列表
  Widget getNormalListView() {
    return new ListView.builder(
      itemCount: normalTweetsList.length * 2 - 1,
      itemBuilder: (context, i) => renderNormalRow(i)
    );
  }

  // 获取热门动弹列表
  Widget getHotListView() {
    return new ListView.builder(
      itemCount: hotTweetsList.length * 2 - 1,
      itemBuilder: (context, i) => renderHotRow(i),
    );
  }
  
  // 渲染普通动弹列表Item
  renderHotRow(i) {
    if (i.isOdd) {
      return new Divider(
        height: 1.0,
      );
    } else {
      i = i ~/ 2;
      return getRowWidget(hotTweetsList[i]);
    }
  }

  // 渲染热门动弹列表Item
  renderNormalRow(i) {
    if (i.isOdd) {
      return new Divider(
        height: 1.0,
      );
    } else {
      i = i ~/ 2;
      return getRowWidget(normalTweetsList[i]);
    }
  }

在TabBarView中,children参数是一个数组,代表不同的页面,这里使用两个方法分别返回普通的动弹列表和热门动弹列表,编码实现动弹列表前,先定义如下一些变量供后面使用,并在TweetsList类的构造方法中初始化这些变量:

import 'package:flutter/material.dart';

// 动弹列表页面
class TweetsListPage extends StatelessWidget {

  // 热门动弹数据
  List hotTweetsList = [];
  // 普通动弹数据
  List normalTweetsList = [];
  // 动弹作者文本样式
  TextStyle authorTextStyle;
  // 动弹时间文本样式
  TextStyle subtitleStyle;
  // 屏幕宽度
  double screenWidth;

  // 构造方法中做数据初始化
  TweetsListPage() {
    authorTextStyle = new TextStyle(fontSize: 15.0, fontWeight: FontWeight.bold);
    subtitleStyle = new TextStyle(fontSize: 12.0, color: const Color(0xFFB5BDC0));
    // 添加测试数据
    for (int i = 0; i < 20; i++) {
      Map<String, dynamic> map = new Map();
      // 动弹发布时间
      map['pubDate'] = '2018-7-30';
      // 动弹文字内容
      map['body'] = '早上七点十分起床,四十出门,花二十多分钟到公司,必须在八点半之前打卡;下午一点上班到六点,然后加班两个小时;八点左右离开公司,呼呼登自行车到健身房锻炼一个多小时。到家已经十点多,然后准备第二天的午饭,接着收拾厨房,然后洗澡,吹头发,等能坐下来吹头发时已经快十二点了。感觉很累。';
      // 动弹作者昵称
      map['author'] = '红薯';
      // 动弹评论数
      map['commentCount'] = 10;
      // 动弹作者头像URL
      map['portrait'] = 'https://static.oschina.net/uploads/user/0/12_50.jpg?t=1421200584000';
      // 动弹中的图片,多张图片用英文逗号隔开
      map['imgSmall'] = 'https://b-ssl.duitang.com/uploads/item/201508/27/20150827135810_hGjQ8.thumb.700_0.jpeg,https://b-ssl.duitang.com/uploads/item/201508/27/20150827135810_hGjQ8.thumb.700_0.jpeg,https://b-ssl.duitang.com/uploads/item/201508/27/20150827135810_hGjQ8.thumb.700_0.jpeg,https://b-ssl.duitang.com/uploads/item/201508/27/20150827135810_hGjQ8.thumb.700_0.jpeg';
      hotTweetsList.add(map);
      normalTweetsList.add(map);
    }
  }
}

有了测试数据,下面最主要的是实现列表的展示,而列表展示最为麻烦的,是列表item的渲染。每个item中要展示用户头像,用户昵称,动弹发布时间,动弹评论数,如果动弹中有图片,还需要以九宫格的方式显示图片。简单分析下动弹列表的item,应该是用Column组件展示,Column组件的第一行显示用户头像、昵称、发布动弹的时间,第二行应该显示动弹的内容,第三行是可展示可不展示的九宫格,如果动弹中有图片,则显示,否则不限时,第四行是动弹评论数,显示在右下角。下面分小步来实现列表item的渲染:

第一行,显示用户头像,昵称和发布时间

这一行用个Row组件展示即可,代码如下:

// 列表item的第一行,显示动弹作者头像、昵称、评论数
var authorRow = new Row(
  children: <Widget>[
    // 用户头像
    new Container(
      width: 35.0,
      height: 35.0,
      decoration: new BoxDecoration(
        // 头像显示为圆形
        shape: BoxShape.circle,
        color: Colors.transparent,
        image: new DecorationImage(
          image: new NetworkImage(listItem['portrait']),
          fit: BoxFit.cover),
        // 头像边框
        border: new Border.all(
          color: Colors.white,
          width: 2.0,
        ),
      ),
    ),
    // 动弹作者的昵称
    new Padding(
      padding: const EdgeInsets.fromLTRB(6.0, 0.0, 0.0, 0.0),
      child: new Text(
        listItem['author'],
        style: new TextStyle(fontSize: 16.0)
      )
    ),
    // 动弹评论数,显示在最右边
    new Expanded(
      child: new Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: <Widget>[
          new Text(
            '${listItem['commentCount']}',
            style: subtitleStyle,
          ),
          new Image.asset(
            './images/ic_comment.png',
            width: 16.0,
            height: 16.0,
          )
        ],
      ),
    )
  ],
);

第二行,显示动弹内容

这一行仅仅是一段文本,所以代码比较简单:

// 动弹内容,纯文本展示
var _body = listItem['body'];
var contentRow = new Row(
  children: <Widget>[
    new Expanded(child: new Text(_body))
  ],
);

第三行,显示动弹中的图片,没有图片则不展示这一行

以九宫格的形式显示图片稍微麻烦些,这也是为什么之前我们要在build方法中获取屏幕的宽度,因为要根据这个宽度来计算九宫格中图片的宽度。另外,九宫格中的图片URL是以字符串形式给出的,以英文逗号隔开的,所以需要对图片URL做分割处理。如果动弹中有图片,可能有1~9张,下面用一个方法来确定用九宫格显示时,总共有几行:

  // 获取行数,n表示图片的张数
  // 如果n取余不为0,则行数为n取整+1,否则n取整就是行数
  int getRow(int n) {
    int a = n % 3; // 取余
    int b = n ~/ 3; // 取整
    if (a != 0) {
      return b + 1;
    }
    return b;
  }

比如一共有9张图片,9 % 3为0,则一共有9 ~/3 = 3行,如果一共有5张图片,5 % 3 != 0,则行数为5 ~/ 3再+1即两行。

下面是生成九宫格图片的代码:

    // 动弹中的图片数据,字符串,多张图片以英文逗号分隔
    String imgSmall = listItem['imgSmall'];
    if (imgSmall != null && imgSmall.length > 0) {
      // 动弹中有图片
      List<String> list = imgSmall.split(",");
      List<String> imgUrlList = new List<String>();
      // 开源中国的openapi给出的图片,有可能是相对地址,所以用下面的代码将相对地址补全
      for (String s in list) {
        if (s.startsWith("http")) {
          imgUrlList.add(s);
        } else {
          imgUrlList.add("https://static.oschina.net/uploads/space/" + s);
        }
      }
      List<Widget> imgList = [];
      List<List<Widget>> rows = [];
      num len = imgUrlList.length;
      // 通过双重for循环,生成每一张图片组件
      for (var row = 0; row < getRow(len); row++) { // row表示九宫格的行数,可能有1行2行或3行
        List<Widget> rowArr = [];
        for (var col = 0; col < 3; col++) { // col为列数,固定有3列
          num index = row * 3 + col;
          double cellWidth = (screenWidth - 100) / 3;
          if (index < len) {
            rowArr.add(new Padding(
              padding: const EdgeInsets.all(2.0),
              child: new Image.network(imgUrlList[index],
                  width: cellWidth, height: cellWidth),
            ));
          }
        }
        rows.add(rowArr);
      }
      for (var row in rows) {
        imgList.add(new Row(
          children: row,
        ));
      }
      columns.add(new Padding(
        padding: const EdgeInsets.fromLTRB(52.0, 5.0, 10.0, 0.0),
        child: new Column(
          children: imgList,
        ),
      ));
    }

上面代码的最后有个columns变量,代表的是整个item的一个列布局,在生成九宫格布局前,已经将第一行和第二行添加到columns中:

var columns = <Widget>[
  // 这是item中第一行
  new Padding(
    padding: const EdgeInsets.fromLTRB(10.0, 10.0, 10.0, 2.0),
    child: authorRow,
  ),
  // 这是item中第二行
  new Padding(
    padding: const EdgeInsets.fromLTRB(52.0, 0.0, 10.0, 0.0),
    child: contentRow,
  ),
];

如果动弹中有图片,则columns中还要添加九宫格图片组件。

第四行,显示动弹发布时间 这一行布局比较简单:

var timeRow = new Row(
  mainAxisAlignment: MainAxisAlignment.end,
  children: <Widget>[
    new Text(
      listItem['pubDate'],
      style: subtitleStyle,
    )
  ],
);

columns.add(new Padding(
  padding: const EdgeInsets.fromLTRB(0.0, 10.0, 10.0, 6.0),
  child: timeRow,
));  

最后返回一个用一个InkWell组件包裹的columns即可:

return new InkWell(
  child: new Column(
    children: columns,
  ),
  onTap: () {
    // 跳转到动弹详情
  }
    );

“发现”页面的实现

本篇要实现的发现页面效果图如下:

该页面就是一个简单的ListView,但是稍微有些不同的是,ListView中的分割线有的长,有的短,有的分割线之间还有空白区域分隔,为了实现这个布局,我用了一种方法是将长短不同的分割线,或者两条分割线间的空白区域,都用不同的字符串来标记,在渲染列表的时候,根据不同的字符串来渲染不同的组件,代码很容易理解,所以这里直接放源码链接了:源码,源码中已有详细注释。

“我的”页面的实现

本篇要实现的我的页面效果图如下:

这个页面也比较简单,头部的绿色区域也属于ListView的一部分,也是ListView的多布局,具体实现方式就不细说了,直接放代码:源码

源码

本篇相关的所有源码都在GitHub上demo-flutter-osc项目的v0.2分支

后记

本篇主要记录的是基于Flutter的开源中国客户端各个静态页面的实现,仅限于UI,具体的网络请求,数据存储和其他逻辑在下一篇中做记录。

我的开源项目

  1. 基于Google Flutter的开源中国客户端,希望大家给个Star支持一下,源码:
  1. 基于Flutter的俄罗斯方块小游戏,希望大家给个Star支持一下,源码:
上一篇 下一篇
从0开始写一个基于Flutter的开源中国客户端(5)
——App整体布局框架搭建
从0开始写一个基于Flutter的开源中国客户端(7)——
App网络请求和数据存储