Search code examples
flutterrecursiontreeviewflutter-dependencies

How to implement TreeView with three-state CheckBoxes featuring select all and unselected all functionality


I want to achieve a tree hierarchy list view. I have tried a few references that I found on pub.dev:
links to packages: https://pub.dev/packages/parent_child_checkbox

https://pub.dev/packages/list_treeview

I have tested them, but they do not meet my requirements. I need a n-level sub-tree with checkbox and selection, as shown in the image below. Does anyone have any ideas on how to achieve this or can anyone please provide guidance? Thank you.

enter image description here

enter image description here

enter image description here


Solution

  • At first, Create a Class named TreeNode:

    class TreeNode {
      final String title;
      final bool isSelected;
      final CheckBoxState checkBoxState;
      final List<TreeNode> children;
    
      TreeNode({
        required this.title,
        this.isSelected = false,
        this.children = const <TreeNode>[],
      }) : checkBoxState = isSelected
                ? CheckBoxState.selected
                : (children.any((element) =>
                        element.checkBoxState != CheckBoxState.unselected)
                    ? CheckBoxState.partial
                    : CheckBoxState.unselected);
    
      TreeNode copyWith({
        String? title,
        bool? isSelected,
        List<TreeNode>? children,
      }) {
        return TreeNode(
          title: title ?? this.title,
          isSelected: isSelected ?? this.isSelected,
          children: children ?? this.children,
        );
      }
    }
    

    Your data could be something like this:

    final nodes = [
      TreeNode(
        title: "title.1",
        children: [
          TreeNode(
            title: "title.1.1",
          ),
          TreeNode(
            title: "title.1.2",
            children: [
              TreeNode(
                title: "title.1.2.1",
              ),
              TreeNode(
                title: "title.1.2.2",
              ),
            ],
          ),
          TreeNode(
            title: "title.1.3",
          ),
        ],
      ),
      TreeNode(
        title: "title.2",
      ),
      TreeNode(
        title: "title.3",
        children: [
          TreeNode(
            title: "title.3.1",
          ),
          TreeNode(
            title: "title.3.2",
          ),
        ],
      ),
      TreeNode(
        title: "title.4",
      ),
    ];
    

    create an enum for checkBox state:

    enum CheckBoxState {
      selected,
      unselected,
      partial,
    }
    

    create a TitleCheckBox widget that has three states and shows title:

    class TitleCheckBox extends StatelessWidget {
      const TitleCheckBox({
        Key? key,
        required this.title,
        required this.checkBoxState,
        required this.onChanged,
        required this.level,
      }) : super(key: key);
    
      final String title;
      final CheckBoxState checkBoxState;
      final VoidCallback onChanged;
      final int level;
    
      @override
      Widget build(BuildContext context) {
        final themeData = Theme.of(context);
        const size = 24.0;
        const borderRadius = BorderRadius.all(Radius.circular(3.0));
        return Row(
          children: [
            SizedBox(
              width: level * 16.0,
            ),
            IconButton(
              onPressed: onChanged,
              // borderRadius: borderRadius,
              icon: Container(
                height: size,
                width: size,
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  border: Border.all(
                    color: checkBoxState == CheckBoxState.unselected
                        ? themeData.unselectedWidgetColor
                        : themeData.primaryColor,
                    width: 2.0,
                  ),
                  borderRadius: borderRadius,
                  color: checkBoxState == CheckBoxState.unselected
                      ? Colors.transparent
                      : themeData.primaryColor,
                ),
                child: AnimatedSwitcher(
                  duration: const Duration(
                    milliseconds: 260,
                  ),
                  child: checkBoxState == CheckBoxState.unselected
                      ? const SizedBox(
                          height: size,
                          width: size,
                        )
                      : FittedBox(
                          key: ValueKey(checkBoxState.name),
                          fit: BoxFit.scaleDown,
                          child: Center(
                            child: checkBoxState == CheckBoxState.partial
                                ? Container(
                                    height: 1.8,
                                    width: 12.0,
                                    decoration: const BoxDecoration(
                                      color: Colors.white,
                                      borderRadius: borderRadius,
                                    ),
                                  )
                                : const Icon(
                                    Icons.check,
                                    color: Colors.white,
                                  ),
                          ),
                        ),
                ),
              ),
            ),
            const SizedBox(
              width: 8.0,
            ),
            Text(title),
          ],
        );
      }
    }
    

    Now implement the recursive TreeView with the selection logic:

    class TreeView extends StatefulWidget {
      const TreeView({
        Key? key,
        required this.nodes,
        this.level = 0,
        required this.onChanged,
      }) : super(key: key);
    
      final List<TreeNode> nodes;
      final int level;
      final void Function(List<TreeNode> newNodes) onChanged;
    
      @override
      State<TreeView> createState() => _TreeViewState();
    }
    
    class _TreeViewState extends State<TreeView> {
      late List<TreeNode> nodes;
    
      @override
      void initState() {
        super.initState();
        nodes = widget.nodes;
      }
    
      TreeNode _unselectAllSubTree(TreeNode node) {
        final treeNode = node.copyWith(
          isSelected: false,
          children: node.children.isEmpty
              ? null
              : node.children.map((e) => _unselectAllSubTree(e)).toList(),
        );
        return treeNode;
      }
    
      TreeNode _selectAllSubTree(TreeNode node) {
        final treeNode = node.copyWith(
          isSelected: true,
          children: node.children.isEmpty
              ? null
              : node.children.map((e) => _selectAllSubTree(e)).toList(),
        );
        return treeNode;
      }
    
      @override
      Widget build(BuildContext context) {
        if (widget.nodes != nodes) {
          nodes = widget.nodes;
        }
    
        return ListView.builder(
          itemCount: nodes.length,
          physics: widget.level != 0 ? const NeverScrollableScrollPhysics() : null,
          shrinkWrap: widget.level != 0,
          itemBuilder: (context, index) {
            return ExpansionTile(
              title: TitleCheckBox(
                onChanged: () {
                  switch (nodes[index].checkBoxState) {
                    case CheckBoxState.selected:
                      nodes[index] = _unselectAllSubTree(nodes[index]);
                      break;
                    case CheckBoxState.unselected:
                      nodes[index] = _selectAllSubTree(nodes[index]);
                      break;
                    case CheckBoxState.partial:
                      nodes[index] = _unselectAllSubTree(nodes[index]);
                      break;
                  }
                  if (widget.level == 0) {
                    setState(() {});
                  }
                  widget.onChanged(nodes);
                },
                title: nodes[index].title,
                checkBoxState: nodes[index].checkBoxState,
                level: widget.level,
              ),
              trailing:
                  nodes[index].children.isEmpty ? const SizedBox.shrink() : null,
              children: [
                TreeView(
                  nodes: nodes[index].children,
                  level: widget.level + 1,
                  onChanged: (newNodes) {
                    bool areAllItemsSelected = !nodes[index]
                        .children
                        .any((element) => !element.isSelected);
    
                    nodes[index] = nodes[index].copyWith(
                      isSelected: areAllItemsSelected,
                      children: newNodes,
                    );
    
                    widget.onChanged(nodes);
                    if (widget.level == 0) {
                      setState(() {});
                    }
                  },
                ),
              ],
            );
          },
        );
      }
    }
    

    All done! you can use your TreeView like this:

     TreeView(
            onChanged: (newNodes) {},
            nodes: nodes,
          ),
    

    and this is the result:
    enter image description here