Flutter使用Draggable實(shí)現(xiàn)可拖拽GridView

本例通過繼承StatefulWidget搅吁,使用Draggable和GridView使GridView的Item實(shí)現(xiàn)可拖拽排序冕茅。

最終效果如下:
Draggable7.gif
實(shí)現(xiàn)原理:

不管是Flutter還是Android應(yīng)用中GridView這類列表的展示通常都是基于數(shù)據(jù)源,在Flutter中店茶,我們?nèi)绻胍oGridView進(jìn)行排序蜕便,只需要修改其數(shù)據(jù)源List的順序就能實(shí)現(xiàn)排序的效果。
由于每次重新排序后 都需要更新UI贩幻,因此我們選擇使用StatefulWidget作為父控件玩裙,當(dāng)需要更新UI,我們?cè)趕etState函數(shù)中修改數(shù)據(jù)源List即可段直。

按照慣例,先來個(gè)空白頁用于展示我們的UI溶诞。


void main() => runApp(new MyApp());

///用于展示Demo的界面鸯檬,其中的MaterialApp、ThemeData螺垢、AppBar都是不必要的喧务,只是稍微美觀一點(diǎn)。
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]

根據(jù)GridView的文檔,最常用的是GridView.count孽亲,我們就從這個(gè)創(chuàng)建方法開始看看怎么創(chuàng)建一個(gè)簡(jiǎn)單的GridView坎穿。
以下是GridView.count方法的部分參數(shù),對(duì)于參數(shù)的說明我知道的都寫上了注釋,有些參數(shù)現(xiàn)在還不了解玲昧。

GridView.count({
Key key,//一般不需要傳栖茉,用于區(qū)分Item是否為同一個(gè),大多數(shù)時(shí)候是在remove某個(gè)Item的時(shí)候系統(tǒng)通過這個(gè)key來執(zhí)行remove的動(dòng)畫孵延。
Axis scrollDirection = Axis.vertical,//滾動(dòng)的方向
bool reverse = false,//是否反向
ScrollController controller,//主要用于控制GridView的滾動(dòng)和設(shè)置滾動(dòng)監(jiān)聽吕漂。當(dāng)item數(shù)量超出屏幕 拖動(dòng)Item到底部或頂部 可使用ScrollController滾動(dòng)GridView 實(shí)現(xiàn)自動(dòng)滾動(dòng)的效果。
@required int crossAxisCount,//列或者行數(shù)尘应,取決于滾動(dòng)方向惶凝,即非主軸方向上的item個(gè)數(shù)
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, //默認(rèn)vertical
      crossAxisCount: 3, //列數(shù)
      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;
  }
}

以上是一個(gè)簡(jiǎn)單的GridView實(shí)現(xiàn),效果如下:


GridViewSimple1.png

此時(shí)犬钢,如果我們使用上一篇中的Draggable和DragTarget的組合Item苍鲜,是不是就可以實(shí)現(xiàn)可拖拽了呢?試一試
將 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

可以看到娜饵,現(xiàn)在確實(shí)已經(jīng)可以拖動(dòng)坡贺,并且數(shù)據(jù)也成功接收了。接下來想要實(shí)現(xiàn)排序的功能就是對(duì)數(shù)據(jù)做處理然后setState啦箱舞。
如果能在DragTarget的onAccept方法中直接獲取到數(shù)據(jù)源List遍坟,那么我們只需要把拖拽的item從他原來的位置remove,再insert到目標(biāo)位置晴股,就可以實(shí)現(xiàn)一個(gè)粗糙的拖拽排序了愿伴。
核心代碼如下:

 onAccept: (fromIndex) {
          setState(() {
            final temp = widget._dataList[fromIndex];
            widget._dataList.remove(temp);
            widget._dataList.insert(index, temp);
          });
        },

順便了解一下LongPressDraggable,是Draggable的子類电湘,區(qū)別就是手勢(shì)識(shí)別需要長(zhǎng)按才會(huì)觸發(fā)拖動(dòng)隔节,不詳細(xì)說明了,用起來是一樣的寂呛。
那么最終實(shí)現(xiàn)的可拖拽的GridView會(huì)是這樣的:


Draggable6.gif

長(zhǎng)按后Item變?yōu)榭赏献顟B(tài)怎诫,拖拽后松手,會(huì)將Item插入到對(duì)應(yīng)位置贷痪。
代碼如下:


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, //默認(rèn)vertical
      crossAxisCount: 3, //列數(shù)
      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的索引幻妓,方便交換數(shù)據(jù)
      child: DragTarget<int>(
        //松手時(shí) 如果onWillAccept返回true 那么久會(huì)調(diào)用
        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]}'),
            ),
          );
        },
        //手指拖著一個(gè)widget從另一個(gè)widget頭上滑走時(shí)會(huì)調(diào)用
        onLeave: (data) {
          print('$data is Leaving item $index');
        },
        //接下來松手 是否需要將數(shù)據(jù)給這個(gè)widget?  因?yàn)樾枰谕蟿?dòng)時(shí)改變UI劫拢,所以在這里直接修改數(shù)據(jù)源
        onWillAccept: (data) {
          print('$index will accept item $data');
          return true;
        },
      ),
      onDragStarted: () {
        //開始拖動(dòng)肉津,備份數(shù)據(jù)源
        print('item $index ---------------------------onDragStarted');
      },
      onDraggableCanceled: (Velocity velocity, Offset offset) {
        print(
            'item $index ---------------------------onDraggableCanceled,velocity = $velocity,offset = $offset');
        //拖動(dòng)取消,還原數(shù)據(jù)源
      },
      onDragCompleted: () {
        //拖動(dòng)完成舱沧,刷新狀態(tài)妹沙,重置willAcceptIndex
        print("item $index ---------------------------onDragCompleted");
      },
      //用戶拖動(dòng)item時(shí),那個(gè)給用戶看起來被拖動(dòng)的widget熟吏,(就是會(huì)跟著用戶走的那個(gè)widget)
      feedback: SizedBox(
        child: Center(
          child: Icon(Icons.feedback),
        ),
      ),
      //這個(gè)是當(dāng)item被拖動(dòng)時(shí)距糖,item原來位置用來占位的widget玄窝,(用戶把item拖走后原來的地方該顯示啥?就是這個(gè))
      childWhenDragging: Container(
        child: Center(
          child: Icon(Icons.child_care),
        ),
      ),
    );
  }
}

至此肾筐,一個(gè)簡(jiǎn)單的可以拖拽GridView就完成了哆料。
接下來思考如何讓item在拖拽時(shí)讓其余Item給他讓位置,

我在 onWillAccept 中添加了Log

print('$index will accept item $fromIndex');

當(dāng)拖動(dòng)第一個(gè)Item到第3個(gè)Item上方時(shí)吗铐,將會(huì)打印

flutter: 2 will accept item 0

  • 可以看到在onWillAccept方法中可以獲得拖動(dòng)的item的起點(diǎn)與終點(diǎn)东亦。
  • 如果我們?cè)趏nWillAccept方法中調(diào)用setState改變數(shù)據(jù)集的順序,應(yīng)該就可以在拖動(dòng)時(shí)讓UI跟隨手指移動(dòng)而變化唬渗。
  • 考慮到如果用戶最后又放棄了拖動(dòng)典阵,需要還原UI,我們應(yīng)該創(chuàng)建另一個(gè)List用來備份當(dāng)前數(shù)據(jù)集镊逝。
  • 與onWillAccept方法對(duì)應(yīng)的方法為onLeave壮啊,在拖動(dòng)的Item離開時(shí)將會(huì)調(diào)用,我們?cè)贒raggable的onDragStarted的時(shí)候記錄當(dāng)前數(shù)據(jù)集到備份集合中撑蒜,每次onLeave的時(shí)候還原數(shù)據(jù)集歹啼,當(dāng)取消拖動(dòng)時(shí)也取消數(shù)據(jù)集,這樣一來座菠,我們可以把onAccept中的代碼移動(dòng)到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的函數(shù),接收一個(gè)context參數(shù)和一個(gè)數(shù)據(jù)源參數(shù)浴滴,返回一個(gè)Widget
  final CanAccept canAccept; //是否接受拖拽過來的數(shù)據(jù)的回調(diào)函數(shù)
  final List<T> dataList; //數(shù)據(jù)源List
  final Axis scrollDirection; //GridView的滾動(dòng)方向
  final int
      crossAxisCount; //非主軸方向的item數(shù)量拓萌,即 如果GridView的滾動(dòng)方向是垂直方向,那么這個(gè)字段的意思就是有多少列升略;如果為水平方向微王,則此字段代表有多少行。
  final double
      childAspectRatio; //每個(gè)Item的寬高比品嚣,由于GridView的Item默認(rèn)是正方形的炕倘,可以通過這個(gè)比例稍作調(diào)整『渤牛可能會(huì)有我不知道的別的辦法罩旋。

  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; //數(shù)據(jù)源
  List<T> _dataListBackup; //數(shù)據(jù)源備份,在拖動(dòng)時(shí) 會(huì)直接在數(shù)據(jù)源上修改 來影響UI變化额嘿,當(dāng)拖動(dòng)取消等情況,需要通過備份還原
  bool _showItemWhenCovered = false; //手指覆蓋的地方劣挫,即item被拖動(dòng)時(shí) 底部的那個(gè)widget是否可見册养;
  int _willAcceptIndex = -1; //當(dāng)拖動(dòng)覆蓋到某個(gè)item上的時(shí)候,記錄這個(gè)item的坐標(biāo)
//  int _draggingItemIndex = -1; //當(dāng)前被拖動(dòng)的item坐標(biāo)
//  ScrollController _scrollController;//當(dāng)item數(shù)量超出屏幕 拖動(dòng)Item到底部或頂部 可使用ScrollController滾動(dòng)GridView 實(shí)現(xiàn)自動(dòng)滾動(dòng)的效果压固。

  @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, //默認(rèn)vertical
      crossAxisCount: widget.crossAxisCount, //列數(shù)
      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;
  }

  //繪制一個(gè)可拖拽的控件球拦。
  Widget _buildDraggable(BuildContext context, int index) {
    return LayoutBuilder(
      builder: (context, constraint) {
        return LongPressDraggable(
          data: index,
          child: DragTarget<int>(
            //松手時(shí) 如果onWillAccept返回true 那么久會(huì)調(diào)用,本案例不使用。
            onAccept: (int data) {},
            //繪制widget
            builder: (context, data, rejects) {
              return _willAcceptIndex >= 0 && _willAcceptIndex == index
                  ? null
                  : widget.itemBuilder(context, _dataList[index]);
            },
            //手指拖著一個(gè)widget從另一個(gè)widget頭上滑走時(shí)會(huì)調(diào)用
            onLeave: (int data) {
              //TODO 這里應(yīng)該還可以優(yōu)化坎炼,當(dāng)用戶滑出而又沒有滑入某個(gè)item的時(shí)候 可以重新排列  讓當(dāng)前被拖走的item的空白被填滿
              print('$data is Leaving item $index');
              _willAcceptIndex = -1;
              setState(() {
                _showItemWhenCovered = false;
                _dataList = _dataListBackup.sublist(0);
              });
            },
            //接下來松手 是否需要將數(shù)據(jù)給這個(gè)widget愧膀?  因?yàn)樾枰谕蟿?dòng)時(shí)改變UI,所以在這里直接修改數(shù)據(jù)源
            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: () {
            //開始拖動(dòng)谣光,備份數(shù)據(jù)源
//            _draggingItemIndex = index;
            _dataListBackup = _dataList.sublist(0);
            print('item $index ---------------------------onDragStarted');
          },
          onDraggableCanceled: (Velocity velocity, Offset offset) {
            print(
                'item $index ---------------------------onDraggableCanceled,velocity = $velocity,offset = $offset');
            //拖動(dòng)取消檩淋,還原數(shù)據(jù)源

            setState(() {
              _willAcceptIndex = -1;
              _showItemWhenCovered = false;
              _dataList = _dataListBackup.sublist(0);
            });
          },
          onDragCompleted: () {
            //拖動(dòng)完成,刷新狀態(tài)萄金,重置willAcceptIndex
            print("item $index ---------------------------onDragCompleted");
            setState(() {
              _showItemWhenCovered = false;
              _willAcceptIndex = -1;
            });
          },
          //用戶拖動(dòng)item時(shí)蟀悦,那個(gè)給用戶看起來被拖動(dòng)的widget,(就是會(huì)跟著用戶走的那個(gè)widget)
          feedback: SizedBox(
            width: constraint.maxWidth,
            height: constraint.maxHeight,
            child: widget.itemBuilder(context, _dataList[index]),
          ),
          //這個(gè)是當(dāng)item被拖動(dòng)時(shí)氧敢,item原來位置用來占位的widget日戈,(用戶把item拖走后原來的地方該顯示啥?就是這個(gè))
          childWhenDragging: Container(
            child: SizedBox(
              child: _showItemWhenCovered
                  ? widget.itemBuilder(context, _dataList[index])
                  : null,
            ),
          ),
        );
      },
    );
  }
}

然后將這個(gè)自定義的SortableGridView創(chuàng)建出來孙乖,填充到最開始的空白頁中浙炼。即可實(shí)現(xiàn)最終的效果。
使用方式如下:


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, //豎向滑動(dòng)
      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),
          ),
        ));
      },
    );
  }
}

至此唯袄,一個(gè)可拖拽的還算能看的GridView就算完成了弯屈。

最近在github上看到了一個(gè)DragAndDropList。
地址:https://github.com/Norbert515/flutter_list_drag_and_drop

是一個(gè)可拖拽的ListView越妈。通過修改Draggable的源碼實(shí)現(xiàn)季俩,感覺這個(gè)思路比我的好,先研究一下梅掠,可能下一篇會(huì)按照他的方式優(yōu)化一下SortableGridView酌住。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市阎抒,隨后出現(xiàn)的幾起案子酪我,更是在濱河造成了極大的恐慌,老刑警劉巖且叁,帶你破解...
    沈念sama閱讀 206,311評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件都哭,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡逞带,警方通過查閱死者的電腦和手機(jī)欺矫,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來展氓,“玉大人穆趴,你說我怎么就攤上這事∮龉” “怎么了未妹?”我有些...
    開封第一講書人閱讀 152,671評(píng)論 0 342
  • 文/不壞的土叔 我叫張陵簿废,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我络它,道長(zhǎng)族檬,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,252評(píng)論 1 279
  • 正文 為了忘掉前任化戳,我火速辦了婚禮单料,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘迂烁。我一直安慰自己看尼,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,253評(píng)論 5 371
  • 文/花漫 我一把揭開白布盟步。 她就那樣靜靜地躺著藏斩,像睡著了一般。 火紅的嫁衣襯著肌膚如雪却盘。 梳的紋絲不亂的頭發(fā)上狰域,一...
    開封第一講書人閱讀 49,031評(píng)論 1 285
  • 那天,我揣著相機(jī)與錄音黄橘,去河邊找鬼兆览。 笑死,一個(gè)胖子當(dāng)著我的面吹牛塞关,可吹牛的內(nèi)容都是我干的抬探。 我是一名探鬼主播,決...
    沈念sama閱讀 38,340評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼帆赢,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼小压!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起椰于,我...
    開封第一講書人閱讀 36,973評(píng)論 0 259
  • 序言:老撾萬榮一對(duì)情侶失蹤怠益,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后瘾婿,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體蜻牢,經(jīng)...
    沈念sama閱讀 43,466評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,937評(píng)論 2 323
  • 正文 我和宋清朗相戀三年偏陪,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了抢呆。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,039評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡笛谦,死狀恐怖抱虐,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情揪罕,我是刑警寧澤梯码,帶...
    沈念sama閱讀 33,701評(píng)論 4 323
  • 正文 年R本政府宣布,位于F島的核電站好啰,受9級(jí)特大地震影響轩娶,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜框往,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,254評(píng)論 3 307
  • 文/蒙蒙 一鳄抒、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧椰弊,春花似錦许溅、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至清焕,卻和暖如春并蝗,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背秸妥。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評(píng)論 1 262
  • 我被黑心中介騙來泰國打工滚停, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人粥惧。 一個(gè)月前我還...
    沈念sama閱讀 45,497評(píng)論 2 354
  • 正文 我出身青樓键畴,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國和親突雪。 傳聞我的和親對(duì)象是個(gè)殘疾皇子起惕,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,786評(píng)論 2 345