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。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,423评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,339评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,241评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,503评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,824评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,262评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,615评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,337评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,989评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,300评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,829评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,193评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,753评论 3 230
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,970评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,708评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,295评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,207评论 2 258