Drag and Drop Header for HorizontalDataTable

Drag and Drop Header for HorizontalDataTable

·

5 min read

Recently, I have gradually updating common examples when using the horizontal_data_table packages. One of the popular topic is enable to reorder the columns. This feature is really easy by working with the flutter Draggable and DropTarget Widget. The following is the steps I have been playing with the example SimpleTablePage.

The whole class can check with the GitHub repo.

This is what it shows at the end of the article: table-reorder.gif

Let's start!

First, extract the important information from each column to a private list. I have created a data class UserColumnInfo just to store the header name and width.

late List<UserColumnInfo> _colInfos;

@override
  void initState() {    
    super.initState();
    widget.user.initData(100);
    _colInfos = [
      const UserColumnInfo('Name', 100),
      const UserColumnInfo('Status', 100),
      const UserColumnInfo('Phone', 200),
      const UserColumnInfo('Register', 100),
      const UserColumnInfo('Termination', 200),
    ];
  }

Next, we start working on the header. If you trace to the _getTitleWidget function, you will see the header widgets are basically the same, the only different is their width.

List<Widget> _getTitleWidget() {
    return [
      _getTitleItemWidget('Name', 100),
      _getTitleItemWidget('Status', 100),
      _getTitleItemWidget('Phone', 200),
      _getTitleItemWidget('Register', 100),
      _getTitleItemWidget('Termination', 200),
    ];
  }

Then it is easy that we can apply the _colInfos to generate the list of widget easily, like this:

List<Widget> _getTitleWidget() {
    return _colInfos.map((e) => _getTitleItemWidget(e.name, e.width)).toList();
}

After all, everything is set. We can start our drag and drop implementation.

Since we are handling this with the Draggable and DropTarget, if you are not familiar with these two widget, it is recommend you to take a few minutes to watch this video from flutter.dev:

What I need to do is to prepare the Draggable widget header allows people drag and a DropTarget for other header to drop to. And this is it! I will explain more below.

List<Widget> _getTitleWidget() {
    return _colInfos
        .map((e) => DragTarget(
              builder: (context, candidateData, rejectedData) {
                return Draggable<String>(
                  data: e.name,
                  feedback:
                      Material(child: _getTitleItemWidget(e.name, e.width)),
                  child: _getTitleItemWidget(e.name, e.width),
                );
              },
              onWillAccept: (value) {
                return value != e.name;
              },
              onAccept: (value) {
                int oldIndex =
                    _colInfos.indexWhere((element) => element.name == value);
                int newIndex =
                    _colInfos.indexWhere((element) => element.name == e.name);
                UserColumnInfo temp = _colInfos.removeAt(oldIndex);
                _colInfos.insert(newIndex, temp);
                setState(() {});
              },
            ))
        .toList();
  }

builder is to build the widget that the DragTarget is displaying. I build the Draggable inside to enable the child to be draggable to somewhere else. The feedback widget is wrapped the Material widget because the feedback widget is not inheriting the parent theme on default. If you want it to adopt the same UI as the existing header looks like. You need to let the child of feedback wrapped by the Theme widget. In this case, it is using the Materail theme and therefore I just simply use the Material widget to handle this issue.

builder: (context, candidateData, rejectedData) {
  return Draggable<String>(
    data: e.name,
    feedback:
        Material(child: _getTitleItemWidget(e.name, e.width)),
    child: _getTitleItemWidget(e.name, e.width),
  );
}

onWillAccept is indicating which value is allowed to be accept. Since the interchange only allow the header is different to the existing position’s header, the value is set to not equal to the current header name.

onWillAccept: (value) {
  return value != e.name;
},

onAccept is handling the changes when the drop is accepted. I first find out the old and new index of the column. And then just simply remove and insert the column again.

onAccept: (value) {
  int oldIndex =
      _colInfos.indexWhere((element) => element.name == value);
  int newIndex =
      _colInfos.indexWhere((element) => element.name == e.name);
  UserColumnInfo temp = _colInfos.removeAt(oldIndex);
  _colInfos.insert(newIndex, temp);
  setState(() {});
}

While header part is finished, the body part needs to follow the change of columns. I use the similar approach for the body part. I first extract the table cell widgets. Since there are generally two types of cell, one is plain text and one is icon. I have these two functions:

Widget _generateGeneralColumnCell(
      BuildContext context, int rowIndex, int colIndex) {
    return Container(
      width: _colInfos[colIndex].width,
      height: 52,
      padding: const EdgeInsets.fromLTRB(5, 0, 0, 0),
      alignment: Alignment.centerLeft,
      child: Text(widget.user.userInfo[rowIndex].get(_colInfos[colIndex].name)),
    );
  }

  Widget _generateIconColumnCell(
      BuildContext context, int rowIndex, int colIndex) {
    return Container(
      width: 100,
      height: 52,
      padding: const EdgeInsets.fromLTRB(5, 0, 0, 0),
      alignment: Alignment.centerLeft,
      child: Row(
        children: <Widget>[
          Icon(
              widget.user.userInfo[rowIndex].status
                  ? Icons.notifications_off
                  : Icons.notifications_active,
              color: widget.user.userInfo[rowIndex].status
                  ? Colors.red
                  : Colors.green),
          Text(widget.user.userInfo[rowIndex].status ? 'Disabled' : 'Active')
        ],
      ),
    );
  }

You may notice these is a get(_colInfos[colIndex].name) for the getting the UserInfo field. Since the header is dynamic changing, the field cannot be hardcoded. I have added a function get in UserInfo class to get the field value by their header name.

dynamic get(String fieldName) {
  if (fieldName == 'Name') {
    return name;
  } else if (fieldName == 'Status') {
    return status;
  } else if (fieldName == 'Phone') {
    return phone;
  } else if (fieldName == 'Register') {
    return registerDate;
  } else if (fieldName == 'Termination') {
    return terminationDate;
  }
  throw Exception('Invalid field name');
}

The HorizontalDataTable left hand side and right hand side builder will also changed as following like the header:

Widget _generateFirstColumnRow(BuildContext context, int rowIndex) {
  if (_colInfos.first.name == 'Status') {
    return _generateIconColumnCell(context, rowIndex, 0);
  } else {
    return _generateGeneralColumnCell(context, rowIndex, 0);
  }
}

Widget _generateRightHandSideColumnRow(BuildContext context, int rowIndex) {
  return Row(
    children: _colInfos.sublist(1).map((e) {
      if (e.name == 'Status') {
        return _generateIconColumnCell(
            context, rowIndex, _colInfos.indexOf(e));
      } else {
        return _generateGeneralColumnCell(
            context, rowIndex, _colInfos.indexOf(e));
      }
    }).toList(),
  );
}

Finally, since the column will change to different order, the column width will also changed. The total width of the fixed side column and the bi-directional side need to be update as follow:

double get _sumOfRightColumnWidth {
  return _colInfos
      .sublist(1)
      .map((e) => e.width)
      .fold(0, (previousValue, element) => previousValue + element);
}

HorizontalDataTable(
  leftHandSideColumnWidth: _colInfos.first.width,
  rightHandSideColumnWidth: _sumOfRightColumnWidth,
    ...
)

This is what it looks like now:

table-reorder.gif