Creating Hierarchical Item Lists in Flutter: A Step-by-Step Guide. Part 2

In this second post of the series, we continue our journey in creating an item tree with one level of children using Flutter.

Recap

To recap briefly, our goal is to display a list of Items, such as User Stories or Bugs, associated with a Sprint. Each Item has a list of Task Items as children.

List Example

The list includes three types of items:

  • User Stories: These items are represented by a blue icon, an ID, attributes, and an expand button.
  • Bugs: Similar to User Stories, but with a red bug icon.
  • Tasks: These yellow-icon items are primarily found in the children lists of the previous items.

A drop-down list above the items allows you to select either the current or the previous sprint.

Previous Post Focus

In the previous post, we delved into the implementation of the ListView using FutureBuilder, data structures, and the backend call to retrieve data.

Focus of This Post

In this post, our focus shifts to the implementation of the ItemTile, where we dive deeper into its structure and functionalities.

The ItemTile, as the name suggests, acts as a tile containing the attributes of an individual item within the list. It serves as a generic tile that displays attributes related to the item types we described earlier.

Observing the animated GIF at the beginning of this post, we notice that an ItemTile can be hidden. Due to this behavior, the widget is implemented as a StatefulWidget, as we need to manage the visibility attribute.

Defining the Widget

Let’s define the widget with the following code:

class ItemTile extends StatefulWidget {
  final Item item;

  const ItemTile({super.key, required this.item});
  
  @override
  State<StatefulWidget> createState() => _ItemTileState();

}

As we can see, the class has the Item as a parameter in the constructor, allowing access to the item properties. In Flutter, a stateful widget must extend the StatefulWidget class and provide a state that, in our case, is managed by the _ItemTileState class below:

class _ItemTileState extends State<ItemTile> {
  late bool isVisible;
  @override
  void initState() {
    isVisible = false;
    super.initState();
  }
  
  @override
  Widget build(BuildContext context) {
    return Container(
      color: widget.item.remaining < 0 ? Colors.redAccent : null,
      child: Column(
        children: [
          ItemTileAttribs(item: widget.item, expandCallback: expandCallback, listExpanded: isVisible,),
          ItemChildrenList(isVisible: isVisible, item: widget.item)
        ],
      )
    );
  }
  
  void expandCallback(){ 
    setState(() {
      isVisible =!isVisible;
    });
  }
  
}

As we can observe from the code, the class extends the Flutter generic State class. The class manages a state composed of the isVisible variable and implements the build method, similarly to what we discussed about the StatelessWidget in the previous post.

In the build method, there’s the logic to render the widget. It returns a Container whose color is chosen based on a parameter of the item contained in the widget. The Container has a child, a Flutter Column widget that defines the layout, rendering the ItemTileAttribs and ItemChildrenList widgets.

Before we delve into the custom widget classes, let’s focus on the expandCallback method (row 22). This method relies on the use of the setState function, a fundamental mechanism in Flutter for notifying the framework that the widget’s state has changed and that the user interface should be redrawn.

Now, let’s take a closer look at the main components of the expandCallback:

  • The method doesn’t return any value (void) but affects the widget’s state.
  • setState(() { ... });: This is where the core action takes place. setState takes a callback function that should be executed when you need to modify the widget’s state. Within this callback, you can make state changes.
  • isVisible = !isVisible;: Inside the callback function, this line toggles the value of the boolean variable isVisible.

The ItemTileAttribs can be implemented as a StatelessWidget as follows:

class ItemTileAttribs extends StatelessWidget {
  final Item item;
  final Function expandCallback;
  final bool listExpanded;

  const ItemTileAttribs({super.key, required this.item, 
    required this.expandCallback, required this.listExpanded});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        if (item.type == 'Task') const SizedBox(width: 32.0),
        const SizedBox(width: 16.0),
        Column(
          children: [
            InkWell(
                child: Text("${item.id}",
                  style: const TextStyle(
                    fontWeight: FontWeight.bold,
                    fontSize: 18,
                    decoration: TextDecoration.underline,
                    color: Colors.blue,
                  ),
                ),
                onTap: () => _launchUrl(item.link)
            ),
          ]),
        const SizedBox(width: 16.0),
        Column(
          children: [
            _itemIcon(item.type),
          ]),
        const SizedBox(width: 16.0),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(item.title,
                style: const TextStyle(
                  fontWeight: FontWeight.bold,
                  fontSize: 16.0,
                ),
              ),
              _keyValueText("Assigned to: ", item.assignedTo),
              _keyValueText("Sprint: ", item.sprint),
              
            ],
          ),
        ),
        Expanded(
          child: Column(
            children: [
              Text("State: ${item.state}",
                style: const TextStyle(
                  fontWeight: FontWeight.bold,
                  fontSize: 16.0,
                ),
              ),
            
            ],
          ),
        ),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
                item.type != 'Task' ?_keyValueText("Story Points: ", item.storyPoints) :
                    const SizedBox(height: 14.0),
                _keyValueText("Completed Work: ",  item.completedWork),
                _keyValueText("Remaining: ", item.remaining), 
                _keyValueText("Percentage Remaining: ", item.percentageRemaining),
              ],
            ),
        ),
        if (item.type != 'Task') _expandButton(item, listExpanded),
      ],
    );
  }
  RichText _keyValueText(key, value) {
    bool isNegative = false;
  
    if (value is double || value is int) {
      if (value < 0) {
        isNegative = true;
      }
    }
    return RichText( 
      text: TextSpan(
        children: <TextSpan>[
        TextSpan(
          text: key,
          style: const TextStyle(
            fontWeight: FontWeight.bold,
          ),
        ),
        TextSpan(
          text: value.toString(),
          style: TextStyle(
            fontWeight: isNegative ? FontWeight.bold : null,
          ),
        ),
      ])
    );
  }
  
  _itemIcon(String type) {
    IconData icon;
    Color color;

    if (type == 'Bug') {
      icon = Icons.bug_report;
      color = Colors.red;
    }
    else if (type == 'User Story') {
      icon = Icons.notes;
      color = Colors.lightBlue;
    }
    else if (type == 'Task') {
      icon = Icons.task;
      color = Colors.amber;
    }
    else {
      return  null;
    }
    return Icon(
      icon,
      color: color,
      size: 30.0,
    );
  }
  
  Widget _expandButton(Item item, bool listExpanded) {
      return SizedBox(
        width: 72.0,
        child: Row(
          children: [ 
            if (item.priority == 0) const Icon(Icons.warning_amber_rounded,
              size: 24,
            ) else const SizedBox(width: 24.0),
            if (item.children.isNotEmpty) IconButton(
              icon: listExpanded ? const Icon(Icons.arrow_drop_up_outlined) 
                : const Icon(Icons.arrow_drop_down_outlined),
              tooltip: 'Expand children',
              iconSize: 24.0,
              onPressed: () {
                expandCallback();
              })
            else const SizedBox(width: 24.0),
          ],
        ),
      );
  }
  

  _launchUrl(String url) async {
    if (!await launchUrl(Uri.parse(url))) {
      throw Exception('Could not launch $url');
    }
  }

}

It might seem like a large piece of code, but it’s not overly complicated. In fact, the code accomplishes the following:

  • Renders the item attributes using various Flutter widgets.
  • Displays different icons based on the item type.
  • Defines the behavior of the small button that allows users to expand the item children, using the provided callback.

The code for ItemChildrenList is as follows. It’s shorter than the previous one and relatively straightforward. The AnimatedContainer within the SingleChildScrollView has the visible attribute, set by the ItemTile and updated by the expandCallback. This means that when you load the page, it’s not visible, and when you click the button, it appears if the item has children.

class ItemChildrenList extends StatelessWidget {
  final bool isVisible;
  final Item item;

  const ItemChildrenList({super.key, required this.isVisible, required this.item});

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 500),
        curve: Curves.easeInOut,
        child: Visibility(
          visible: isVisible,
          child: Column(
            children: [
              const Divider(
                color: Colors.grey,
                thickness: 1.0,
              ),
              ListView.separated(
                itemBuilder: (context, index) {
                  return ItemTile(
                    item: item.children[index],
                  );
                },
                separatorBuilder: (BuildContext context, int index) {
                  return const Divider(
                    color: Colors.grey,
                    thickness: 1.0,
                  );
                },
                itemCount: item.children.length,
                scrollDirection: Axis.vertical,
                shrinkWrap: true,
              ),
            ],
          ),
        )
      )
    );
  }
}

The widgets presented in this post and the previous one are easily usable within a page, similar to how you would use other widgets in Flutter. You can download the code at the following links:

  • user_stories.dart: Contains the page that loads the widget defined in this series.
  • items_list.dart: Contains the code shown in this series.
  • sprint_selector.dart: Contains the code to generate the DropdownButton that allows switching between current and previous sprints.

I hope you enjoyed this post, if you want to share it to your contacts on the social media, you can do it by clicking on the social icons at the beginning of this post, otherwise you can copy the url and share it wherever you like.

If you want to reach me, you will find all my contacts in the About page. Sooner or later I will enable comments on my posts, in the meantime, if you have questions, contact me, I’ll be happy to answer.