jsonfluttertreeview

Flutter convert data into custom map with parent and child relation


I am working on a Flutter app where I need to generate a tree view from JSON data. The TreeView shown below:

Image shows what my simple tree view should look like

The Table structured as follows:

ParentID ChildID Title
1 0 Root 1
2 0 Root 2
3 1 Node 1.1
4 3 Node 1.1.1
5 3 Node 1.1.2
5 1 Node 1.2
5 2 Node 2.1
5 2 Node 2.2

The JSON data is structured as follows:

[
  {
    "ParentID": 1,
    "ChildID": 0,
    "Title": "Root 1"
  },
  {
    "ParentID": 2,
    "ChildID": 0,
    "Title": "Root 2"
  },
  {
    "ParentID": 3,
    "ChildID": 1,
    "Title": "Node 1.1"
  },
  {
    "ParentID": 4,
    "ChildID": 3,
    "Title": "Node 1.1.1"
  },
  {
    "ParentID": 5,
    "ChildID": 3,
    "Title": "Node 1.1.2"
  },
  {
    "ParentID": 6,
    "ChildID": 1,
    "Title": "Node 1.2"
  },
  {
    "ParentID": 7,
    "ChildID": 2,
    "Title": "Node 2.1"
  },
  {
    "ParentID": 8,
    "ChildID": 2,
    "Title": "Node 2.2"
  }
]

This data represents a hierarchical structure where each node can have multiple children. The ParentID field indicates the parent node, and the ChildID field indicates the current node's position in the hierarchy. Currently, I have hardcoded data to generate the tree view, as shown below:

  static const List<MyNode> roots = <MyNode>[
MyNode(
  title: 'Root 1',
  children: <MyNode>[
    MyNode(
      title: 'Node 1.1',
      children: <MyNode>[
        MyNode(title: 'Node 1.1.1'),
        MyNode(title: 'Node 1.1.2'),
      ],
    ),
    MyNode(title: 'Node 1.2'),
  ],
),
MyNode(
  title: 'Root 2',
  children: <MyNode>[
    MyNode(
      title: 'Node 2.1',
    ),
    MyNode(title: 'Node 2.2')
  ],
),];

What I would like to achieve is to dynamically generate this tree view from the JSON data retrieved from a database. Could someone please help me convert this JSON data into a hierarchical tree view in Flutter? Any guidance or code examples would be greatly appreciated. Thank you in advance for your help!


Solution

  • You only need a single function, one that can "convert this JSON data into a hierarchical tree", like this extension function.

    As for guidance, I would recommend to convert json data into an class (immutable if posible). I'm using here an extension but only because the type is somewhat narrow.

    import 'package:flutter/material.dart';
    
    void main() => runApp(const App());
    
    final data = [
      {"ParentID": 1, "ChildID": 0, "Title": "Root 1"},
      {"ParentID": 2, "ChildID": 0, "Title": "Root 2"},
      {"ParentID": 3, "ChildID": 1, "Title": "Node 1.1"},
      {"ParentID": 4, "ChildID": 3, "Title": "Node 1.1.1"},
      {"ParentID": 5, "ChildID": 3, "Title": "Node 1.1.2"},
      {"ParentID": 6, "ChildID": 1, "Title": "Node 1.2"},
      {"ParentID": 7, "ChildID": 2, "Title": "Node 2.1"},
      {"ParentID": 8, "ChildID": 2, "Title": "Node 2.2"}
    ];
    
    typedef NodeData = Map<String, Object>;
    typedef NodePoint = MapEntry<NodeData, List<dynamic>?>;
    
    extension NodeDataHelper on List<NodeData> {
      // Maps the a list of [NodeData] into a list of
      // [NodePoint] trees, nodes that don't have any
      // children have a null as a value to indicate so.
      List<NodePoint> as_tree_list() {
        assert(isNotEmpty);
        // Should you check if the data is malformed ?
    
        // In recursion, the child node is the parent node
        NodePoint _rec(NodeData node) {
          final children = where(
            (inode) => inode['ChildID'] == node['ParentID'],
          );
    
          return MapEntry(
            node,
            (children.isNotEmpty)
                ? children
                    .map(
                      (child_node) => _rec(child_node),
                    )
                    .toList(growable: false) // Does this need sorting ?
                : null,
          );
        }
    
        return where(
          (inode) => inode['ChildID'] == 0,
        ).map(_rec).toList(growable: false);
      }
    }
    
    class App extends StatelessWidget {
      const App({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: Scaffold(
            body: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: data
                  .as_tree_list()
                  .map(
                    (tree) => TreeNode(node: tree),
                  )
                  .toList(growable: false),
            ),
          ),
        );
      }
    }
    
    class TreeNode extends StatelessWidget {
      final NodePoint node;
    
      const TreeNode({super.key, required this.node});
    
      @override
      Widget build(BuildContext context) {
        Widget widget;
        if (node.value != null) {
          widget = Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(node.key['Title'].toString()),
              ...node.value!.map(
                (child) => TreeNode(node: child),
              ),
            ],
          );
        } else {
          widget = Text(node.key['Title'].toString());
        }
        return Padding(
          padding: const EdgeInsets.only(left: 25),
          child: widget,
        );
      }
    }
    

    This example might seem a bit more convoluted but over all it's much better because converting json (a Map in this case) into a class removes all the lookups and thus making the code faster, but depending on how many fields the nodes have, the biggest benefit might be the type/value checks that class objects have (and you are getting lints from the lsp, the error are way more human readable).

    import 'dart:convert';
    
    import 'package:flutter/material.dart';
    
    void main() => runApp(const App());
    
    final data = [
      {"ParentID": 1, "ChildID": 0, "Title": "Root 1"},
      {"ParentID": 2, "ChildID": 0, "Title": "Root 2"},
      {"ParentID": 3, "ChildID": 1, "Title": "Node 1.1"},
      {"ParentID": 4, "ChildID": 3, "Title": "Node 1.1.1"},
      {"ParentID": 5, "ChildID": 3, "Title": "Node 1.1.2"},
      {"ParentID": 6, "ChildID": 1, "Title": "Node 1.2"},
      {"ParentID": 7, "ChildID": 2, "Title": "Node 2.1"},
      {"ParentID": 8, "ChildID": 2, "Title": "Node 2.2"}
    ];
    
    typedef NodeData = Map<String, dynamic>;
    
    class NodePoint {
      final String title;
    
      // Do you need this variables?
      //final int child_id;
      //final int parent_id;
    
      final List<NodePoint> children;
    
      const NodePoint._({
        required this.title,
        required this.children,
      });
    
      factory NodePoint.from_json(Map<String, dynamic> json_node, List<NodePoint> children) {
        // Do your checks here if you need to, any throw during this call should be
        // handled by the caller in order to drop bad nodes.
    
        return NodePoint._(
          title: json_node['Title'] as String,
          children: children,
        );
      }
    
      // Helper functions used while building the tree(s).
      static int _parent_id(NodeData data) => data['ParentID'] as int;
      static int _child_id(NodeData data) => data['ChildID'] as int;
    
      // Maps the a list of [NodeData] into a list of [NodePoint] trees,
      // nodes always contain a list of children that may be empty, use
      // [has_children] to check if a node has children.
      static List<NodePoint> nodes_from_json(String json) {
        final data = jsonDecode(json);
        assert(data is List, 'Not a list (${data.runtimeType}), wrong json?');
    
        // The casting needs to happen in "layers" and you may want to
        // type check all the variables like with the previous assert.
        final nodes = (data as List).map((e) => (e as NodeData));
    
        NodePoint _rec(NodeData node) => NodePoint.from_json(
            node,
            nodes
                .where(
                  (inode) => _child_id(inode) == _parent_id(node),
                )
                .map(_rec)
                .toList(growable: false));
    
        // If the underlying functions are expected to throw,
        // make the return null-able.
        return nodes
            .where(
              (inode) => _child_id(inode) == 0,
            )
            .map(_rec)
            .toList(growable: false);
      }
    
      // Just for convenience
    
      bool get has_children => children.isNotEmpty;
    }
    
    class App extends StatelessWidget {
      const App({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: Scaffold(
            body: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: NodePoint.nodes_from_json(jsonEncode(data))
                  .map(
                    (tree) => TreeNode(node: tree),
                  )
                  .toList(growable: false),
            ),
          ),
        );
      }
    }
    
    class TreeNode extends StatelessWidget {
      final NodePoint node;
    
      const TreeNode({super.key, required this.node});
    
      @override
      Widget build(BuildContext context) {
        Widget widget;
    
        if (node.has_children) {
          widget = Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(node.title),
              ...node.children.map(
                (child) => TreeNode(node: child),
              ),
            ],
          );
        } else {
          widget = Text(node.title);
        }
    
        return Padding(
          padding: const EdgeInsets.only(left: 25),
          child: widget,
        );
      }
    }