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。