学习完列表渲染后,我打算做一个综合一点的练习小项目:豆瓣Top电影排行列表;效果图如下:
这个项目主要是为了锻炼Widget的布局,也设计到一些其他的知识,评分展示、分割线、底部工具栏
在进行豆瓣评分模仿时,有两个部件实现起来比较困难
- 评分展示:我们需要根据不同的评分显示不用的星级展示,这里封装一个
start_rating 的widget来实现 - 分割线:flutter中好像并没有边框虚线,因此我们需要封装一个
DashedLine 的Widget来实现
1. start_rating(评分)widget
1.1 最终效果展示
目的:实现功能的同时,实现高度的定制效果
rating :必传参数,当前的评分maxRating :可选参数,最高评分,根据她来计算一个比例,默认值为10count :可选参数,表示星星的个数,默认值为5ratingSize :可选参数,表示星星的带下,默认值:30normalColor :可选参数,表示星星的原始颜色,默认grey.selectedColor : 可选参数,表示星星选中的颜色, 默认orangeunselectedWidget :可选参数, 未选中时的widget,默认参数通过初始化列表初始化一个IconselectedWidget :可选参数,选中时展示的widget,默认参数通过初始化列表初始化一个Icon
暂时实现以上的目标,后期如果有需求可以添加新的功能点
1.2 实现思路分析
- 未选中start展示:根据个数和传入的
unselectedWidget 创建对应个数的widget即可 - 选中start
- 计算出满start的个数,创建对应的wiget
- 计算剩余比例的评分,对最后一个widget进行裁剪
问题一:选择StatelessWidget还是StatefulWidget?
- 考虑到后面可能会做用户点击进行评分或者用户手指滑动评分的效果,所以这里选择StatefulWidget
- 但是目前我还没有学习到事件的监听,所以暂时不添加这个功能
问题二:如何让选中的star和未选中的star重叠显示?
- 其实使用我们前面学习
stack 这个widget即可
child: Stack(
children:[
Row(mainAxisSize: MainAxisSize.min, children: buildNormalSatrt()),
Row(mainAxisSize: MainAxisSize.min, children: buildSelectedSart())
],
),
问题三:如何对最好选中的start进行裁剪?
- 可以使用ClipRect定制CustomClipper进行裁剪
- 定义CustomClipper裁剪规则
class GYStarClipper extends CustomClipper<Rect> {
final double width;
GYStarClipper(this.width);
@override
Rect getClip(Size size) {
return Rect.fromLTRB(0, 0, width, size.height);
}
@override
bool shouldReclip(GYStarClipper oldClipper) {
return oldClipper.width != width;
}
}
final clipStar = ClipRect(
child: star,
clipper: GYStarClipper(leftValue * this.widget.ratingSize),
);
1.3 最终实现代码
import 'package:flutter/material.dart';
class GYStartRating extends StatefulWidget {
final double rating;
final double maxRating;
final int count;
final double ratingSize;
final Color normalColor;
final Color selectedColor;
final Widget unselectedWidget;
final Widget selectedWidget;
GYStartRating({
Key? key,
required this.rating,
this.maxRating = 10,
this.count = 5,
this.ratingSize = 30,
this.normalColor = Colors.grey,
this.selectedColor = Colors.orange,
Widget? unselectedWidget,
Widget? selectedWidget
}) : unselectedWidget = unselectedWidget ?? Icon(Icons.star_border, size: ratingSize,color: normalColor,),
selectedWidget = selectedWidget ?? Icon(Icons.star, size: ratingSize, color: selectedColor,),
super(key: key);
@override
_GYStartRatingState createState() => _GYStartRatingState();
}
class _GYStartRatingState extends State<GYStartRating> {
@override
Widget build(BuildContext context) {
return Center(
child: Stack(
children:[
Row(mainAxisSize: MainAxisSize.min, children: buildNormalSatrt()),
Row(mainAxisSize: MainAxisSize.min, children: buildSelectedSart())
],
),
);
}
List<Widget> buildNormalSatrt(){
return List.generate(this.widget.count, (index) {
return this.widget.unselectedWidget;
});
}
List<Widget> buildSelectedSart() {
List<Widget> starts = [];
final star = this.widget.selectedWidget;
double oneValue = this.widget.maxRating / this.widget.count;
int entireCount = (this.widget.rating / oneValue).floor();
for(var i = 0; i < entireCount; i++) {
starts.add(star);
}
double leftValue = (this.widget.rating / oneValue) - entireCount;
final clipStar = ClipRect(
child: star,
clipper: GYStarClipper(leftValue * this.widget.ratingSize),
);
starts.add(clipStar);
return starts;
}
}
class GYStarClipper extends CustomClipper<Rect> {
final double width;
GYStarClipper(this.width);
@override
Rect getClip(Size size) {
return Rect.fromLTRB(0, 0, width, size.height);
}
@override
bool shouldReclip(GYStarClipper oldClipper) {
return oldClipper.width != width;
}
}
2. DashedLine(虚线)Widget
2.1 实现思路和代码
-
参数介绍:
axis :确定虚线的方向dashedWidth :根据虚线的方向来设置虚线的宽度dashedHeight :根据虚线的方向来设置虚线的高度color :虚线的颜色dashedSpaceWitdh :虚线的间隔 -
思路分析:
- 虚线的个数是根据容器的宽度, 和传入的虚线宽高,虚线的间隔 来计算虚线的高度的
- 这里是根据方向,获取父容器的宽度和高度来决定的
- 通过
LayoutBuilder widget可以获取到父widget的宽度和高度 -
最终代码实现:
import 'package:flutter/material.dart';
class GYDashedLine extends StatelessWidget {
final Axis axis;
final double dashedWidth;
final double dashedHeight;
final Color color;
final double dashedSpaceWitdh;
GYDashedLine({this.axis = Axis.horizontal,
this.dashedWidth = 1,
this.dashedSpaceWitdh = 5,
this.dashedHeight = 1,
this.color = Colors.orange});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints){
int count = 0;
var direction = this.axis == Axis.horizontal ? true : false;
if (direction) {
count = (constraints.biggest.width / (this.dashedWidth + this.dashedSpaceWitdh)).floor();
} else {
count = (constraints.biggest.height / (this.dashedHeight + this.dashedSpaceWitdh)).floor();
}
return Flex(
direction: axis,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(count, (index) {
return SizedBox(
width: this.dashedWidth,
height: this.dashedHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: this.color
),
),
);
}),
);
},
);
}
}
2.2 LayoutBuilder(获取父组件的大小)
const LayoutBuilder({
Key? key,
required LayoutWidgetBuilder builder,
}) : assert(builder != null),
super(key: key, builder: builder);
typedef LayoutWidgetBuilder = Widget Function(BuildContext context, BoxConstraints constraints);
LayoutBuilder(
builder: (context,constraints){
context为父级上下文
constraints.biggest.height; 获取组件在父组件所能设置的最大高度
contraints.maxWidth; 获取父组件宽度,高度同理
return 组件
}
)
3. 实现底部TabBar
3.1 tabBar的实现说明
在Flutter 中,我们会使用Scaffold 来搭建页面的基本结构,实际上它里面有一个属性就可以实现底部TabBar 功能:bottomNavigationBar 。
bottomNavigationBar对应的类型是BottomNavigationBar,我们来看一下它有什么属性:
BottomNavigationBar({
Key? key,
required this.items,
this.onTap,
this.currentIndex = 0,
this.elevation,
this.type,
Color? fixedColor,
this.backgroundColor,
this.iconSize = 24.0,
Color? selectedItemColor,
this.unselectedItemColor,
this.selectedIconTheme,
this.unselectedIconTheme,
this.selectedFontSize = 14.0,
this.unselectedFontSize = 12.0,
this.selectedLabelStyle,
this.unselectedLabelStyle,
this.showSelectedLabels,
this.showUnselectedLabels,
this.mouseCursor,
this.enableFeedback,
})
当我们实现底部Tabbar之后我们需要监听它的点击来切换不同的页面,这个时候我们可以使用IndexStack widget来管理多个页面的切换
class GYMainPage extends StatefulWidget {
const GYMainPage({Key? key}) : super(key: key);
@override
_GYMainPageState createState() => _GYMainPageState();
}
class _GYMainPageState extends State<GYMainPage> {
var _currentIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
children: pages,
index: _currentIndex,
),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
selectedFontSize: 14,
unselectedFontSize: 14,
currentIndex: _currentIndex,
items: items,
onTap: (index){
setState(() {
_currentIndex = index;
});
},
),
);
}
}
List<GYBottomBarItem> items = [
GYBottomBarItem("home", "首页"),
GYBottomBarItem("subject", "书影音"),
GYBottomBarItem("group", "小组"),
GYBottomBarItem("mall", "市集"),
GYBottomBarItem("profile", "我的"),
];
- 注意:
- 当你的item大于等于4个后,tababr会自动隐藏你的文本,这个时候我们需要设置
bottomNavigationBar 中的type 属性值为BottomNavigationBarType.fixed ,这个时候文字才能显现出来
3.2 最终代码实现
import 'package:flutter/material.dart';
import 'widget/start_rating.dart';
import 'widget/dashedline.dart';
import 'pages/main/initialize_times.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primarySwatch: Colors.green,
splashColor: Colors.transparent,
highlightColor: Colors.transparent),
home: GYMainPage());
}
}
class GYMainPage extends StatefulWidget {
const GYMainPage({Key? key}) : super(key: key);
@override
_GYMainPageState createState() => _GYMainPageState();
}
class _GYMainPageState extends State<GYMainPage> {
var _currentIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
children: pages,
index: _currentIndex,
),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
selectedFontSize: 14,
unselectedFontSize: 14,
currentIndex: _currentIndex,
items: items,
onTap: (index){
setState(() {
_currentIndex = index;
});
},
),
);
}
}
import 'package:flutter/material.dart';
import '../home/home.dart';
import '../subject/subject.dart';
import '../group/group.dart';
import '../mall/mall.dart';
import '../profile/profile.dart';
import 'bottom_bar_item.dart';
List<GYBottomBarItem> items = [
GYBottomBarItem("home", "首页"),
GYBottomBarItem("subject", "书影音"),
GYBottomBarItem("group", "小组"),
GYBottomBarItem("mall", "市集"),
GYBottomBarItem("profile", "我的"),
];
List<Widget> pages = [
GYHomePage(),
GYSubjectPage(),
GYGropuPage(),
GYMallPage(),
GYProfilePage()
];
4. 首页数据请求
目前我并没有学习到详细的网络请求相关知识,所以这里网络请求是基于前面学习到的Dio 库的一个简单的使用
本来是采用豆瓣数据的接口来请求数据,但是现在发现豆瓣的API都无法使用了,所以这里我们直接把相关电影的JSON 数据放到本地的文件中,然后解析数据
4.1 模型数据的封装
json数据格式如下
class GYDataHandleTools {
static Future<List<GYMovieItem>> loadJsonData() async {
List<GYMovieItem> movieList = <GYMovieItem>[];
String jsonString = await rootBundle.loadString("assets/tempData.json");
final jsonResult = json.decode(jsonString);
if (jsonResult is Map) {
var data = jsonResult["data"];
var subject = data["subject"];
for (Map<String, dynamic> map in subject) {
movieList.add(GYMovieItem.forMap(map));
}
}
return movieList;
}
}
class GYMovieItem {
String movied_id = "";
String movied_img = "";
String movied_title_china = "";
String movied_title_english = "";
String movied_title_hk = "";
String movied_average = "";
String movied_evaluation = "";
String movied_director = "";
String movied_plot = "";
String movied_quote = "";
GYMovieItem.forMap(Map<String, dynamic> json) {
this.movied_id = json["id"];
this.movied_img = json["img"];
this.movied_title_china = json["title_china"];
this.movied_title_english = json["title_english"];
this.movied_title_hk = json["title_hk"];
this.movied_average = json["average"];
this.movied_evaluation = json["evaluation"];
this.movied_director = json["director"];
this.movied_plot = json["plot"];
this.movied_quote = json["quote"];
}
}
|