Flutter Dynamic expansion tile for group data

eye-catch Dart and Flutter

I needed a expansion tile for grouping data. I wanted to show item list like a folder structure on VS Code. Top parents have children that can also be parents. This is the sample video.

When item list is retrieved from a database the expansion tiles need to be created dynamically. How can we implement it? Let’s consider together.

Sponsored links

Tree data structure

First of all, we have to consider the data structure to describe the relationships between a parent and children. What I came up with was following two patterns.

  • A parent has children (Parent to children)
  • A child has a parent (Child to parent)
category-structure

Parent to children

If we want to implement in this way the code looks like this.

class Item {
    String uid;
    List<String> children;
}

An item has an unique ID and ID list for the children. If we want to have an item that can belong to more than two group this way is suitable. However, it is not what I want. What I want is following.

  • An item can have only one parent (can have no parent)
  • An item can have children (can have no child)

Therefore, this way suits to my purpose.

Child to a parent

Let’s look at the code first.

class Item {
    String uid;
    String? parent;
}

Item has parent property that can have only one parent. This is what I want. We can show folder structure with this class structure.

But, wait… If we use this class we need to create the item tree from bottom up whereas Flutter widgets are top down. We need somehow to adjust it.

Sponsored links

Creating item tree from top down

Since Flutter creates widget tree from top down we need to have following code showed above.

class Item {
    String uid;
    List<String> children;
}

Let’s generalize it.

class GroupBase {
  final String uid;
  final String? parent;

  GroupBase({
    required this.uid,
    this.parent,
  });
}

class Parent<T extends GroupBase> {
  final T self;
  Iterable<Parent<T>>? children;

  Parent({
    required this.self,
    this.children,
  });
}

GroupBase follows “Child to Parent” way. We can use the class as it is if no other property is needed. Create a class that has additional properties and extends GroupBase, if necessary. Parent class has itself and children. Children have the same structure. How can we create a item tree?

Let’s assume that we have following item list.

final data = [
    GroupBase(uid: "group-1"),
    GroupBase(uid: "group-2"),
    GroupBase(uid: "group-1-1" parent: "group-1"),
    GroupBase(uid: "group-2-1" parent: "group-2"),
    GroupBase(uid: "group-3"),
    GroupBase(uid: "group-2-1-1", parent: "group-2-1"),
    GroupBase(uid: "group-2-2",  parent: "group-2"),
    GroupBase(uid: "group-2-3",  parent: "group-2"),
];

There are 3 top level parents, “group-1”, “group-2” and “group-3”. Let’s extract them first and then, change them to Parent class.

final topParents = data
    .where((e) => e.parent == null)
    .map((e) => Parent<T>(self: e))
    .toList();

It’s easy and simple. The list contains items that have the same uid of parent. We need to find the uid set to parent property. The code is following.

final children = data
    .where((e) => e.parent == parent.self.uid) // looking for the same id
    .map((e) => Parent<T>(self: e))
    .toList();

Currently, the parent doesn’t have any children at the moment. children property is null, so we need to set the result there.

if (children.isNotEmpty) {
  parent.children = children;
}

It’s the process to get the children. This is first level (depth) children. The process needs to be repeated since the children may have children. It means the process needs to be done recursively. _createItemTree is the function for this.

if (children.isNotEmpty) {
  _createItemTree(nextParents);
}

Since top parents are list type it needs to be repeated. Following is the complete code.

List<Parent<T>> _createItemTree(List<Parent<T>> parents) {
  final List<Parent<T>> nextParents = [];

  for (final parent in parents) {
    final children = data
        .where((e) => e.parent == parent.self.uid)
        .map((e) => Parent<T>(self: e))
        .toList();

    if (children.isNotEmpty) {
      parent.children = children;
      nextParents.addAll(children);
    }
  }

  if (nextParents.isNotEmpty) {
    _createItemTree(nextParents);
  }
  return parents;
}

This code works but we can improve this code to reduce memory usage. In this code, the function stores children list in nextParents because it tries to complete the process for the children on the same level. The process looks like following.

tree-search-1


If it tries to go to bottom first, the code becomes following.

List<Parent<T>> _createItemTree(List<Parent<T>> parents) {
  for (final parent in parents) {
    final children = data
        .where((e) => e.parent == parent.self.uid)
        .map((e) => Parent<T>(self: e))
        .toList();

    if (children.isNotEmpty) {
      parent.children = children;
      _createItemTree(children);
    }
  }
  return parents;
}

It’s shorter and simpler, isn’t it? The process looks like this.

tree-search-2

Creating expansion tile widget tree

Next step is to create expansion tile widget tree. Let’s use the item tree that we just created before. Logic is the same as creating item tree. Final code is following.

Widget _createWidgetTree(Parent<T> parent, int depth) {
  final Iterable<Widget> children =
      parent.children?.map((e) => _createWidgetTree(e, depth + 1)) ?? [];

  final leading = children.isEmpty ? const Icon(Icons.remove) : null;
  return ExpansionTile(
    tilePadding: EdgeInsets.only(left: depth * 20),
    initiallyExpanded: true,
    title: Text(parent.self.uid),
    children: children.toList(),
    controlAffinity: ListTileControlAffinity.leading,
  );
}

If there are 3 top level parents, the function is called three times in the following code. Children follow the same process. If it doesn’t have children it returns empty array and goes to next step.

final Iterable<Widget> children =
    parent.children?.map((e) => _createWidgetTree(e, depth + 1)) ?? [];

Icon should change to make it clear that it doesn’t have any children.

final leading = children.isEmpty ? const Icon(Icons.remove) : null;

The last step is creating expansion tile. The deeper level the child is, the more right side the widget is placed.

return ExpansionTile(
    tilePadding: EdgeInsets.only(left: depth * 20),
    initiallyExpanded: true,
    title: Text(parent.self.uid),
    children: children.toList(),
    controlAffinity: ListTileControlAffinity.leading,
);

Download the Package

I created this package and published it. If you don’t want to create the code yourself you can download it.

grouped_expansion_tile | Flutter Package
Expansion Tile for grouped data

Currently it is not so flexible. If you want to extend it it’s welcome. Go to GitHub repository and create a new issue or open your PR for it. Writing unit test is also welcome.

Comments

Copied title and URL