Github项目地址: 项目地址
上一篇博客已经完成了首页基础控件的构建,但还缺少交互,推荐卡片也仅有固定的三张,这篇博客我们来一起完善一下美团首页。
AppBar增加按钮点击事件和弹出菜单
///主界面AppBar
AppBar _buildHomeAppBar() {
return AppBar(
automaticallyImplyLeading: false,
elevation: 0.0,
backgroundColor: Colors.white,
flexibleSpace: SafeArea(
//适配刘海
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
InkWell(
onTap: () => Navigator.of(context)
.push(CupertinoPageRoute(builder: (context) => TestPage())),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ClipOval(
child: Image.asset("images/protrait.png",
width: 35.0, height: 35.0),
),
),
),
InkWell(
onTap: () => Navigator.of(context)
.push(CupertinoPageRoute(builder: (context) => TestPage())),
child: Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
"三河",
style: TextStyle(color: Colors.black, fontSize: 15.0),
),
Icon(
Icons.keyboard_arrow_down,
size: 15.0,
),
],
),
Text(
"晴 20°",
style: TextStyle(fontSize: 10.0),
)
],
),
padding: const EdgeInsets.all(8.0),
),
),
Expanded(
child: GestureDetector(
onTap: () {
Navigator.of(context).push(
CupertinoPageRoute(builder: (context) => SearchPage()));
},
child: Container(
height: 45.0,
child: Card(
elevation: 0.0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0))),
color: Colors.grey[200],
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
Icons.search,
color: Colors.black87,
size: 20.0,
),
Text(
"自助烤肉",
style:
TextStyle(fontSize: 15.0, color: Colors.black87),
),
],
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: PopupMenuButton(
child: Icon(
Icons.add,
size: 30,
color: Colors.black,
),
itemBuilder: (context) => <PopupMenuEntry>[
PopupMenuItem(
child: Container(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.image),
SizedBox(
width: 20,
),
Text("扫一扫"),
],
)),
),
PopupMenuItem(
child: Container(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.scanner),
SizedBox(
width: 20,
),
Text("付款码"),
],
)),
),
],
),
),
],
),
),
);
}
主要结构没有变化,控件的排列依次是一个圆形的头像框,一个带有地点和天气的可点击文本,一个导航功能的搜索框,一个弹出菜单按钮。上篇博客我们已经完成了样式和布局的绘制,这里主要是给这些控件加上相应用户点击的功能。
前面提到过在Flutter里提供了许多样式的Button控件,也可以通过GestureDetector来给控件增加捕捉用户手势的功能。这次我们通过另一个控件InkWell实现。Flutter自带的Button类通常都自带有水波纹特效以及被选中后高亮等特效,但会都占用比较大的空间,不太适合国内App紧凑布局的风格。而使用GestureDetector控件捕捉手势便没有了点击时的动画等,交互性差。InkWell控件很好的解决了这些问题,它给予子控件捕获各种手势操作的同时带有符合Google Material Design 的水波纹动画,而且不会占用多余的空间。先来看一看InkWell控件的构造方法。
const InkWell({
Key key,
Widget child,
GestureTapCallback onTap,
GestureTapCallback onDoubleTap,
GestureLongPressCallback onLongPress,
GestureTapDownCallback onTapDown,
GestureTapCancelCallback onTapCancel,
ValueChanged<bool> onHighlightChanged,
Color highlightColor,
Color splashColor,
InteractiveInkFeatureFactory splashFactory,
double radius,
BorderRadius borderRadius,
ShapeBorder customBorder,
bool enableFeedback = true,
bool excludeFromSemantics = false,
})
key之前提到过,主要是用来在 widget tree 上标识同类控件的不同对象,一般情况下用不到。onTap和onLongPress等就是InkWell控件支持捕获的手势,当这些事件传入的值不会空时,对应的手势就会触发水波纹特效。splashColor属性的值即为水波纹的颜色,highlightColor对应高亮时的颜色(被选中或被点击等情况下触发),borderRadius属性配置水波纹触发时的边界弧度。
当使用该控件并添加了对应手势事件后不显示水波纹特效时,再包裹一个Ink控件即可。
InkWell(
borderRadius: BorderRadius.circular(45),
onTap: () => Navigator.of(context)
.push(CupertinoPageRoute(builder: (context) => TestPage())),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ClipOval(
child: Image.asset("images/protrait.png",
width: 35.0, height: 35.0),
),
),
),
在圆形头像框与地址标题的外部都包裹上一个InkWell控件,并在点击事件中导航到一个测试界面,CupertinoPageRoute的作用是将路由动画设为IOS风格的左侧滑入。BorderRadius.circular(45) 是将左上,右上,左下,右下四个方向上的圆角弧度都设为45°。而搜索框不需要水波纹特效,我们就使用GestureDetector控件捕捉点击事件即可。
弹出菜单按钮
Padding(
padding: const EdgeInsets.all(8.0),
child: PopupMenuButton(
child: Icon(
Icons.add,
size: 30,
color: Colors.black,
),
itemBuilder: (context) => <PopupMenuEntry>[
PopupMenuItem(
child: Container(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.image),
SizedBox(
width: 20,
),
Text("扫一扫"),
],
)),
),
PopupMenuItem(
child: Container(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.scanner),
SizedBox(
width: 20,
),
Text("付款码"),
],
)),
),
],
),
),
Flutter带有专门处理弹出菜单的按钮控件:PopupMenuButton。构造函数如下:
const PopupMenuButton({
Key key,
@required this.itemBuilder, //按钮被点击时显示的菜单项
this.initialValue, //初始值
this.onSelected, //点击事件
this.onCanceled, //用户点击其他区域时的取消事件
this.tooltip, //长按时显示的提示信息
this.elevation = 8.0, //阴影高度
this.padding = const EdgeInsets.all(8.0), //外边距
this.child, //子控件
this.icon, //图标
this.offset = Offset.zero,
})
itemBuilder接收一个PopupMenuEntry<dynamic>列表,dynamic可以替换成任意类型,作为某个菜单项被点击时返回的值,通常返回字符串或整数类型。itemBudiler参数不能为空,child和icon参数不能同时给出,当两者都为空时显示一个默认的菜单按钮。
每个PopupMenuItem作为一个单独的菜单项,竖直排列。child参数接收一个控件作为显示的内容,value参数作为被点击时返回的值,enabled参数表示是否启用。
const PopupMenuItem({
Key key,
this.value,
this.enabled = true,
this.height = _kMenuItemHeight,
@required this.child,
})
效果图:
动态推荐卡片列表
前面提到过ListView是Flutter中最为常见的滚动块,可以通过scrollDirection参数设置竖向或横向滚动。当需要一个无限或较长的滚动列表时使用builder模式(命名构造函数),使用itemBuilder来动态的加载要显示的子控件。
简单示例:
ListView.builder(
itemCount: 20,
itemBuilder: (context, index) => Text(index.toString()),
);
可以通过itemCount和itemBuilder参数来显示一个固定长度的滚动列表也可以仅设置itemBuilder参数来显示一个无限滚动列表。因为我们希望在删除推荐卡片和加载更多推荐卡片时显示一个动画效果,所以这里使用一个AnimatedList控件来代替ListView.builder。两者的构造方法很相似,AnimatedList控件通常需要一个GlobalKey<AnimatedListState> 来控制子控件增删时的动画效果,一般情况下我们将 key 放在State的属性中,以便于在该类的任意位置控制带动画的列表。
显示一个AnimatedList通常需要构建一个Widget列表,在增删子控件时同时增删该列表和调用key中的增删方法。而我们还需要监听列表滑动到底部时加载更多推荐卡片,所以需要一个控制AnimatedList滚动的ScrollController。
第一步,在State属性中加入GlobalKey<AnimatedListState>,ScrollController,List<Widget>。
class _HomePageState extends State<HomePage> {
final GlobalKey<AnimatedListState> _listKey =
new GlobalKey<AnimatedListState>();
List<Widget> _list;
ScrollController _controller;
bool isRefreshing; //避免在加载时触发加载更多的监听器
...
覆盖initState方法,并添加增删控件和显示一个移除动画的方法。
@override
void initState() {
super.initState(); //调用父类的initState方法
isRefreshing = false;
_controller = ScrollController();
_controller.addListener(() {
//给ScrollController添加监听器
if (_controller.position.pixels == _controller.position.maxScrollExtent &&
!this.isRefreshing) {
//当滚动到底部且不在加载中时
setState(() {
isRefreshing = true; //设置当前状态为正在加载中
Widget item = Container(
//一个IOS风格的加载中的指示器
height: 50,
child: Center(child: CupertinoActivityIndicator()),
);
_selectedItem = item; //设置当前选中的控件为该指示器,方便加载完后删除指示器
_insertItem(_list.length, item); //添加该指示器到底部
});
Future.delayed(Duration(seconds: 2), () {
//两秒后执行传入的方法
_removeItem(_list.length - 1); //删除指示器并添加三个推荐卡片
_insertItem(_list.length, _buildCard1());
_insertItem(_list.length, _buildCard2());
_insertItem(_list.length, _buildCard3());
setState(() {
isRefreshing = false; //设置当前状态为不在加载中
});
});
}
});
_list = <Widget>[
//第一次加载HomePage时初始化三个推荐卡片
_buildCard1(),
_buildCard2(),
_buildCard3(),
];
}
void _removeItem(int index) {
_list.removeAt(index); //从控件列表中移除对应索引位置的控件
_listKey.currentState.removeItem(
//从key中移除对应索引位置的控件并通过_buildRemovedItem方法显示一个移除的动画
index + 6,
(context, animation) =>
_buildRemovedItem(_selectedItem, context, animation));
}
void _insertItem(int index, Widget item) {
_list.insert(index, item); //从控件列表和key中添加一个控件
_listKey.currentState.insertItem(index + 6);
}
//构建被移除的控件(显示移除动画)
Widget _buildRemovedItem(
Widget widget, BuildContext context, Animation<double> animation) {
return SizeTransition( //封装有尺寸变化动画的控件
axis: Axis.vertical, //尺寸动画伸缩方向
sizeFactor: animation, //尺寸值,这里为AnimatedList给出的移除动画的值
child: widget,
);
}
在Scaffold的body中添加一个AnimatedList控件,并在外部包裹一个渐变色的Container。
@override
Widget build(BuildContext context) {
final bodys = _initBody(); //顶部三行标题栏及轮播图等不会被删除的控件,因含有用于分隔的SizedBox控件,所以总长度为6
return Scaffold(
appBar: _buildHomeAppBar(),
body: Container(
decoration: GradientDecoration, //定义在主题文件中的渐变色装饰器
child: AnimatedList(
controller: _controller, //绑定滚动控制器,key等
key: _listKey,
initialItemCount: bodys.length + _list.length, //初始化时的控件总长度
itemBuilder: (context, index, animation) { //当子控件要可见时调用该方法构建控件
if (index > 5) { //索引大于5时显示_list列表中的推荐卡片,否则bodys中的控件。
return _buildItem(context, index - bodys.length, animation);
} else {
return bodys[index];
}
},
),
),
);
}
当监听到列表滚动到底部时会添加一个IOS风格的加载中的指示器在底部,两秒后移除该指示器并添加三个推荐卡片按钮到底部。
实现当点击推荐卡片右上端删除按钮时从列表中将该卡片删除并显示一个删除的动画效果。首先在推荐卡片的构造方法中增加一个onDelete属性用于接收删除按钮的点击回调事件。在删除按钮的点击事件中传入该方法。
typedef OnPressCallback = void Function(Widget selectedItem);
//定义一个参数为Widget,无返回值的方法
class ScenicCard extends StatelessWidget {
ScenicCard(
{@required this.price,
@required this.title,
@required this.imageUrls,
@required this.score,
@required this.address,
this.onDelete, //new
this.tags = const <Widget>[]});
final Widget price;
final List<Widget> tags;
final String title;
final List<String> imageUrls;
final String score;
final String address;
final OnPressCallback onDelete; //new
...
return RecommendedCard(
title: title,
onDelete: () => onDelete(this),
child: Column(
...
在HomePage中构造推荐卡片时传入回调方法。
ScenicCard(
onDelete: _showDeleteDialog,
...
//显示推荐卡片的关闭对话框
void _showDeleteDialog(Widget selectedItem) {
_selectedItem = selectedItem;
var dialog = SimpleDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
titlePadding: EdgeInsets.only(top: 20),
title: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
"选择具体理由,会减少相关推荐呦",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildMyButton("去过了"),
SizedBox(
width: 10,
),
_buildMyButton("不感兴趣"),
SizedBox(
width: 10,
),
_buildMyButton("价格不合适"),
],
),
SizedBox(
height: 15,
),
GestureDetector(
onTap: () {
_removeItem(_list.indexOf(_selectedItem));
Navigator.of(context).pop();
},
child: InkWell(
child: Container(
height: 50,
decoration: BoxDecoration(
color: CupertinoColors.lightBackgroundGray,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(15),
bottomRight: Radius.circular(15))),
child: Center(
child: Text(
"不感兴趣",
style: TextStyle(fontSize: 12, color: Colors.teal),
),
),
),
),
)
],
),
);
showDialog(
context: context,
builder: (context) => dialog,
);
}