1

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

Mahdi Dahouei
  • 1,588
  • 2
  • 12
  • 32
Akash
  • 461
  • 2
  • 14

2 Answers2

2

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

Mahdi Dahouei
  • 1,588
  • 2
  • 12
  • 32
  • Hi i saw your code but it is not as i am expecting there should be expanded feature mean open and close the main parent you can see there is right side arrow so there should be according open and close while click on parent node. – Akash Jun 26 '23 at 04:52
  • Second there should be work checkmark mean when only single selected then it will be show check mark but its parent should be - sign of icon while all child selected then it should be change with check mark icon of parent – Akash Jun 26 '23 at 04:53
  • third when i click all group check box then all should be checked along with child too.when again click to uncheck then all should be unchecked.. – Akash Jun 26 '23 at 04:56
  • my code has expansionTile so the items are closed at first and when you click them they will be opened. but I know that checkboxes don't work as you expected. I'll implement that within the next few hours and I'll update the answer. – Mahdi Dahouei Jun 26 '23 at 07:22
  • Okay Mahdi Dahouei – Akash Jun 27 '23 at 04:29
  • Hi @Akash, sorry for the delay, I updated the answer with the correct code. everything works fine, please consider upvoting the answer and accepting it. thanks – Mahdi Dahouei Jun 28 '23 at 11:35
1

Maybe you can create your own renderObject, I try my best for make the indent widget look like your image provided. Keep in mind this is not Sliver widget therefore this can cost some perform an issue.

enter image description here

also I suggest you take a look this video if youe interest about renderObject in flutter. https://www.youtube.com/watch?v=HqXNGawzSbY&t=7458s

main.dart

import 'package:flutter/material.dart';
import 'indent_widget.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  Widget _buildColumn() {
    return Row(
      children: [
        Checkbox(value: false, onChanged: (value) {}),
        const Expanded(child: Text('text'))
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    double tabSpace = 30;
    return Scaffold(
      body: SafeArea(
        child: SingleChildScrollView(
          child: IndentWidget(children: [
            _buildColumn(),
            IndentTab(
                tabSpace: tabSpace,
                child: IndentWidget(
                  children: [
                    IndentTab(
                        tabSpace: tabSpace,
                        child: IndentWidget(
                          children: [
                            _buildColumn(),
                            _buildColumn(),
                            IndentTab(
                                tabSpace: tabSpace,
                                child: IndentWidget(
                                  children: [
                                    _buildColumn(),
                                    _buildColumn(),
                                    IndentTab(
                                        tabSpace: tabSpace,
                                        child: IndentWidget(
                                          children: [
                                            _buildColumn(),
                                            _buildColumn(),
                                            _buildColumn(),
                                          ],
                                        )),
                                    _buildColumn(),
                                    IndentTab(
                                        tabSpace: tabSpace,
                                        child: IndentWidget(
                                          children: [
                                            _buildColumn(),
                                            _buildColumn(),
                                            _buildColumn(),
                                          ],
                                        )),
                                  ],
                                )),
                            _buildColumn(),
                          ],
                        )),
                    _buildColumn(),
                    _buildColumn(),
                    _buildColumn(),
                  ],
                )),
            _buildColumn(),
          ]),
        ),
      ),
    );
  }
}

indent_widget.dart

import 'dart:math';

import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';

class IndentWidget extends MultiChildRenderObjectWidget {
  IndentWidget({super.key, super.children});

  @override
  RenderObject createRenderObject(BuildContext context) {
    /// 1. entry point.
    return RenderIndent();
  }
}

/// provide information to RenderIndent;
class RenderIndentParentData extends ContainerBoxParentData<RenderBox> {
  double? tabSpace;
}

class IndentTab extends ParentDataWidget<RenderIndentParentData> {
  final double tabSpace;

  const IndentTab({super.key, required this.tabSpace, required super.child});

  @override
  void applyParentData(RenderObject renderObject) {
    final RenderIndentParentData parentData =
        renderObject.parentData! as RenderIndentParentData;

    if (parentData.tabSpace != tabSpace) {
      parentData.tabSpace = tabSpace;
      final targetObject = renderObject.parent;
      if (targetObject is RenderObject) {
        targetObject.markNeedsLayout();
      }
    }
  }

  @override
  Type get debugTypicalAncestorWidgetClass => RenderIndentParentData;
}

class RenderIndent extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, RenderIndentParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, RenderIndentParentData> {
  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! RenderIndentParentData) {
      child.parentData = RenderIndentParentData();
    }
  }

  Size _performLayout(BoxConstraints constraints, bool dry) {
    RenderBox? child = firstChild;
    double width = 0, height = 0;

    while (child != null) {
      final RenderIndentParentData childParentData =
          child.parentData as RenderIndentParentData;
      final double leftShift = childParentData.tabSpace ?? 0;

      if (!dry) {
        childParentData.offset = Offset(leftShift, height);
        child.layout(BoxConstraints(maxWidth: constraints.maxWidth),
            parentUsesSize: true);
      }
      height += child.size.height;
      width = max(width, leftShift + child.size.width);
      child = childParentData.nextSibling;
    }

    if (width > constraints.maxWidth) {
      width = constraints.maxWidth;
    }

    return Size(width, height);
  }

  @override
  void performLayout() {
    size = _performLayout(constraints, false);
  }

  @override
  Size computeDryLayout(BoxConstraints constraints) {
    return _performLayout(constraints, true);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    defaultPaint(context, offset);
  }

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    return defaultHitTestChildren(result, position: position);
  }
}
Wi Cheung
  • 36
  • 1
  • 3