在Gmail中,我们经常会看到如下效果:
滑动去存档,也可以滑动删除。
那作为Google 自家出品的Flutter,当然也会有这种组件。
Dismissible
按照惯例来看一下官方文档上给出的解释:
代码语言:javascript复制A widget that can be dismissed by dragging in the indicated direction.
Dragging or flinging this widget in the DismissDirection causes the child to slide out of view.
可以通过指示的方向来拖动消失的组件。
在DismissDirection中拖动或投掷该组件会导致该组件滑出视图。
再来看一下构造方法,来确认一下我们怎么使用:
代码语言:javascript复制const Dismissible({
@required Key key,
@required this.child,
this.background,
this.secondaryBackground,
this.confirmDismiss,
this.onResize,
this.onDismissed,
this.direction = DismissDirection.horizontal,
this.resizeDuration = const Duration(milliseconds: 300),
this.dismissThresholds = const <DismissDirection, double>{},
this.movementDuration = const Duration(milliseconds: 200),
this.crossAxisEndOffset = 0.0,
this.dragStartBehavior = DragStartBehavior.start,
}) : assert(key != null),
assert(secondaryBackground != null ? background != null : true),
assert(dragStartBehavior != null),
super(key: key);
可以发现我们必传的参数有 key 和 child。
child不必多说,就是我们需要滑动删除的组件,那key是什么?
后续我会出一篇关于 Flutter Key 的文章来详细解释一下什么是 Key。
现在我们只需要理解,key 是 widget 的唯一标示。因为有了key,所以 widget tree 才知道我们删除了什么widget。
简单使用
知道了需要传什么参数,那我们开始撸一个demo:
代码语言:javascript复制class _DismissiblePageState extends State<DismissiblePage> {
// 生成列表数据
var _listData = List<String>.generate(30, (i) => 'Items $i');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('DismissiblePage'),
),
body: _createListView(),
);
}
// 创建ListView
Widget _createListView() {
return ListView.builder(
itemCount: _listData.length,
itemBuilder: (context, index) {
return Dismissible(
// Key
key: Key('key${_listData[index]}'),
// Child
child: ListTile(
title: Text('Title${_listData[index]}'),
),
);
},
);
}
}
代码很简单,就是生成了一个 ListView ,在ListView 的 item中用 Dismissible 包起来。
效果如下:
虽然看起来这里每一个 item 被删除了,但是实际上并没有,因为我们没对数据源进行处理。
添加删除逻辑
代码语言:javascript复制// 创建ListView
Widget _createListView() {
return ListView.builder(
itemCount: _listData.length,
itemBuilder: (context, index) {
return Dismissible(
// Key
key: Key('key${_listData[index]}'),
// Child
child: ListTile(
title: Text('${_listData[index]}'),
),
onDismissed: (direction){
// 删除后刷新列表,以达到真正的删除
setState(() {
_listData.removeAt(index);
});
},
);
},
);
}
可以看到我们添加了一个 onDismissed
参数。
这个方法会在删除后进行回调,我们在这里把数据源删除,并刷新列表即可。
现在数据可以真正的删除了,但是用户并不知道我们做了什么,所以要来一点提示:
代码如下:
代码语言:javascript复制onDismissed: (direction) {
// 展示 SnackBar
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('删除了${_listData[index]}'),
));
// 删除后刷新列表,以达到真正的删除
setState(() {
_listData.removeAt(index);
});
},
增加视觉效果
虽然我们处理了删除后的逻辑,但是我们在滑动的时候,用户还是不知道我们在干什么。
这个时候我们就要增加滑动时候的视觉效果了。
还是来看构造函数:
代码语言:javascript复制const Dismissible({
@required Key key,
@required this.child,
this.background,
this.secondaryBackground,
this.confirmDismiss,
this.onResize,
this.onDismissed,
this.direction = DismissDirection.horizontal,
this.resizeDuration = const Duration(milliseconds: 300),
this.dismissThresholds = const <DismissDirection, double>{},
this.movementDuration = const Duration(milliseconds: 200),
this.crossAxisEndOffset = 0.0,
this.dragStartBehavior = DragStartBehavior.start,
}) : assert(key != null),
assert(secondaryBackground != null ? background != null : true),
assert(dragStartBehavior != null),
super(key: key);
可以看到有个 background 和 secondaryBackground。
一个背景和一个次要的背景,我们点过去查看:
代码语言:javascript复制/// A widget that is stacked behind the child. If secondaryBackground is also
/// specified then this widget only appears when the child has been dragged
/// down or to the right.
final Widget background;
/// A widget that is stacked behind the child and is exposed when the child
/// has been dragged up or to the left. It may only be specified when background
/// has also been specified.
final Widget secondaryBackground;
可以看到两个 background 都是一个Widget,那么也就是说我们写什么上去都行。
通过查看注释我们了解到:
background 是向右滑动展示的,secondaryBackground是向左滑动展示的。
如果只有一个 background,那么左滑右滑都是它自己。
那我们开始撸码,先来一个背景的:
代码语言:javascript复制background: Container(
color: Colors.red,
// 这里使用 ListTile 因为可以快速设置左右两端的Icon
child: ListTile(
leading: Icon(
Icons.bookmark,
color: Colors.white,
),
trailing: Icon(
Icons.delete,
color: Colors.white,
),
),
),
效果如下:
再来两个背景的:
代码语言:javascript复制background: Container(
color: Colors.green,
// 这里使用 ListTile 因为可以快速设置左右两端的Icon
child: ListTile(
leading: Icon(
Icons.bookmark,
color: Colors.white,
),
),
),
secondaryBackground: Container(
color: Colors.red,
// 这里使用 ListTile 因为可以快速设置左右两端的Icon
child: ListTile(
trailing: Icon(
Icons.delete,
color: Colors.white,
),
),
),
效果如下:
处理不同滑动方向的完成事件
那现在问题就来了,既然我现在有两个滑动方向了,就代表着两个业务逻辑。
这个时候我们应该怎么办?
这个时候 onDismissed: (direction) 中的 direction 就有用了:
我们找到 direction 的类为 DismissDirection,该类为一个枚举类:
代码语言:javascript复制/// The direction in which a [Dismissible] can be dismissed.
enum DismissDirection {
/// 上下滑动
vertical,
/// 左右滑动
horizontal,
/// 从右到左
endToStart,
/// 从左到右
startToEnd,
/// 向上滑动
up,
/// 向下滑动
down
}
那我们就可以根据上面的枚举来判断了:
代码语言:javascript复制onDismissed: (direction) {
var _snackStr;
if(direction == DismissDirection.endToStart){
// 从右向左 也就是删除
_snackStr = '删除了${_listData[index]}';
}else if (direction == DismissDirection.startToEnd){
_snackStr = '收藏了${_listData[index]}';
}
// 展示 SnackBar
Scaffold.of(context).showSnackBar(SnackBar(
content: Text(_snackStr),
));
// 删除后刷新列表,以达到真正的删除
setState(() {
_listData.removeAt(index);
});
},
效果如下:
避免误操作
看到这肯定有人觉得,这手一抖不就删除了么,能不能有什么操作来防止误操作?
那肯定有啊,你能想到的,Google都想好了,还是来看构造函数:
代码语言:javascript复制const Dismissible({
@required Key key,
@required this.child,
this.background,
this.secondaryBackground,
this.confirmDismiss,
this.onResize,
this.onDismissed,
this.direction = DismissDirection.horizontal,
this.resizeDuration = const Duration(milliseconds: 300),
this.dismissThresholds = const <DismissDirection, double>{},
this.movementDuration = const Duration(milliseconds: 200),
this.crossAxisEndOffset = 0.0,
this.dragStartBehavior = DragStartBehavior.start,
}) : assert(key != null),
assert(secondaryBackground != null ? background != null : true),
assert(dragStartBehavior != null),
super(key: key);
看没看到一个 confirmDismiss ?,就是它,来看一下源码:
代码语言:javascript复制/// Gives the app an opportunity to confirm or veto a pending dismissal.
///
/// If the returned Future<bool> completes true, then this widget will be
/// dismissed, otherwise it will be moved back to its original location.
///
/// If the returned Future<bool> completes to false or null the [onResize]
/// and [onDismissed] callbacks will not run.
final ConfirmDismissCallback confirmDismiss;
大致意思就是:
代码语言:javascript复制使应用程序有机会是否决定dismiss。
如果返回的future<bool>为true,则该小部件将被dismiss,否则它将被移回其原始位置。
如果返回的future<bool>为false或空,则不会运行[onResize]和[ondismissed]回调。
既然如此,我们就在该方法中,show 一个Dialog来判断用户是否删除:
代码语言:javascript复制confirmDismiss: (direction) async {
var _confirmContent;
var _alertDialog;
if (direction == DismissDirection.endToStart) {
// 从右向左 也就是删除
_confirmContent = '确认删除${_listData[index]}?';
_alertDialog = _createDialog(
_confirmContent,
() {
// 展示 SnackBar
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('确认删除${_listData[index]}'),
duration: Duration(milliseconds: 400),
));
Navigator.of(context).pop(true);
},
() {
// 展示 SnackBar
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('不删除${_listData[index]}'),
duration: Duration(milliseconds: 400),
));
Navigator.of(context).pop(false);
},
);
} else if (direction == DismissDirection.startToEnd) {
_confirmContent = '确认收藏${_listData[index]}?';
_alertDialog = _createDialog(
_confirmContent,
() {
// 展示 SnackBar
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('确认收藏${_listData[index]}'),
duration: Duration(milliseconds: 400),
));
Navigator.of(context).pop(true);
},
() {
// 展示 SnackBar
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('不收藏${_listData[index]}'),
duration: Duration(milliseconds: 400),
));
Navigator.of(context).pop(false);
},
);
}
var isDismiss = await showDialog(
context: context,
builder: (context) {
return _alertDialog;
});
return isDismiss;
},
解释一下上面的代码。
首先判断滑动的方向,然后根据创建的方向来创建Dialog 以及 点击事件。
最后点击时通过 Navigator.pop()来返回值。
效果如下: