上一篇中我记录了基于Flutter的开源中国客户端的整体布局框架的搭建,本篇记录的是每个页面的静态实现,关于具体的数据加载和存储,放在下一篇中记录,希望自己在温故知新的同时,能给Flutter初学者一些帮助。
索引 | 文章 |
---|---|
1 | |
2 | |
3 | |
4 | |
5 | |
?6 | |
7 | |
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:[ // 菜单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。以上代码中已有相关注释,其中有几点需要注意:
- 在渲染菜单item文本时用到了Expanded组件,该组件类似于在Android中布局时添加android:layout_weight="1"属性,上面使用Expanded包裹的Text组件在水平方向上会占据除icon和箭头图标外的剩余的所有空间;
- 最后返回了一个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 StatecreateState() { // 可以在构造方法中传参供SlideViewState使用 // 或者也可以不传参数,直接在SlideViewState中通过this.widget.data访问SlideView中的data变量 return new SlideViewState(data); }}class SlideViewState extends State 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 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: [ // 加载网络图片 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
detailUrl
3个字段。
注意:本项目的轮播图里没有加入小圆点页面指示器,小伙伴们可自行添加相关代码。
轮播图和列表的组合
上面实现了自定义的轮播图组件,下面就需要将这个组件和列表组合起来。
由于资讯列表的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:[ // 标题充满一整行,所以用Expanded组件包裹 new Expanded( child: new Text(itemData['title'], style: titleTextStyle), ) ], ); // 时间这一行包含了作者头像、时间、评论数这几个 var timeRow = new Row( children: [ // 这是作者头像,使用了圆形头像 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: [ 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: [ // 左边是标题,时间,评论数等信息 new Expanded( flex: 1, child: new Padding( padding: const EdgeInsets.all(10.0), child: new Column( children: [ 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:[ new Tab(text: "动弹列表"), new Tab(text: "热门动弹") ], ), body: new TabBarView( children: [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++) { Mapmap = 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:[ // 用户头像 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: [ 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:[ 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) { // 动弹中有图片 Listlist = imgSmall.split(","); List imgUrlList = new List (); // 开源中国的openapi给出的图片,有可能是相对地址,所以用下面的代码将相对地址补全 for (String s in list) { if (s.startsWith("http")) { imgUrlList.add(s); } else { imgUrlList.add("https://static.oschina.net/uploads/space/" + s); } } List imgList = []; List
> rows = []; num len = imgUrlList.length; // 通过双重for循环,生成每一张图片组件 for (var row = 0; row < getRow(len); row++) { // row表示九宫格的行数,可能有1行2行或3行 List 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 =[ // 这是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:[ 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项目的。
后记
本篇主要记录的是基于Flutter的开源中国客户端各个静态页面的实现,仅限于UI,具体的网络请求,数据存储和其他逻辑在下一篇中做记录。
我的开源项目
- 基于Google Flutter的开源中国客户端,希望大家给个Star支持一下,源码:
- 基于Flutter的俄罗斯方块小游戏,希望大家给个Star支持一下,源码:
上一篇 | 下一篇 |
---|---|