学习完列表渲染后,我们打算做一个综合一点的练习小项目:豆瓣Top电影排行列表。
这个练习小项目主要是为了锻炼布局Widget,但是也涉及到一些其他知识点:评分展示、分割线、bottomNavigationBar等。
这些内容,我们放到后面进行补充,但是在进行豆瓣Top电影模仿时,有两个东西实现起来比较复杂:
- 评分展示: 我们需要根据不同的评分显示不同的星级展示,这里我封装了一个StarRating的小Widget来实现。
-
分割线: 最初我考虑使用
边框虚线
来完成分割线,后来发现Flutter并不支持虚线边框
,因此封装了一个DashedLine的小Widget来实现。
当然,这个章节如果你觉得过于复杂,可以直接把我封装好的两个东西拿过去使用。
一. StarRating
1.1. 最终效果展示
目的:实现功能展示的同时,提供高度的定制效果。
-
rating
:必传参数,告诉Widget当前的评分; -
maxRating
:可选参数,最高评分,根据它来计算一个比例,默认值为10; -
size
:星星的大小,决定每一个star的大小; -
unselectedColor
:未选中星星的颜色(该属性是使用默认的star才有效); -
selectedColor
:选中星星的颜色(该属性也是使用默认的star才有效); -
unselectedImage
:定制未选中的star; -
selectedImage
:定义选中时的star; -
count
:展示星星的个数;
暂时实现上面的定制,后续有新的需求继续添加新的功能点。
1.2. 实现思路分析
理清楚思路后,你会发现并不是非常复杂,主要就是两点的展示:
- 未选中star的展示:根据个数和传入的unselectedImage创建对应个数的Widget即可;
- 选中star的展示:
计算出满star的个数,创建对应的Widget;
计算剩余比例的评分,对最后一个Widget进行裁剪;
问题一:选择StatelessWidget还是StatefulWidget?
考虑到后面可能会做用户点击进行评分或者用户手指滑动评分的效果,所以这里选择StatefulWidget。
- 目前还没有讲解事件监听相关,所以暂时不添加这个功能。
问题二:如何让选中的star
和未选中的star
重叠显示?
- 非常简单,使用Stack即可;
child: Stack(
children: <Widget>[
Row(children: getUnSelectImage(), mainAxisSize: MainAxisSize.min,),
Row(children: getSelectImage(), mainAxisSize: MainAxisSize.min,),
],
),
问题三:如何实现对选中的最后一个star进行裁剪?
- 可以使用ClipRect定制CustomClipper进行裁剪。
定义CustomClipper裁剪规则:
class MyRectClipper extends CustomClipper<Rect>{
final double width;
MyRectClipper({
this.width
});
@override
Rect getClip(Size size) {
return Rect.fromLTRB(0, 0, width, size.height);
}
@override
bool shouldReclip(MyRectClipper oldClipper) {
return width != oldClipper.width;
}
}
使用MyRectClipper进行裁剪:
Widget leftStar = ClipRect(
clipper: MyRectClipper(width: leftRatio * widget.size),
child: widget.selectedImage,
);
1.3. 最终代码实现
最终代码并不复杂,而且我也有给出主要注释:
import 'package:flutter/material.dart';
class HYStarRating extends StatefulWidget {
final double rating; //当前分数
final double maxRating; // 最大分数
final int count; // 星星个数
final double size; // 星星大小
final Color unselectedColor; // 未选中的颜色
final Color selectedColor; // 选中的颜色
final Widget unselectedImage; //未选中的图片
final Widget selectedImage; //选中的图片
// 初始化方法
HYStarRating({
@required this.rating,
this.maxRating = 10,
this.count = 5,
this.size = 30,
this.unselectedColor = const Color(0xffbbbbbb), // 默认值必须是个常量,所以加个const
this.selectedColor = const Color(0xffff0000),
Widget unselectedImage,
Widget selectedImage
// 初始化列表
}): unselectedImage = unselectedImage ?? Icon(Icons.star_border, color: unselectedColor, size: size),
selectedImage = selectedImage ?? Icon(Icons.star, color: selectedColor, size: size);
@override
_HYStarRatingState createState() => _HYStarRatingState();
}
class _HYStarRatingState extends State<HYStarRating> {
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Row(mainAxisSize: MainAxisSize.min, children: buildUnselectedStar()),
Row(mainAxisSize: MainAxisSize.min, children: buildSelectedStar())
],
);
}
// 创建未选中的star
List<Widget> buildUnselectedStar() {
// 如果在state里面想拿到widget里面的数据,直接使用widget即可
return List.generate(widget.count, (index) {
return widget.unselectedImage;
});
}
// 创建选中的star
List<Widget> buildSelectedStar() {
// 1.创建stars
List<Widget> stars = [];
final star = widget.selectedImage;
// 2.构建满填充的star
// 一个星代表几分
double oneValue = widget.maxRating / widget.count;
// floor向下取整,获取有几个整的星
// floor是地板的意思,向下取整,ceil是天花板的意思,向上取整
int entireCount = (widget.rating / oneValue).floor();
for (var i = 0; i < entireCount; i++) {
stars.add(star);
}
// 3.构建部分填充star
// (widget.rating / oneValue) 3.5 - 3 = 0.5 * widget.size
// 计算需要裁剪的宽度
double leftWidth = ((widget.rating / oneValue) - entireCount) * widget.size;
final halfStar = ClipRect(
clipper: HYStarClipper(leftWidth),
child: star
);
stars.add(halfStar);
// 边界处理
if (stars.length > widget.count) {
return stars.sublist(0, widget.count);
}
return stars;
}
}
// 系统提供的抽象类的子类我们用不了,自己实现一个
class HYStarClipper extends CustomClipper<Rect> {
double width;
HYStarClipper(this.width);
@override
Rect getClip(Size size) {
// 返回裁剪的范围
return Rect.fromLTRB(0, 0, width, size.height);
}
@override
bool shouldReclip(HYStarClipper oldClipper) {
// 宽度变化之后才裁剪
return oldClipper.width != this.width;
}
}
二. DashedLine
2.1. 最终实现效果
目的:实现效果的同时,提供定制,并且可以实现水平和垂直两种虚线效果。
-
axis
:虚线的方向; -
dashedWidth
:虚线的宽度; -
dashedHeight
:虚线的高度; -
count
:虚线的个数; -
color
:虚线的颜色;
暂时实现上面的定制,后续有新的需求继续添加新的功能点。
2.2. 实现思路分析
- 首先需要使用Container包裹一下虚线,Container设置宽度或者高度。
- 每个SizedBox就是一个虚线,给虚线设置宽高颜色以后,直接spaceBetween方式排布到Container里面即可。
2.3. 最终代码实现
import 'package:flutter/material.dart';
class HYDashedLine extends StatelessWidget {
final Axis axis; //虚线的方向
final double dashedWidth; // 虚线宽度
final double dashedHeight;// 虚线高度
final int count; // 虚线个数
final Color color;
HYDashedLine({
// 设置默认值
this.axis = Axis.horizontal,
this.dashedWidth = 1,
this.dashedHeight = 1,
this.count = 10,
this.color = Colors.red
});
@override
Widget build(BuildContext context) {
return Flex(
direction: axis,
// 设置虚线的排布方式
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(count, (_) {
// 每一个SizedBox就是一个虚线
return SizedBox(
width: dashedWidth,
height: dashedHeight,
// 给SizedBox设置背景色
child: DecoratedBox(
decoration: BoxDecoration(color: color),
),
);
}),
);
}
}
使用方式如下:
实现效果如下:
三. 实现底部TabBar
在即将开始的小练习中,我们有实现一个底部的TabBar,如何实现呢?
在Flutter中,我们会使用Scaffold来搭建页面的基本结构,实际上它里面有一个属性就可以实现底部TabBar功能:bottomNavigationBar。
bottomNavigationBar对应的类型是BottomNavigationBar,我们来看一下它有什么属性,属性非常多,但是都是设置底部TabBar相关的,我们介绍几个:
-
currentIndex
:当前选中哪一个item; -
selectedFontSize
:选中时的文本大小; -
unselectedFontSize
:未选中时的文本大小; -
type
:当item的数量超过2个时,需要设置为fixed; -
items
:放入多个BottomNavigationBarItem类型; -
onTap
:监听哪一个item被选中;
class BottomNavigationBar extends StatefulWidget {
BottomNavigationBar({
Key key,
@required this.items,
this.onTap,
this.currentIndex = 0,
this.elevation = 8.0,
BottomNavigationBarType type,
Color fixedColor,
this.backgroundColor,
this.iconSize = 24.0,
Color selectedItemColor,
this.unselectedItemColor,
this.selectedIconTheme = const IconThemeData(),
this.unselectedIconTheme = const IconThemeData(),
this.selectedFontSize = 14.0,
this.unselectedFontSize = 12.0,
this.selectedLabelStyle,
this.unselectedLabelStyle,
this.showSelectedLabels = true,
bool showUnselectedLabels,
})
}
当实现了底部TabBar展示后,我们需要监听它的点击来切换显示不同的页面,这个时候我们可以使用IndexedStack来管理多个页面的切换:
main/main.dart代码如下:
import 'package:flutter/material.dart';
import 'initialize_items.dart';
class HYMainPage extends StatefulWidget {
@override
_HYMainPageState createState() => _HYMainPageState();
}
class _HYMainPageState extends State<HYMainPage> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
// 以前我们演示代码的时候,MaterialApp里面有个home属性,我们把home属性设置为HYHomePage
// 然后在HYHomePage里面添加Scaffold,appBar中的appBar就是导航条,body就是我们演示的页面
// 现在body我们要设置为IndexedStack,这个组件的index属性是多少,就会将对应index的page放到最上面
// 这就实现了点击底部tabbar切换page的效果了
body: IndexedStack(
// 当前显示哪个界面
index: _currentIndex,
// 页面数组
children: pages,
),
bottomNavigationBar: BottomNavigationBar(
// 设置文字大小
selectedFontSize: 14,
unselectedFontSize: 14,
selectedItemColor: Colors.red,
// 默认选中哪个item
currentIndex: _currentIndex,
// 当下面的item超过2个的时候,要设置这个属性为fixed,否则文字将会不显示
type: BottomNavigationBarType.fixed,
// items数组
items: items,
// 点击事件
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
),
);
}
}
整个项目的main.dart代码如下:
import 'package:flutter/material.dart';
import 'pages/main/main.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.green,
splashColor: Colors.transparent,
// 默认点击的时候会有水波纹的效果,设置为透明,去掉这个效果
highlightColor: Colors.transparent
),
home: HYMainPage(),
);
}
}
我们把创建item和page的代码抽出来了。
创建item的代码,bottom_bar_item.dart文件如下:
import 'package:flutter/material.dart';
//将这些代码抽到一个方法中是可以的,当然也可以抽到类中,然后将参数传递给父类即可
//使用了初始化列表
class HYBottomBarItem extends BottomNavigationBarItem {
HYBottomBarItem(String iconName, String title)
: super(
title: Text(title),
icon: Image.asset("assets/images/tabbar/$iconName.png", width: 32, gaplessPlayback: true,),
activeIcon: Image.asset("assets/images/tabbar/${iconName}_active.png", width: 32, gaplessPlayback: true,),
);
}
创建items和pages的代码,initialize_items.dart文件如下:
import 'package:flutter/material.dart';
import '../home/home.dart';
import '../subject/subject.dart';
import 'bottom_bar_item.dart';
// 获取到item列表
List<HYBottomBarItem> items = [
HYBottomBarItem("home", "首页"),
HYBottomBarItem("subject", "书影音"),
HYBottomBarItem("group", "小组"),
HYBottomBarItem("mall", "市集"),
HYBottomBarItem("profile", "我的"),
];
List<Widget> pages = [
HYHomePage(),
HYSubjectPage(),
HYSubjectPage(),
HYSubjectPage(),
HYSubjectPage(),
];
首页home.dart代码如下:
import 'package:flutter/material.dart';
import 'home_content.dart';
class HYHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("首页"),
),
body: Text("我是首页"),
);
}
}