从0开始写一个基于Flutter的开源中国客户端(6)——各个静态页面的实现
上一篇中我记录了基于Flutter的开源中国客户端的整体布局框架的搭建,本篇记录的是每个页面的静态实现,关于具体的数据加载和存储,放在下一篇中记录,希望自己在温故知新的同时,能给Flutter初学者一些帮助。在基于Flutter的开源中国客户端中,使用得最多的就是ListView组件了,基本上80%的页面都需要用列表展示,下面分别说明每个页面的实现过程。侧滑菜单页面的实现上一篇...
上一篇中我记录了基于Flutter的开源中国客户端的整体布局框架的搭建,本篇记录的是每个页面的静态实现,关于具体的数据加载和存储,放在下一篇中记录,希望自己在温故知新的同时,能给Flutter初学者一些帮助。
在基于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
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: <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,具体的网络请求,数据存储和其他逻辑在下一篇中做记录。
我的开源项目
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)