案例地址:Flutter_豆瓣案例
一. 数据请求和转化
1.1. 首页数据请求转化
豆瓣数据的获取
这里我使用豆瓣的API接口来请求数据:
模型对象的封装
在面向对象的开发中,数据请求下来并不会像前端那样直接使用,而是封装成模型对象:
- 前端开发者很容易没有面向对象的思维或者类型的思维。
- 但是目前前端开发正在向TypeScript发展,也在帮助我们强化这种思维方式。
为了方便之后使用请求下来的数据,我将数据划分成了如下的模型:
Person、Actor、Director模型:它们会被使用到MovieItem中,home_model.dart代码如下:
class Person {
String name;
String avatarURL;
Person.fromMap(Map<String, dynamic> json) {
this.name = json["name"];
this.avatarURL = json["avatars"]["medium"];
}
}
class Actor extends Person {
Actor.fromMap(Map<String, dynamic> json): super.fromMap(json);
}
class Director extends Person {
Director.fromMap(Map<String, dynamic> json): super.fromMap(json);
}
int counter = 1;
class MovieItem {
int rank;
String imageURL;
String title;
String playDate;
double rating;
List<String> genres;
List<Actor> casts;
Director director;
String originalTitle;
MovieItem.fromMap(Map<String, dynamic> json) {
// 电影排名
this.rank = counter++;
this.imageURL = json["images"]["medium"];
this.title = json["title"];
this.playDate = json["year"];
this.rating = json["rating"]["average"];
this.genres = json["genres"].cast<String>();
// casts里面是演员,转成List后,就可以使用map方法将演员map转成演员模型
this.casts = (json["casts"] as List<dynamic>).map((item) {
return Actor.fromMap(item);
}).toList();
this.director = Director.fromMap(json["directors"][0]);
this.originalTitle = json["original_title"];
}
@override
// 重写这个方法以后,打印的时候会把模型的所有属性都信息都打印出来
String toString() {
return 'MovieItem{rank: $rank, imageURL: $imageURL, title: $title, playDate: $playDate, rating: $rating, genres: $genres, casts: $casts, director: $director, originalTitle: $originalTitle}';
}
}
补充:鼠标选中MovieItem,按command+n
,可以快速生成构造器以及toString方法。
首页数据请求封装以及模型转化
这里我封装了一个专门的类,用于请求首页的数据,这样让我们的请求代码更加规范的管理:HomeRequest。
- 目前类中只有一个方法requestMovieList;
- 后续有其他首页数据需要请求,就继续在这里封装请求的方法;
import 'package:learn_flutter/douban/model/home_model.dart';
import 'config.dart';
import 'http_request.dart';
class HomeRequest {
// 类方法,返回一个Future
static Future<List<MovieItem>> requestMovieList(int start) async {
// 1.构建URL
final movieURL = "/movie/top250?start=$start&count=${HomeConfig.movieCount}";
// 2.发送网络请求获取结果
final result = await HttpRequest.request(movieURL);
final subjects = result["subjects"];
// 3.将Map转成Model
List<MovieItem> movies = [];
for (var sub in subjects) {
movies.add(MovieItem.fromMap(sub));
}
return movies;
}
}
在home_content.dart文件中请求数据
二. 界面效果实现
2.1. 首页整体代码
首页整体布局非常简单,使用一个ListView即可。
import 'package:flutter/material.dart';
import 'package:learn_flutter/douban/model/home_model.dart';
import 'package:learn_flutter/_06_service//home_request.dart';
import 'home_movie_item.dart';
class HYHomeContent extends StatefulWidget {
@override
_HYHomeContentState createState() => _HYHomeContentState();
}
class _HYHomeContentState extend s State<HYHomeContent> {
// 保存数据
final List<MovieItem> movies = [];
@override
void initState() {
super.initState();
// 发送网络请求
HomeRequest.requestMovieList(0).then((res) {
setState(() {
movies.addAll(res);
});
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: movies.length,
itemBuilder: (ctx, index) {
// 渲染数据
return HYHomeMovieItem(movies[index]);
}
);
}
}
2.2. 单独Item局部
下面是针对界面结构的分析:
大家按照对应的结构,实现代码,home_movie_item.dart
文件如下:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:learn_flutter/douban/model/home_model.dart';
import 'package:learn_flutter/douban/utils/log.dart';
import 'package:learn_flutter/douban/widgets/dashed_line.dart';
import 'package:learn_flutter/douban/widgets/star_rating.dart';
class HYHomeMovieItem extends StatelessWidget {
final MovieItem movie;
HYHomeMovieItem(this.movie);
@override
Widget build(BuildContext context) {
return Container(
// 包裹一层Container是为了好设置内边距
padding: EdgeInsets.all(8),
// 底部加边框,设置分隔条
decoration: BoxDecoration(
border:
Border(bottom: BorderSide(width: 8, color: Color(0xffcccccc)))),
child: Column(
// 交叉轴左对齐
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 1. 头部的布局
buildHeader(),
SizedBox(
height: 8,
),
// 2. 内容布局
buildContent(),
SizedBox(
height: 8,
),
// 3. 尾部布局
buildFooter(),
],
),
);
}
// 1.头部的布局
Widget buildHeader() {
return Container(
padding: EdgeInsets.fromLTRB(10, 5, 10, 5),
decoration: BoxDecoration(
color: Color.fromARGB(255, 238, 205, 144),
borderRadius: BorderRadius.circular(3)),
child: Text(
"No.${movie.rank}",
style: TextStyle(fontSize: 18, color: Color.fromARGB(255, 131, 95, 36)),
),
);
}
// 2.内容的布局
Widget buildContent() {
return Row(
// 交叉轴从头开始
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
buildContentImage(),
SizedBox(
width: 8,
),
Expanded(
// 添加IntrinsicHeight组件就能保证内容+虚线+想看高度都是一样的,这样我们就不需要设置虚线和想看的高度了
child: IntrinsicHeight(
child: Row(
children: <Widget>[
buildContentInfo(),
SizedBox(
width: 8,
),
// 虚线
buildContentLine(),
SizedBox(
width: 8,
),
// 想看
buildContentWish()
],
),
),
)
],
);
}
// 2.1.内容的图片
Widget buildContentImage() {
return ClipRRect( // 设置圆角,这种方式简单方便
borderRadius: BorderRadius.circular(8),
child: Image.network(
movie.imageURL,
// 设置高度之后宽度会自适应比例,也就是宽高固定了
height: 150,
));
}
// 2.2.内容的信息
Widget buildContentInfo() {
// 因为左边的图片和让Column都是在一个row里面,如果文字过多,文字会超出屏幕外面
// 所以我们让Column变成可伸缩的,从而不超出屏幕
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
buildContentInfoTitle(),
SizedBox(
height: 8,
),
buildContentInfoRate(),
SizedBox(
height: 8,
),
buildContentInfoDesc()
],
),
);
}
// 2.2.1 内容的信息的标题
Widget buildContentInfoTitle() {
List<InlineSpan> spans = [];
// 图标+电影名称+年份,使用row也是可以实现的,但是以后文字多的时候row无法换行,所以使用如下方式
return Text.rich(
TextSpan(children: [
// 以前我们用的是WidgetSpan+textSpan+textSpan,但是这样会导致前两个不在一条水平线上
// 现在我们三个都使用WidgetSpan,然后前两个设置PlaceholderAlignment.middle,最后一个设置PlaceholderAlignment.bottom
// 就可以让前两个中心点对齐,最后一个底部对齐
// 图标
WidgetSpan(
child: Icon(
Icons.play_circle_outline,
color: Colors.pink,
size: 40,
),
baseline: TextBaseline.ideographic,
alignment: PlaceholderAlignment.middle
),
// 电影名称
// WidgetSpan要么都是一行显示,要么就三行显示,如果电影的名字过长,就无法做到两行显示的效果
// 我们的解决办法是让每个文字都是一个WidgetSpan,runes就是每个文字组成的数组,使用map映射成WidgetSpan的 Iterable,再转成数组,再展开
...movie.title.runes.map((rune) {
return WidgetSpan(child: Text(new String.fromCharCode(rune), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),), alignment: PlaceholderAlignment.middle);
}).toList(),
// 年份
WidgetSpan(child: Text("(${movie.playDate})"), style: TextStyle(fontSize: 18, color: Colors.grey), alignment: PlaceholderAlignment.bottom)
])
);
}
// 2.2.2 星级评分组件
Widget buildContentInfoRate() {
// 当在iPhone5小尺寸手机上的时候,左边图片的宽度是固定的,右边的虚线和想看的宽度也是固定的
// 这时候剩下的宽度也许都不够星星的宽度了,这时候就会报错
// 我们添加FittedBox,这时候如果剩下的宽度不够了,就可以稍微缩小一点,这样就不会报错了
return FittedBox(
child: Row(
children: <Widget>[
// 以前封装的星级插件☆
HYStarRating(
rating: movie.rating,
size: 20,
),
SizedBox(
width: 6,
),
// 星级文字
Text(
"${movie.rating}",
style: TextStyle(fontSize: 16),
)
],
),
);
}
// 2.2.3 电影描述
Widget buildContentInfoDesc() {
// 1.字符串拼接
// 数组元素以空格拼接
final genresString = movie.genres.join(" ");
// 导演
final directorString = movie.director.name;
List<Actor> casts = movie.casts;
// 演员数组取出名字
final actorString = movie.casts.map((item) => item.name).join(" ");
return Text(
"$genresString / $directorString / $actorString",
maxLines: 2, //最多两行
overflow: TextOverflow.ellipsis, //超出以后显示...
style: TextStyle(fontSize: 16),
);
}
// 2.3.内容的虚线
Widget buildContentLine() {
return Container(
// height: 100,
child: HYDashedLine(
axis: Axis.vertical,
dashedWidth: .4,
dashedHeight: 6,
count: 10,
color: Colors.pink,
),
);
}
// 2.4.内容的想看
Widget buildContentWish() {
return Container(
// height: 100,
child: Column(
// 包裹一个Container,再设置为center,让❤和想看垂直居中
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image.asset("assets/images/home/wish.png"),
Text(
"想看",
style: TextStyle(
fontSize: 18,
color: Color.fromARGB(255, 235, 170, 60)
),
)
],
),
);
}
// 3.尾部的布局
Widget buildFooter() {
return Container(
// 默认的宽度是内容包裹的宽度,设置为最大
width: double.infinity,
// 内边距
padding: EdgeInsets.all(8),
// 圆角
decoration: BoxDecoration(
color: Color(0xfff2f2f2),
borderRadius: BorderRadius.circular(6),
),
child: Text(
movie.originalTitle,
style: TextStyle(fontSize: 20, color: Color(0xff666666)),
),
);
}
}
补充:Flutter默认的print打印只会打印信息,并没有所在行的信息,所以我们自定义一个打印:
void hyLog(Object message, StackTrace current) {
HYCustomTrace programInfo = HYCustomTrace(current);
print("所在文件: ${programInfo.fileName}, 所在行: ${programInfo.lineNumber}, 打印信息: $message");
}
class HYCustomTrace {
final StackTrace _trace;
String fileName;
int lineNumber;
int columnNumber;
HYCustomTrace(this._trace) {
_parseTrace();
}
void _parseTrace() {
var traceString = this._trace.toString().split("\n")[0];
var indexOfFileName = traceString.indexOf(RegExp(r'[A-Za-z_]+.dart'));
var fileInfo = traceString.substring(indexOfFileName);
var listOfInfos = fileInfo.split(":");
this.fileName = listOfInfos[0];
this.lineNumber = int.parse(listOfInfos[1]);
var columnStr = listOfInfos[2];
columnStr = columnStr.replaceFirst(")", "");
this.columnNumber = int.parse(columnStr);
}
}
使用方式如下:
hyLog("aaaaaa", StackTrace.current);