Flutter Draggable Expansion Tile

eye-catch Dart and Flutter

When we want to make a widget draggable, Draggable class is useful and easy to to use. An Expansion Tile has children. How can we make each of them draggable? Let’s learn how to do it in this article. Following video shows the final artifact.

Sponsored links

Data preparation

We have 4 food items on the view. Let’s define following data class.

class DraggableFood {
  String value;
  Widget widget;
  DraggableFood({
    required this.value,
    required this.widget,
  });
}

We will set ExpansionTile or Card with ListTile to widget property. Following function is for the child items.

DraggableFood _createFood(String name) {
  final listTile = ListTile(
    leading: Icon(Icons.food_bank),
    title: Text(name),
  );
  return DraggableFood(
    value: name,
    widget: Card(child: listTile),
  );
}

Following function is for ExpansionTile. Since it has children it requires food list. _createDraggable is called for each item because we want to make all items draggable. Without this call, only expansion tile can be draggable.

DraggableFood _createExpansionTile(
    String groupName, List<DraggableFood> foods) {
  final widgets = foods.map((e) => _createDraggable(context, e)).toList();

  final expansionTile = ExpansionTile(
    title: Text(groupName),
    children: widgets,
  );

  return DraggableFood(
    value: foods.map((e) => e.value).join(","),
    widget: expansionTile,
  );
}

We can call them in following way.

final egg = _createFood("Egg");
final bacon = _createFood("Bacon");
final milk = _createFood("Milk");
final baconEggGroup = _createExpansionTile("baconEgg", [egg, bacon, milk]);
final cheese = _createFood("Cheese won't be accepted");

These variables have a widget but it has not been draggable yet. Let’s make them draggable next.

Sponsored links

Make a widget draggable

We need to use Draggable class if we want to make a widget draggable. It is easy to use. The simplest code is following.

Draggable(
  child: widget,
  feedback: widget,
);

Expansion Tile causes “No Material widget found” error

When I tried to use Draggable for Expansion Tile I got “No Material widget found.” error. Following is an example code. The child widget appears without motion. If we change the appearance while dragging the widget we need to set a different widget to feedback.

Draggable(
  child: Text("Hello"),
  feedback: ExpansionTile(
    title: Text("Dragging"),
    ),
);

The code is simple. The complete error message is following.

Restarted application in 2,815ms.

════════ Exception caught by widgets library ═══════════════════════════════════
The following assertion was thrown building ListTile(dirty):
No Material widget found.

ListTile widgets require a Material widget ancestor.
In material design, most widgets are conceptually "printed" on a sheet of material. In Flutter's material library, that material is represented by the Material widget. It is the Material widget that renders ink splashes, for instance. Because of this, many material library widgets require that there be a Material widget in the tree above them.

To introduce a Material widget, you can either directly include one, or use a widget that contains Material itself, such as a Card, Dialog, Drawer, or Scaffold.

The specific widget that could not find a Material ancestor was: ListTile
    dirty
The ancestors of this widget were
    MaterialApp
        state: _MaterialAppState#9a2c0
    ...
The relevant error-causing widget was
ListTile ListTile:.../flutter_samples/lib/draggable_sampe.dart:76:22
When the exception was thrown, this was the stack
#0      debugCheckHasMaterial.<anonymous closure>
#1      debugCheckHasMaterial
#2      ListTile.build
#3      StatelessElement.build
#4      ComponentElement.performRebuild
#5      Element.rebuild
#6      ComponentElement._firstBuild
#7      ComponentElement.mount
...     Normal element mounting (30 frames)
#37     Element.inflateWidget
#38     MultiChildRenderObjectElement.inflateWidget
#39     Element.updateChild
#40     RenderObjectElement.updateChildren
#41     MultiChildRenderObjectElement.update
#42     Element.updateChild
#43     ComponentElement.performRebuild
#44     StatefulElement.performRebuild
#45     Element.rebuild
#46     BuildOwner.buildScope
#47     WidgetsBinding.drawFrame
#48     RendererBinding._handlePersistentFrameCallback
#49     SchedulerBinding._invokeFrameCallback
#50     SchedulerBinding.handleDrawFrame
#51     SchedulerBinding._handleDrawFrame
#55     _invoke (dart:ui/hooks.dart:166:10)
#56     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:270:5)
#57     _drawFrame (dart:ui/hooks.dart:129:31)
(elided 3 frames from dart:async)
════════════════════════════════════════════════════════════════════════════════

════════ Exception caught by widgets library ═══════════════════════════════════
No Material widget found.
The ancestors of this widget were
    MaterialApp
        state: _MaterialAppState#9a2c0
    ...
The relevant error-causing widget was
ListTile ListTile:.../flutter_samples/lib/draggable_sampe.dart:76:22
════════════════════════════════════════════════════════════════════════════════

According to the error message, there’s something wrong with ListTile. The same error occurs if we try following.

Widget _createDraggable(BuildContext context, DraggableFood data) {
  final feedback = ListTile(
    leading: const Icon(Icons.directions_run),
    title: Text(data.value),
  );

  return Draggable(
    data: data.value,
    child: data.widget,
    feedback: feedback,
  );
}

Its error didn’t occur when I use Draggable for Text widget. The error occurs only when I start dragging the widget. What do we need to do to solve it? There’s a hint in the error message.

To introduce a Material widget, you can either directly include one, or use a widget that contains Material itself, such as a Card, Dialog, Drawer, or Scaffold.

“RenderBox was not laid out” error

Hmm… I placed a Scaffold in the parent but the error occurs… Why? Anyway, let’s try to add a Material widget to the Draggable.

Widget _createDraggable(BuildContext context, DraggableFood data) {
  final feedback = Material(  // Add Material widget
      child: ListTile(
        leading: const Icon(Icons.directions_run),
        title: Text(data.value),
    ),
  );

  return Draggable(
    data: data.value,
    child: data.widget,
    feedback: feedback,
  );
}

It causes following two errors again!

════════ Exception caught by rendering library ═════════════════════════════════
RenderBox was not laid out: RenderIgnorePointer#e4dfc relayoutBoundary=up1
'package:flutter/src/rendering/box.dart':
Failed assertion: line 1929 pos 12: 'hasSize'

The relevant error-causing widget was
MaterialApp MaterialApp:.../flutter_samples/lib/main.dart:8:10
════════════════════════════════════════════════════════════════════════════════

════════ Exception caught by rendering library ═════════════════════════════════
'package:flutter/src/rendering/proxy_box.dart': Failed assertion: line 1889 pos 12: 'hasSize': is not true.
The relevant error-causing widget was
Material Material:.../flutter_samples/lib/draggable_sampe.dart:81:22
════════════════════════════════════════════════════════════════════════════════

It seems that there is no widget to specify size. Let’s add it.

Widget _createDraggable(BuildContext context, DraggableFood data) {
  final feedback = Material(
    child: ConstrainedBox(
      constraints:
          BoxConstraints(maxWidth: MediaQuery.of(context).size.width),
      child: ListTile(
        leading: const Icon(Icons.directions_run),
        title: Text(data.value),
      ),
    ),
  );

  return Draggable(
    data: data.value,
    child: data.widget,
    feedback: feedback,
  );
}

It works as expected with this code. It just makes it draggable. This is source widget and we need a destination widget.

Define DragTarget that accepts the dragged item

Next step, we need to define DragTarget widget. It will be a box that accepts the drop behavior. I defined only required properties.

Border border = Border.all(color: Colors.black, width: 5);
Widget _createDragTarget(BuildContext context) {
  final target = DragTarget<String>(
    builder: (
      BuildContext context,
      List<dynamic> accepted,
      List<dynamic> rejected,
    ) {
      final text = 'Egg: $eggCount\n'
          'Bacon: $baconCount\n'
          'Milk: $milkCount\n'
          'Cheese: $cheeseCount\n';

      return Container(
        height: 100,
        width: 200,
        child: Text(text),
        decoration: BoxDecoration(
          border: border,
          color: Colors.blue.shade50,
        ),
      );
    },
    onAccept: (data) {
      setState(() {
        eggCount += data.contains("Egg") ? 1 : 0;
        milkCount += data.contains("Milk") ? 1 : 0;
        baconCount += data.contains("Bacon") ? 1 : 0;
        cheeseCount += data.contains("Cheese") ? 1 : 0;
        border = _setBorder(Colors.black);
      });
    },
  );

  return Container(
    padding: EdgeInsets.only(top: 100),
    child: target,
  );
}

Border _setBorder(Color color) => Border.all(color: color, width: 5);

The behavior looks following.

By the way, if different data type is specified here, onAccept doesn’t accept the drop behavior.

final target = DragTarget<String>(

Change border color when dragging over the widget

For usability, we should change the border color when dragging over the destination widget. Let’s implement the feature.

To change the border color, its widget needs to be updated when cursor/finger is over the widget. We need to add onMove property.

onAccept: (data) {
  setState(() {
    eggCount += data.contains("Egg") ? 1 : 0;
    milkCount += data.contains("Milk") ? 1 : 0;
    baconCount += data.contains("Bacon") ? 1 : 0;
    cheeseCount += data.contains("Cheese") ? 1 : 0;
    border = _setBorder(Colors.black);
  });
},
onMove: (details) {
  setState(() {
    border = _setBorder(Colors.red);
  });
},

We can access to the dragged widget’s data via details.data if necessary. Border color changes when the curser is over the widget.

But its color should be back when leaving the box. onLeave is for that.

onMove: (details) {
  setState(() {
    border = _setBorder(Colors.red);
  });
},
onLeave: (data) => border = _setBorder(Colors.black),

It works

Deny the specific widget (accept condition)

Current application accepts cheese. If cheese is unnecessary for bacon egg it should deny it. We can add the condition to onWillAccept property. We need null check since the argument can be null.

onLeave: (data) => border = _setBorder(Colors.black),
onWillAccept: (data) => data?.contains("Cheese") == false,

End

The implementation is easier than I thought. Go to following repository if you need complete flutter sample code.

GitHub - yuto-yuto/flutter_samples
Contribute to yuto-yuto/flutter_samples development by creating an account on GitHub.

Following are related articles.

Comments

Copied title and URL