Flutter使用Draggable实现可拖拽GridView
本例通过继承StatefulWidget,使用Draggable和GridView使GridView的Item实现可拖拽排序。
最终效果如下:
Draggable7.gif实现原理:
不管是Flutter还是Android应用中GridView这类列表的展示通常都是基于数据源,在Flutter中,我们如果想要给GridView进行排序,只需要修改其数据源List的顺序就能实现排序的效果。
由于每次重新排序后 都需要更新UI,因此我们选择使用StatefulWidget作为父控件,当需要更新UI,我们在setState函数中修改数据源List即可。
按照惯例,先来个空白页用于展示我们的UI。
void main() => runApp(new MyApp());
///用于展示Demo的界面,其中的MaterialApp、ThemeData、AppBar都是不必要的,只是稍微美观一点。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Flutter Demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
home: new Scaffold(
appBar: new AppBar(
title: new Text("DraggableDemo"),
),
// body: MyDraggable()),
// body: Drag2TargetPage()),
// body: DraggableItemDemo()),
body: DraggableGridViewDemo()),//此处为本例将要展示的页面
);
}
}
有了空白页,就可以开始封装我们想要的可拖拽GridView了,先看一下GridView该怎么用
///The most commonly used grid layouts are [GridView.count]
根据GridView的文档,最常用的是GridView.count,我们就从这个创建方法开始看看怎么创建一个简单的GridView。
以下是GridView.count方法的部分参数,对于参数的说明我知道的都写上了注释,有些参数现在还不了解。
GridView.count({
Key key,//一般不需要传,用于区分Item是否为同一个,大多数时候是在remove某个Item的时候系统通过这个key来执行remove的动画。
Axis scrollDirection = Axis.vertical,//滚动的方向
bool reverse = false,//是否反向
ScrollController controller,//主要用于控制GridView的滚动和设置滚动监听。当item数量超出屏幕 拖动Item到底部或顶部 可使用ScrollController滚动GridView 实现自动滚动的效果。
@required int crossAxisCount,//列或者行数,取决于滚动方向,即非主轴方向上的item个数
double childAspectRatio = 1.0,//item的宽高比
List<Widget> children = const <Widget>[],//itemList
})
class GridViewPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GridView.count(
childAspectRatio: 3.0, //item宽高比
scrollDirection: Axis.vertical, //默认vertical
crossAxisCount: 3, //列数
children: _buildGridChildren(context),
);
}
//生成widget列表
List<Widget> _buildGridChildren(BuildContext context) {
final List list = List<Widget>();
for (int x = 0; x < 12; x++) {
list.add(Card(
child: Center(
child: Text('x = $x'),
),
));
}
return list;
}
}
以上是一个简单的GridView实现,效果如下:
GridViewSimple1.png
此时,如果我们使用上一篇中的Draggable和DragTarget的组合Item,是不是就可以实现可拖拽了呢?试一试
将 dart _buildGridChildren 方法稍作修改
//生成widget列表
List<Widget> _buildGridChildren(BuildContext context) {
final List list = List<Widget>();
for (int x = 0; x < 12; x++) {
list.add(MyDraggableTarget(data: 'x = $x'));
}
return list;
}
效果如下:
Draggable5.gif
可以看到,现在确实已经可以拖动,并且数据也成功接收了。接下来想要实现排序的功能就是对数据做处理然后setState啦。
如果能在DragTarget的onAccept方法中直接获取到数据源List,那么我们只需要把拖拽的item从他原来的位置remove,再insert到目标位置,就可以实现一个粗糙的拖拽排序了。
核心代码如下:
onAccept: (fromIndex) {
setState(() {
final temp = widget._dataList[fromIndex];
widget._dataList.remove(temp);
widget._dataList.insert(index, temp);
});
},
顺便了解一下LongPressDraggable,是Draggable的子类,区别就是手势识别需要长按才会触发拖动,不详细说明了,用起来是一样的。
那么最终实现的可拖拽的GridView会是这样的:
Draggable6.gif
长按后Item变为可拖拽状态,拖拽后松手,会将Item插入到对应位置。
代码如下:
class GridViewPage3 extends StatefulWidget {
@override
State<StatefulWidget> createState() => _GridViewPage3State();
final List _dataList = <String>[
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
].toList();
}
class _GridViewPage3State extends State<GridViewPage3> {
@override
Widget build(BuildContext context) {
return GridView.count(
childAspectRatio: 3.0, //item宽高比
scrollDirection: Axis.vertical, //默认vertical
crossAxisCount: 3, //列数
children: _buildGridChildren(context),
);
}
//生成widget列表
List<Widget> _buildGridChildren(BuildContext context) {
final List list = List<Widget>();
for (int x = 0; x < widget._dataList.length; x++) {
list.add(_buildItemWidget(context, x));
}
return list;
}
Widget _buildItemWidget(BuildContext context, int index) {
return LongPressDraggable(
data: index, //这里data使用list的索引,方便交换数据
child: DragTarget<int>(
//松手时 如果onWillAccept返回true 那么久会调用
onAccept: (data) {
setState(() {
final temp = widget._dataList[data];
widget._dataList.remove(temp);
widget._dataList.insert(index, temp);
});
},
//绘制widget
builder: (context, data, rejects) {
return Card(
child: Center(
child: Text('x = ${widget._dataList[index]}'),
),
);
},
//手指拖着一个widget从另一个widget头上滑走时会调用
onLeave: (data) {
print('$data is Leaving item $index');
},
//接下来松手 是否需要将数据给这个widget? 因为需要在拖动时改变UI,所以在这里直接修改数据源
onWillAccept: (data) {
print('$index will accept item $data');
return true;
},
),
onDragStarted: () {
//开始拖动,备份数据源
print('item $index ---------------------------onDragStarted');
},
onDraggableCanceled: (Velocity velocity, Offset offset) {
print(
'item $index ---------------------------onDraggableCanceled,velocity = $velocity,offset = $offset');
//拖动取消,还原数据源
},
onDragCompleted: () {
//拖动完成,刷新状态,重置willAcceptIndex
print("item $index ---------------------------onDragCompleted");
},
//用户拖动item时,那个给用户看起来被拖动的widget,(就是会跟着用户走的那个widget)
feedback: SizedBox(
child: Center(
child: Icon(Icons.feedback),
),
),
//这个是当item被拖动时,item原来位置用来占位的widget,(用户把item拖走后原来的地方该显示啥?就是这个)
childWhenDragging: Container(
child: Center(
child: Icon(Icons.child_care),
),
),
);
}
}
至此,一个简单的可以拖拽GridView就完成了。
接下来思考如何让item在拖拽时让其余Item给他让位置,
我在 onWillAccept 中添加了Log
print('$index will accept item $fromIndex');
当拖动第一个Item到第3个Item上方时,将会打印
flutter: 2 will accept item 0
- 可以看到在onWillAccept方法中可以获得拖动的item的起点与终点。
- 如果我们在onWillAccept方法中调用setState改变数据集的顺序,应该就可以在拖动时让UI跟随手指移动而变化。
- 考虑到如果用户最后又放弃了拖动,需要还原UI,我们应该创建另一个List用来备份当前数据集。
- 与onWillAccept方法对应的方法为onLeave,在拖动的Item离开时将会调用,我们在Draggable的onDragStarted的时候记录当前数据集到备份集合中,每次onLeave的时候还原数据集,当取消拖动时也取消数据集,这样一来,我们可以把onAccept中的代码移动到onWillAccept。
完整代码如下:
typedef bool CanAccept(int oldIndex, int newIndex);
typedef Widget DataWidgetBuilder<T>(BuildContext context, T data);
class SortableGridView<T> extends StatefulWidget {
final DataWidgetBuilder<T>
itemBuilder; //用于生成GridView的Item Widget的函数,接收一个context参数和一个数据源参数,返回一个Widget
final CanAccept canAccept; //是否接受拖拽过来的数据的回调函数
final List<T> dataList; //数据源List
final Axis scrollDirection; //GridView的滚动方向
final int
crossAxisCount; //非主轴方向的item数量,即 如果GridView的滚动方向是垂直方向,那么这个字段的意思就是有多少列;如果为水平方向,则此字段代表有多少行。
final double
childAspectRatio; //每个Item的宽高比,由于GridView的Item默认是正方形的,可以通过这个比例稍作调整。可能会有我不知道的别的办法。
SortableGridView(
this.dataList, {
Key key,
this.scrollDirection = Axis.vertical,
this.crossAxisCount = 3,
this.childAspectRatio = 1.0,
@required this.itemBuilder,
@required this.canAccept,
}) : assert(itemBuilder != null),
assert(canAccept != null),
assert(dataList != null && dataList.length >= 0),
super(key: key);
@override
State<StatefulWidget> createState() => _SortableGridViewState<T>();
}
class _SortableGridViewState<T> extends State<SortableGridView> {
List<T> _dataList; //数据源
List<T> _dataListBackup; //数据源备份,在拖动时 会直接在数据源上修改 来影响UI变化,当拖动取消等情况,需要通过备份还原
bool _showItemWhenCovered = false; //手指覆盖的地方,即item被拖动时 底部的那个widget是否可见;
int _willAcceptIndex = -1; //当拖动覆盖到某个item上的时候,记录这个item的坐标
// int _draggingItemIndex = -1; //当前被拖动的item坐标
// ScrollController _scrollController;//当item数量超出屏幕 拖动Item到底部或顶部 可使用ScrollController滚动GridView 实现自动滚动的效果。
@override
void initState() {
super.initState();
_dataList = widget.dataList;
_dataListBackup = _dataList.sublist(0);
// _scrollController = ScrollController();
}
@override
void dispose() {
// TODO: implement dispose
super.dispose();
// _scrollController?.dispose();
}
@override
Widget build(BuildContext context) {
return GridView.count(
// controller: _scrollController,
childAspectRatio: widget.childAspectRatio, //item宽高比
scrollDirection: widget.scrollDirection, //默认vertical
crossAxisCount: widget.crossAxisCount, //列数
children: _buildGridChildren(context),
);
}
//生成widget列表
List<Widget> _buildGridChildren(BuildContext context) {
final List list = List<Widget>();
for (int x = 0; x < _dataList.length; x++) {
list.add(_buildDraggable(context, x));
}
return list;
}
//绘制一个可拖拽的控件。
Widget _buildDraggable(BuildContext context, int index) {
return LayoutBuilder(
builder: (context, constraint) {
return LongPressDraggable(
data: index,
child: DragTarget<int>(
//松手时 如果onWillAccept返回true 那么久会调用,本案例不使用。
onAccept: (int data) {},
//绘制widget
builder: (context, data, rejects) {
return _willAcceptIndex >= 0 && _willAcceptIndex == index
? null
: widget.itemBuilder(context, _dataList[index]);
},
//手指拖着一个widget从另一个widget头上滑走时会调用
onLeave: (int data) {
//TODO 这里应该还可以优化,当用户滑出而又没有滑入某个item的时候 可以重新排列 让当前被拖走的item的空白被填满
print('$data is Leaving item $index');
_willAcceptIndex = -1;
setState(() {
_showItemWhenCovered = false;
_dataList = _dataListBackup.sublist(0);
});
},
//接下来松手 是否需要将数据给这个widget? 因为需要在拖动时改变UI,所以在这里直接修改数据源
onWillAccept: (int fromIndex) {
print('$index will accept item $fromIndex');
final accept = fromIndex != index;
if (accept) {
_willAcceptIndex = index;
_showItemWhenCovered = true;
_dataList = _dataListBackup.sublist(0);
final fromData = _dataList[fromIndex];
setState(() {
_dataList.removeAt(fromIndex);
_dataList.insert(index, fromData);
});
}
return accept;
},
),
onDragStarted: () {
//开始拖动,备份数据源
// _draggingItemIndex = index;
_dataListBackup = _dataList.sublist(0);
print('item $index ---------------------------onDragStarted');
},
onDraggableCanceled: (Velocity velocity, Offset offset) {
print(
'item $index ---------------------------onDraggableCanceled,velocity = $velocity,offset = $offset');
//拖动取消,还原数据源
setState(() {
_willAcceptIndex = -1;
_showItemWhenCovered = false;
_dataList = _dataListBackup.sublist(0);
});
},
onDragCompleted: () {
//拖动完成,刷新状态,重置willAcceptIndex
print("item $index ---------------------------onDragCompleted");
setState(() {
_showItemWhenCovered = false;
_willAcceptIndex = -1;
});
},
//用户拖动item时,那个给用户看起来被拖动的widget,(就是会跟着用户走的那个widget)
feedback: SizedBox(
width: constraint.maxWidth,
height: constraint.maxHeight,
child: widget.itemBuilder(context, _dataList[index]),
),
//这个是当item被拖动时,item原来位置用来占位的widget,(用户把item拖走后原来的地方该显示啥?就是这个)
childWhenDragging: Container(
child: SizedBox(
child: _showItemWhenCovered
? widget.itemBuilder(context, _dataList[index])
: null,
),
),
);
},
);
}
}
然后将这个自定义的SortableGridView创建出来,填充到最开始的空白页中。即可实现最终的效果。
使用方式如下:
class DraggableGridViewDemo extends StatelessWidget {
final List<String> channelItems = List<String>();
@override
Widget build(BuildContext context) {
for (int x = 0; x < 20; x++) {
channelItems.add("x = $x");
}
return SortableGridView(
channelItems,
childAspectRatio: 3.0, //宽高3比1
crossAxisCount: 3, //3列
scrollDirection: Axis.vertical, //竖向滑动
canAccept: (oldIndex, newIndex) {
return true;
},
itemBuilder: (context, data) {
return Card(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0),
child: Center(
child: Text(data),
),
));
},
);
}
}
至此,一个可拖拽的还算能看的GridView就算完成了。
最近在github上看到了一个DragAndDropList。
地址:https://github.com/Norbert515/flutter_list_drag_and_drop
是一个可拖拽的ListView。通过修改Draggable的源码实现,感觉这个思路比我的好,先研究一下,可能下一篇会按照他的方式优化一下SortableGridView。