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

In this first post of this little series, I want to share how I implemented an item tree with one level of children using Flutter.
I encountered this problem: I needed 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. I retrieved the Items from Microsoft DevOps, but that’s just a detail as this post doesn’t focus on the backend.

In the following figure, you can see the list I implemented.

As you can see, the list contains three types of items:

  • User Stories: These items have a blue icon, as shown, an ID, some attributes, and a little button to expand the children list.
  • Bugs: These items are quite similar to the previous ones, but with a red bug icon.
  • Tasks: These items have a yellow icon and are primarily found in the children lists of the previous items

The drop-down list over the items list, allows to select between the current and the previous sprint.

Let’s stop beating around the bush and see some code.

class ItemsList extends StatelessWidget {
  final SprintElement? sprint;
  final SprintViewApi sprintViewApi;

  const ItemsList({
    this.sprint,
    required this.sprintViewApi,
    super.key,
  });

  //continues...

The ItemsList class extends StatelessWidget and accepts two constructor arguments:

  • SprintElement: that contains the information about the selected sprint.
  • SprintViewApi: that is the api that you call to obtain the list of items to show.

Let’s take a look at the SprintElement class:

class SprintElement {
  SprintType type;
  String label;
  Function callBack;

  SprintElement({required this.type, required this.label, required this.callBack});

  // hashCode method based on type field
  @override
  int get hashCode => type.hashCode;
  
  // == operator that returns true if type fields are equal
  @override
  bool operator ==(Object other) => 
    identical(this, other) ||
    other is SprintElement && other.type == type;
}

enum SprintType {
  previous,
  current,
}

This class is pretty simple and contains the sprint type (previous or current as enum), a label, to showon the UI and ovverrides the hashcode and the equals operator to compare two SprintElement objects.

About the SprintViewApi we just need to know that it exposes the methods:

abstract class SprintViewApi {
  Future<List<Item>> current();
  Future<List<Item>> previous();
}

We will not focus on the implementation of these methods. Let’s just make a sample implementation of the current() method, it will basically call a backend API that returns the Item list, using the factory method fromJson for each item contained in the response:

Future<List<Item>> current() async {
  final response = await http.get(Uri.parse('URL TO YOUR API'));
  if (response.statusCode == 200) {
    final mapped = List<Item>.from(json.decode(response.body).map((item) => Item.fromJson(item)));

    return mapped;
  } else {
    throw Exception('Failed to load data');
  }
}

The previous() has the same implementation, it just changes the api url. Now, let’s take a look at the Item class, the model object that contains all information about the items we saw before:

@JsonSerializable()
class Item {
  int id;
  String title;
  String description;
  String sprint;
  String state;
  String assignedTo;
  String createdDate;
  String createdBy;
  String activatedDate;
  String activatedBy;
  String resolvedDate;
  String resolvedBy;
  String closedDate;
  String closedBy;
  double storyPoints;
  int priority;
  double completedWork;
  String type;
  String estimatedEndDate;
  double leadTime;
  double remaining;
  double percentageRemaining;
  String link;
  List<Item> children;

  Item({
    required this.id,
    required this.title,
    required this.description,
    required this.sprint,
    required this.state,
    required this.assignedTo,
    required this.createdDate,
    required this.createdBy,
    required this.activatedDate,
    required this.activatedBy,
    required this.resolvedDate,
    required this.resolvedBy,
    required this.closedDate,
    required this.closedBy,
    required this.storyPoints,
    required this.priority,
    required this.completedWork,
    required this.type,
    required this.estimatedEndDate,
    required this.leadTime,
    required this.remaining,
    required this.percentageRemaining,
    required this.link,
    required this.children,
  });

  factory Item.fromJson(Map<String, dynamic> json) =>
      _$ItemFromJson(json);
  
}

The factory method used by the SprintViewApi is the following, and it translates the json object into the Item.

Item _$ItemFromJson(Map<String, dynamic> json) {
  //logger.d(json['id']);
  try {
    return Item(
      id: json['id'],
        title: json['title'] ?? 'N/A',
        description: json['description'] ?? 'N/A',
        sprint: json['sprint'] ?? 'N/A',
        state: json['state'] ?? 'N/A',
        assignedTo: json['assignedTo'] ?? 'N/A',
        createdDate: json['createdDate'] ?? 'N/A',
        createdBy: json['createdBy'] ?? 'N/A',
        activatedDate: json['activatedDate'] ?? 'N/A',
        activatedBy: json['activatedBy'] ?? 'N/A',
        resolvedDate: json['resolvedDate'] ?? 'N/A',
        resolvedBy: json['resolvedBy'] ?? 'N/A',
        closedDate: json['closedDate'] ?? 'N/A',
        closedBy: json['closedBy'] ?? 'N/A',
        storyPoints: json['storyPoints']?.toDouble() ?? 0.0,
        priority: json['priority'] ?? 0,
        completedWork: json['completedWork']?.toDouble() ?? 0.0,
        type: json['type'] ?? 'N/A',
        estimatedEndDate: json['estimatedEndDate'] ?? 'N/A',
        leadTime: json['leadTime']?.toDouble() ?? 0.0,
        remaining: json['remaining']?.toDouble() ?? 0.0,
        percentageRemaining: json['percentageRemaining']?.toDouble() ?? 0.0,
        link: json['link'] ?? 'N/A',
        children: json['children'] != null
        ? List<Item>.from(json['children'].map((item) => Item.fromJson(item)))
        : [],
    );
  } catch (e) {
    logger.e(e);
    rethrow;
  }
}

Let’s get back to the ItemList implementation, and put it all toghether:

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Item>>(
      future: sprint!.type == SprintType.current ? sprintViewApi.current() : sprintViewApi.previous(),
      builder: (context, futureSnapshot) {
        if (futureSnapshot.connectionState == ConnectionState.waiting) {
          return _buildLoadingIndicator();
        }
        if (futureSnapshot.hasError) {
          return Text("Error: Backend Unavailable.");
        }
        return ListView.separated(
          itemBuilder: (context, index) {
            return ItemTile(
              item: futureSnapshot.data![index],
            );
          },
          separatorBuilder: (BuildContext context, int index) {
            return const Divider(
              color: Colors.lightBlue,
              thickness: 1.0,
            );
          },
          itemCount: futureSnapshot.data!.length,
          scrollDirection: Axis.vertical,
          shrinkWrap: true,
        );
      },
      
    );
  }
  Widget _buildLoadingIndicator() {
    return const Center(child: CircularProgressIndicator());
  }

In the code snippet there is the overridden build method. The implementation is quite straightforward, it uses the FutureBuilder component with the following attributes:

  • future: It switches between the current and previous methods of the sprintViewApi, depending on the value of the sprint type. This attribute represents the Future that will be called by the component to fetch the data. It essentially calls the asynchronous API, and when the response is ready and data is available, it passes the results to the callback named builder.
  • builder: This attribute implements the callback of the API call and returns the necessary widgets to build the UI. Whern the connection is still waiting for results, it returns a loading indicator; when there are errors, it returns a Text widget containing the error message; and in all other cases, it returns the ListView containing the tiles with the Items (ItemTile).

We will take a closer look at the ItemTile in the next post, for now let’s focus on the most important parts of the previous code snippet.

Line 6 is crucial, as it determines when the loading indicator should be displayed. In many code samples and tutorials, you might find the following condition:

if (!futureSnapshot.hasData)

However, using this condition can lead to a strange behavior when switching the selection between the current and the previous sprint in the drop-down list over the items list. The loading indicator will not show during data load, and the update of data will happen all of a sudden.

By using the check on the connection state instead, the loading indicator is correctly loaded and there is no confusion when selecting the sprints while data is still loading.

At line 32, there’s a private method that returns the loading indicator.

The ListView widget is created using the ListView.separated constructor, which creates a fixed-length scrollable linear array of list items separated by list item separators. The attributes are quite easy to understand, especially if you take a look at the ListView documentation

In this first post, we delved into the implementation of ListView and its crucial role in displaying item trees in Flutter. We explored how to create a dynamic and visually appealing UI for User Stories, Bugs, and Tasks associated with Sprints.
In the next one, we will focus on the implementation of the ItemTile, diving deeper into its structure and functionalities. Stay tuned for more exciting details!

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.