效果视频
功能简述
功能
- Top250榜单
- 电影详情
- 电影收藏列表
- 模糊搜索
- 搜索记录
- 搜索列表
- 清空搜索记录
- 侧滑删除收藏影片
第三方库
名称 | 备注 |
---|
dio | 网路库 | provider | 状态管理 | sqlfite | 数据库 | fluttertoast | Toast提示 | flutter_swiper | Banner | pk_skeleton | 骨架屏 |
接口简述
由于豆瓣接口现在很多都有了限制,此项目使用的是Github某人整理的接口地址,能用的总共有四个接口,其中搜索相关的两个接口,30s内只允许访问一次,连续访问会出现code:429,访问频繁异常 ,剩余两个接口没有限制
`https://api.wmdb.tv/api/v1/top?type=Imdb&skip=0&limit=50&lang=Cn`
https://api.wmdb.tv/movie/api?id=doubanid
https://api.wmdb.tv/api/v1/movie/search?q=英雄本色&limit=10&skip=0&lang=Cn&year=2002
https://api.wmdb.tv/movie/api/generateimage?doubanId=1306123&lang=En
底部导航栏
每一个BottomNavigationBarItem 的背景颜色都不相同,每次初始化都是随机获取
效果图
实现
初始化BottomNavigationBarItem
三个Item的背景颜色通过随机进行获取
///底部导航栏数据
final pageList = [const HomePage(),const SearchPage(),const MinePage()];
final navList = [
BottomNavigationBarItem(
icon: const Icon(Icons.home),
label: '首页',
backgroundColor: Color.fromARGB(255, Random().nextInt(255), Random().nextInt(255), Random().nextInt(255))
),
BottomNavigationBarItem(
icon: const Icon(Icons.search),
label: '搜索',
backgroundColor: Color.fromARGB(255, Random().nextInt(255), Random().nextInt(255), Random().nextInt(255))
),
BottomNavigationBarItem(
icon: const Icon(Icons.person),
label: '我的',
backgroundColor: Color.fromARGB(255, Random().nextInt(255), Random().nextInt(255), Random().nextInt(255))
)
];
bottomNavigationBar
定义记录当前页面序号
var _currentPage = 0;
- items:对应的是Navigation的子项
- currentIndex:当前页面的序号
- type:底部导航栏的显示方式
- onTap:点击事件
bottomNavigationBar: BottomNavigationBar(
items: navList,
currentIndex: _currentPage,
type: BottomNavigationBarType.shifting,
onTap: (index){
_changeIndex(index);
},
)
切换页面事情,做一个简单判断,防止点击当前页面重复执行
void _changeIndex(int index){
if(index != _currentPage){
setState(() {
_currentPage = index;
});
}
}
切换页面导致bottomNavigationBar子页面重绘
通过使用IndexedStack ,在进行子页面进行切换的时候就不会重新进行加载
body: IndexedStack(
index: _currentPage,
children: pageList,
)
Top250榜单
通过FutureBuilder 执行网络请求方法,然后判断数据是否返法,在waiting 状态下显示骨架屏loading ,等数据返回之后通知Provider进行刷新,然后在局部刷新电影ListView
效果图
实现
Widget树
次Widget只是通过Column和Row进行一个简单的描述,没有完全绘制整个Widget树
FutureBuilder
其中getTopList()是获取Top250榜单的网络请求,具体内容网络封装请看Dio封装
获取Top250榜单数据并进行解析
Future<List<ITopEntity>?> getTopList(int skip,int limit) async {
var url = HttpPath.getTop250(skip, limit);
final result = await HttpUtil.getInstance().get(url, null);
return jsonConvert.convertListNotNull<ITopEntity>(result);
}
构建FutureBuilder
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(10.0),
color: Colors.white,
child: FutureBuilder(
future: getTopList(skip, limit),
builder: _buildFutureBuild
),
);
}
然后对FutureBuilder 每个状态进行监听,其中waiting 状态返回的是骨架屏组件,然后在数据返回之后开始构建ListView
Widget _buildFutureBuild(BuildContext context, AsyncSnapshot<List<ITopEntity>?> snapshot){
switch(snapshot.connectionState){
case ConnectionState.none:
return const Text('未开始网络请求...');
case ConnectionState.active:
return const Text('开始网络请求...');
case ConnectionState.waiting:
return PKCardPageSkeleton(totalLines: 6);
case ConnectionState.done:
if(snapshot.hasError) {
return const Text('请求过于频繁,请稍后再试...');
}else{
if(snapshot.data == null) {
return const Center(child: Text('请求过于频繁,请稍后再试...'));
} else {
Widget widget = _buildMovie(snapshot.data!);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_loadMovieList(snapshot.data!);
});
return widget;
}
}
}
}
异常
当我在使用FutureBuilder 构建ListView的时候,当数据返回时,我需要通过Provider 数据已经获取,可以开始刷新,但是我当前的ListView正在构建中,所以它会提示如下警告,但是不影响代码执行,我通过将数据刷新用帧回调 进行监听,意味按顺序执行,只有我的布局构建完成之后,才会进行此回调并且此回调只会执行一次。
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_loadMovieList(snapshot.data!);
});
// The following assertion was thrown while dispatching notifications for MovieProvider:
// setState() or markNeedsBuild() called during build.
//
// This _InheritedProviderScope<MovieProvider> widget cannot be marked as needing to build because the framework is already in the process of building widgets. A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase.
// The widget on which setState() or markNeedsBuild() was called was: _InheritedProviderScope<MovieProvider>
// value: Instance of 'MovieProvider'
// listening to value
// The widget which was currently being built when the offending call was made was: FutureBuilder<List<ITopEntity>?>
// dirty
// state: _FutureBuilderState<List<ITopEntity>?>#aba8e
ListView上拉加载
- 定义上拉加载控制器
final ScrollController _scrollController = ScrollController();
- 添加上拉监听
///这个判断相当于屏幕底部高度和手指滑动到底部高度一致时执行上拉操作
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if(_scrollController.position.pixels == _scrollController.position.maxScrollExtent){
skip += limit;
_loadMovieData();
}
});
}
使用provider 进行状态管理,然后将获取的数据进行添加,并进行局部刷新
///上拉加载
Future _loadMovieData() async{
await getTopList(skip,limit).then((value){
final movieProvider = context.read<MovieProvider>();
if(value != null){
movieProvider.setList = value;
}
});
}
- 构建ListView
///电影ListView
///电影Provider修改作用域
Widget _buildMovie(List<ITopEntity> entityList){
return Consumer<MovieProvider>(
builder: (ctx,movieProvider,child){
return ListView.builder(
itemCount: movieProvider.getList.length,
controller: _scrollController,
itemBuilder:(context,index){
return createMovieItem(movieProvider.getList[index],index);
});
},
);
}
电影详情
效果图
实现
依旧使用FutureBuilder实现数据请求然后在数据返回之后进行填充
高斯模糊
背景图片是通过回调之后数据,然后进行模糊处理,模糊比例可以通过传入sigmaX 和sigmaY 值进行修改
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX:10,sigmaY: 10),
child: Image.network(
snapshot.data?.data[0].poster ?? '',
fit: BoxFit.fill,
width: double.infinity,
height: double.infinity,),
)
网络数据获取
通过传入的doubanId 作为参数进行网络请求,然后进行解析返回
///电影详情数据解析
Future<IMovieDetailEntity> getMovieDetail(String doubanid) async{
var url = HttpPath.getMovieDetail(doubanid);
final result = await HttpUtil.getInstance().get(url, null);
return IMovieDetailEntity.fromJson(result);
}
电影详情
电影海报和下面由白色背景的包裹的电影信息描述,通过使用Stack 和Position 进行显示,同时使用了SingleChildScrollView 滑动组件进行包裹。
///电影海报和电影描述重叠
Widget buildMovieDetail(BuildContext context,IMovieDetailEntity? entity){
if(entity == null) return const Text('请求过于频繁,请稍后再试...');
final height = MediaQuery.of(context).size.height;
final width = MediaQuery.of(context).size.width;
return SingleChildScrollView(
scrollDirection: Axis.vertical,
physics: const BouncingScrollPhysics(),
reverse: false,
child: Container(
width: width,
height: height,
padding: const EdgeInsets.only(top: 20.0),
//color: Colors.grey,
alignment: Alignment.topCenter,
child: Stack(
children: [
_buildMovieDescribe(entity),
Positioned(
top: 0,
left: 0,
right: 0,
child: buildMovieImage(entity.data[0].poster,250.0))
],
),
),
);
}
具体内容通过Column 布局垂直显示,由于篇幅原因,具体子Widget展示内容就不进行介绍
///电影描述
Widget _buildMovieDescribe(IMovieDetailEntity entity){
return Container(
width: double.infinity,
height: double.infinity,
margin: const EdgeInsets.only(top: 180.0,left: 15.0,right: 15.0,bottom: 20.0),
decoration: const BoxDecoration(
shape: BoxShape.rectangle,
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(10.0))
),
child: Padding(
padding: const EdgeInsets.only(top: 100.0,left: 10,right: 10,bottom: 0),
child:Column(
children: [
getText(entity.data[0].name,textSize: 20.0,fontWeight: FontWeight.bold),
const SizedBox(height: 5.0),
getText(entity.data.length > 1 ? entity.data[1].name : 'unknown',textSize: 16.0,color: Colors.grey),
const SizedBox(height: 20.0),
_buildYearCountryWidget(entity),
const SizedBox(height: 10.0),
_buildGenreWidget(entity),
const SizedBox(height: 20.0),
_buildCollection(entity),
const SizedBox(height: 20.0),
getText(entity.data[0].description,textSize: 12,color: Colors.black,maxLine: 10),
const SizedBox(height: 10.0),
getText(entity.data.length > 1 ? entity.data[1].description : 'unknown...',textSize: 12,color: Colors.black,maxLine: 10),
const SizedBox(height: 20.0),
splitLine,
const SizedBox(height: 20.0),
_buildActorRowWidget('作者:',entity.writer,0),
const SizedBox(height: 10.0),
_buildActorRowWidget('导演:',entity.director,1),
const SizedBox(height: 10.0),
_buildActorRowWidget('演员:',entity.actor,2),
const SizedBox(height: 10.0),
_buildMovieDateWidget('日期:',0,1,entity.dateReleased.isNotEmpty ? entity.dateReleased: '未知'),
const SizedBox(height: 10.0),
_buildMovieDateWidget('片长:',entity.duration,2)
],
),
)
);
}
模糊搜索
效果图
实现
搜索内容与返回数据字段使用富文本高亮显示进行区别
Widget _buildSearchListView(String key) {
if(key.isNotEmpty && key == _lastSearch){
return defaultContent!;
}else{
_lastSearch = key;
}
Widget displayContent = const Center(child: CircularProgressIndicator());
List<ITopEntity> resultList = [];
getSearchList(key)
.then((value) => {
if(value != null){
resultList = value
}})
.catchError(
(e) => {displayContent = const Center(child: Text('加载失败!'))})
.whenComplete(() => {
displayContent = ListView.builder(
itemCount: resultList.length,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.search, size: 20),
title: Transform(
transform: Matrix4.translationValues(-16, 0.0, 0.0),
child: RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
text: resultList[index].data[0].name.substring(0, key.length),
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold),
children: [
TextSpan(
text: resultList[index].data[0].name.substring(key.length),
style: const TextStyle(color: Colors.grey),
)
]),
),
),
onTap: () {
exeSearchInsert(SearchRecord(resultList[index].data[0].name.toString()), context);
Navigator.of(context).push(MaterialPageRoute(builder: (context) => SearchResultPage(resultList[index].data[0].name)));
},
);
}),
setState(() {
if (resultList.isEmpty) {
displayContent = const Center(child: Text('没有搜索到相关内容!'));
}else{
defaultContent = displayContent;
}
}),
});
return displayContent;
}
搜索记录
效果图
实现
通过sqlite 数据库进行本地存储,具体内容请看SQLite数据的使用与封装,此处只介绍数据插入和记录清空,以及布局构建
历史搜索
历史记录标题,并通过GestureDetector 赋予其点击事件,并在点击事件中处理清空操作
Widget _buildHistory(){
return Column(
children: [
GestureDetector(
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
getText('历史记录',color: Colors.black,textSize: 16.0,fontWeight: FontWeight.bold),
const Icon(Icons.delete_forever,color: Colors.grey)
]
),
onTap: (){
exeSearchClear(context);
},
),
Flexible(child: _buildHistoryList())
],
);
}
搜索列表
通过GridView 建立每行4列的一个列表,因为数据会随着搜索的变化而变化,所以通过provider 进行状态管理,当数据进行插入时,通知监听处进行局部更新。
参数 | 备注 |
---|
crossAxisCount | 列数 | mainAxisSpacing | 主轴之间间距 | crossAxisSpacing | 交叉轴之间间距 | childAspectRatio | item的宽高比 |
Widget _buildHistoryList() {
return Container(
margin: const EdgeInsets.only(top: 10.0),
child: Consumer<SearchProvider>(
builder: (ctx, searchProvider, child) {
return GridView.builder(
itemCount: searchProvider.searchList.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 3),
itemBuilder: (ctx, index) {
return _buildHistoryItem(searchProvider.searchList[index].searchKey);
});
}),
);
}
Widget _buildHistoryItem(String key){
return GestureDetector(
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(5.0),
decoration: const BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.all(Radius.circular(10.0))
),
child: getText(key,color: Colors.white),
),
onTap: (){
exeSearchInsert(SearchRecord(key),context);
Navigator.of(context).push(MaterialPageRoute(builder: (context) => SearchResultPage(key)));
},
);
}
插入记录
首先判断数据库中是否存在相同内容,如果存在则不进行处理,反之插入数据库,然后通知provider 修改数据源
exeSearchInsert(SearchRecord(_searchContent.toString()), context);
exeSearchInsert(SearchRecord entity,BuildContext context) async {
bool isExist = await SearchDao.getInstance().isExist(entity.searchKey);
if(!isExist){
final provider = context.read<SearchProvider>();
provider.addSingleBean = entity;
await SearchDao.getInstance().insert(entity);
}
}
清空记录
同样分为两步,一步是清空数据库,另一部是情况provider 持有的数据源
exeSearchClear(BuildContext context) async{
final provider = context.read<SearchProvider>();
if(provider.searchList.isEmpty){
showFailedToast('记录为空,无需清空!');
return;
}
int count = await SearchDao.getInstance().deleteAll();
if(count > 0){
provider.clear();
showSuccessToast('清空完成!');
}else{
showFailedToast('清空失败!');
}
}
搜索结果
效果图
实现
获取网络数据
Future<List<ITopEntity>?> getSearchList(String key) async {
var url = HttpPath.getSearchContent(key);
final result = await HttpUtil.getInstance().get(url, null);
return jsonConvert.convertListNotNull<ITopEntity>(result);
}
搜索结果列表
同样使用FutureBuilder ,在其future 参数中传入上述网络请求,然后在builder 监听数据状态,并建立数据列表
Widget _buildMovieList(List<ITopEntity>? entityList){
if(entityList == null || entityList.length == 0) return const Center(child: Text('没有搜索到相关内容!'));
return ListView.builder(
itemCount: entityList.length,
itemBuilder: (BuildContext context, int index){
return Container(
padding: const EdgeInsets.all(10.0),
child: MovieItem(
MovieFavorite(
entityList[index].doubanId,
entityList[index].data[0].poster,
entityList[index].data[0].name,
entityList[index].data[0].country,
entityList[index].data[0].language,
entityList[index].data[0].genre,
entityList[index].data[0].description
)
),
);
});
}
电影Item
由于此处的电影item与收藏列表中的item一致,所以封装成啦一个StatelessWidget 无状态的Widget
class MovieItem extends StatelessWidget {
final MovieFavorite entity;
const MovieItem(this.entity,{Key? key}) : super(key: key);
Widget _buildItemRight(MovieFavorite entity,double height){
return Container(
height: height,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 10.0),
getText(entity.movieName,textSize: 16.0,fontWeight: FontWeight.bold,color: Colors.black),
const SizedBox(height: 10.0),
getText(entity.movieCountry,textSize: 12.0),
const SizedBox(height: 5.0),
getText(entity.movieLanguage,textSize: 12.0),
const SizedBox(height: 5.0),
getText(entity.movieGenre.isNotEmpty ? entity.movieGenre : '未知',textSize: 12.0),
const SizedBox(height: 5.0),
Expanded(child: getText(entity.movieDescription,textSize: 12.0,color: Colors.grey,maxLine: 5)),
const SizedBox(height: 10.0),
],
),
);
}
Widget _buildMovieItem(MovieFavorite entity,BuildContext context){
return GestureDetector(
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildMovieImage(entity.moviePoster, 200),
const SizedBox(width: 10.0),
Expanded(child: _buildItemRight(entity,200))
],
),
onTap: (){
Navigator.of(context).push(MaterialPageRoute(builder: (context) => MovieDetailPage(entity.doubanId, entity.movieName)));
},
);
}
@override
Widget build(BuildContext context) {
return Container(
child: _buildMovieItem(entity,context),
);
}
}
影片收藏
效果图
实现
由于收藏的影片item和搜索结果的影片item一致,封装成一个StatelessWidget 无状态的Widget,在上述已经展示,此处就不在冗余
添加收藏
只有当添加的影片在数据库中没有相对应的实例时,才能被添加,然后通知provider 进行数据修改,通过使用Toast进行提示
exeFavoriteInsert(
MovieFavorite(
entity.doubanId,
entity.data[0].poster,
entity.data[0].name,
entity.data[0].country,
entity.data[0].language,
entity.data[0].genre,
entity.data[0].description),
context
);
exeFavoriteInsert(MovieFavorite entity,BuildContext context) async {
bool isExist = await FavoriteDao.getInstance().isExist(entity.doubanId);
if(!isExist){
int flag = await FavoriteDao.getInstance().insert(entity);
final favoriteProvide = context.read<FavoriteProvider>();
final List<MovieFavorite> list = [entity];
favoriteProvide.favoriteList = list;
if (flag > 0) {
showSuccessToast('收藏成功!');
} else {
showFailedToast('收藏失败!');
}
}else{
showFailedToast('此影片已被收藏,请勿重复添加!');
}
}
侧滑删除
效果图
实现
Dismissible侧滑
侧滑删除通过Dismissible 实现,其中从左到右滑动不进行删除,只显示蓝色背景一段文字,内容右background 提供,返回的是一个Widget ;从右到左滑动会显示红色背景文字,由secondaryBackground 提供,并显示删除提示框。重点是confirmDismiss 方法,他需要返回的是Future<bool?> 类型值,如果返回ture 则从当前列表删除,此删除只是一个静态的,从当前列表移除该元素,具体删除逻辑需要自己处理,返回false 则不移除该元素
Widget _buildFavoriteList(){
return Container(
margin: const EdgeInsets.all(10.0),
child: Consumer<FavoriteProvider>(
builder: (context,favoriteProvider,child){
if(favoriteProvider.favoriteList.isEmpty){
return const Center(child: Text('暂未收藏任何影片!'));
}else{
return ListView.builder(
itemCount: favoriteProvider.favoriteList.length,
itemBuilder: (BuildContext context, int index){
return Dismissible(
key: Key(favoriteProvider.favoriteList[index].movieName),
background: Container(
alignment: Alignment.center,
color: Colors.blue,
child: ListTile(
leading: const Icon(Icons.tag_faces_rounded,color: Colors.white,),
title: getText('就不让你删,气死你!!!',color: Colors.white))
),
secondaryBackground: Container(
alignment: Alignment.center,
color: Colors.red,
child: ListTile(
leading: const Icon(Icons.delete_forever,color: Colors.white),
title: getText('删就删咯,反正不爱了...',color: Colors.white))),
confirmDismiss: (direction) async{
switch(direction){
case DismissDirection.endToStart:
return await _showDeleteDialog(context, favoriteProvider.favoriteList[index],index,favoriteProvider) == true;
case DismissDirection.vertical:
case DismissDirection.horizontal:
case DismissDirection.startToEnd:
case DismissDirection.up:
case DismissDirection.down:
case DismissDirection.none:
break;
}
return false;
},
child: Container(
padding: const EdgeInsets.all(10.0),
child: MovieItem(favoriteProvider.favoriteList[index]),
),
);
});
}
},
)
);
}
Dismissible 的滑动方向如下所示,包括其中模式
enum DismissDirection {
/// The [Dismissible] can be dismissed by dragging either up or down.
vertical,
/// The [Dismissible] can be dismissed by dragging either left or right.
horizontal,
/// The [Dismissible] can be dismissed by dragging in the reverse of the
/// reading direction (e.g., from right to left in left-to-right languages).
endToStart,
/// The [Dismissible] can be dismissed by dragging in the reading direction
/// (e.g., from left to right in left-to-right languages).
startToEnd,
/// The [Dismissible] can be dismissed by dragging up only.
up,
/// The [Dismissible] can be dismissed by dragging down only.
down,
/// The [Dismissible] cannot be dismissed by dragging.
none
}
删除提示框
从右到左滑动返回true ,即代表移除该元素,上述也进行了说明,如果该函数返回的也是true 则移除,反之亦然
return await _showDeleteDialog(context, favoriteProvider.favoriteList[index],index,favoriteProvider) == true;
通过AlertDialog 实现提示框,一个标题、一个内容、一个确定按钮、一个取消按钮,按钮分别处理不同的逻辑,清晰可见,可是在代码中并没有体现返回的bool 值是什么。看源码可以看出,从堆移除当前堆顶context ,除了上下文之外,还有一个bool 值,此值就是dialog 返回的值
/// A dialog box might be closed with a result:
///
/// ```dart
/// void _accept() {
/// Navigator.pop(context, true); // dialog returns true
/// }
/// ```
Future<bool?> _showDeleteDialog(BuildContext context, MovieFavorite bean,int index,FavoriteProvider provider) {
return showDialog<bool>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return AlertDialog(
title: getText('系统提示',textAlign: TextAlign.center,fontWeight: FontWeight.bold,textSize: 20),
content: getText('是否要从收藏列表中删除影片《${bean.movieName}》?',textSize: 14,maxLine: 2),
actions: <Widget>[
TextButton(
child: getText('确定',color: Colors.red),
onPressed: () async{
await FavoriteDao.getInstance().delete(bean.doubanId);
provider.removeItem = index;
showSuccessToast('删除成功!');
Navigator.pop(context,true);
},
),
TextButton(
child: getText('取消',color: Colors.blue),
onPressed: () {
showSuccessToast('取消删除!');
Navigator.pop(context,false);
},
),
],
);
},
);
}
项目地址
Gitee
|