diff --git a/examples/api/lib/widgets/sliver/sliver_tree.0.dart b/examples/api/lib/widgets/sliver/sliver_tree.0.dart new file mode 100644 index 000000000000..914815752df7 --- /dev/null +++ b/examples/api/lib/widgets/sliver/sliver_tree.0.dart @@ -0,0 +1,104 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for [TreeSliver]. + +void main() => runApp(const TreeSliverExampleApp()); + +class TreeSliverExampleApp extends StatelessWidget { + const TreeSliverExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: TreeSliverExample(), + ); + } +} + +class TreeSliverExample extends StatefulWidget { + const TreeSliverExample({super.key}); + + @override + State createState() => _TreeSliverExampleState(); +} + +class _TreeSliverExampleState extends State { + TreeSliverNode? _selectedNode; + final TreeSliverController controller = TreeSliverController(); + final List> _tree = >[ + TreeSliverNode('First'), + TreeSliverNode( + 'Second', + children: >[ + TreeSliverNode( + 'alpha', + children: >[ + TreeSliverNode('uno'), + TreeSliverNode('dos'), + TreeSliverNode('tres'), + ], + ), + TreeSliverNode('beta'), + TreeSliverNode('kappa'), + ], + ), + TreeSliverNode( + 'Third', + expanded: true, + children: >[ + TreeSliverNode('gamma'), + TreeSliverNode('delta'), + TreeSliverNode('epsilon'), + ], + ), + TreeSliverNode('Fourth'), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('TreeSliver Demo'), + ), + body: CustomScrollView( + slivers: [ + TreeSliver( + tree: _tree, + controller: controller, + treeNodeBuilder: ( + BuildContext context, + TreeSliverNode node, + AnimationStyle animationStyle, + ) { + Widget child = GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + setState(() { + controller.toggleNode(node); + _selectedNode = node as TreeSliverNode; + }); + }, + child: TreeSliver.defaultTreeNodeBuilder( + context, + node, + animationStyle, + ), + ); + if (_selectedNode == node as TreeSliverNode) { + child = ColoredBox( + color: Colors.purple[100]!, + child: child, + ); + } + return child; + }, + ), + ], + ), + ); + } +} diff --git a/examples/api/lib/widgets/sliver/sliver_tree.1.dart b/examples/api/lib/widgets/sliver/sliver_tree.1.dart new file mode 100644 index 000000000000..b5d8b783f351 --- /dev/null +++ b/examples/api/lib/widgets/sliver/sliver_tree.1.dart @@ -0,0 +1,189 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// Flutter code sample for [TreeSliver]. + +void main() => runApp(const TreeSliverExampleApp()); + +class TreeSliverExampleApp extends StatelessWidget { + const TreeSliverExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: TreeSliverExample(), + ); + } +} + +class TreeSliverExample extends StatefulWidget { + const TreeSliverExample({super.key}); + + @override + State createState() => _TreeSliverExampleState(); +} + +class _TreeSliverExampleState extends State { + TreeSliverNode? _selectedNode; + final List> tree = >[ + TreeSliverNode('README.md'), + TreeSliverNode('analysis_options.yaml'), + TreeSliverNode( + 'lib', + children: >[ + TreeSliverNode( + 'src', + children: >[ + TreeSliverNode( + 'widgets', + children: >[ + TreeSliverNode('about.dart.dart'), + TreeSliverNode('app.dart'), + TreeSliverNode('basic.dart'), + TreeSliverNode('constants.dart'), + ], + ), + ], + ), + TreeSliverNode('widgets.dart'), + ], + ), + TreeSliverNode('pubspec.lock'), + TreeSliverNode('pubspec.yaml'), + TreeSliverNode( + 'test', + children: >[ + TreeSliverNode( + 'widgets', + children: >[ + TreeSliverNode('about_test.dart'), + TreeSliverNode('app_test.dart'), + TreeSliverNode('basic_test.dart'), + TreeSliverNode('constants_test.dart'), + ], + ), + ], + ), + ]; + + Widget _treeNodeBuilder( + BuildContext context, + TreeSliverNode node, + AnimationStyle toggleAnimationStyle, + ) { + final bool isParentNode = node.children.isNotEmpty; + final BorderSide border = BorderSide( + width: 2, + color: Colors.purple[300]!, + ); + return TreeSliver.wrapChildToToggleNode( + node: node, + child: Row( + children: [ + // Custom indentation + SizedBox(width: 10.0 * node.depth! + 8.0), + DecoratedBox( + decoration: BoxDecoration( + border: node.parent != null + ? Border(left: border, bottom: border) + : null, + ), + child: const SizedBox(height: 50.0, width: 20.0), + ), + // Leading icon for parent nodes + if (isParentNode) + DecoratedBox( + decoration: BoxDecoration(border: Border.all()), + child: SizedBox.square( + dimension: 20.0, + child: Icon( + node.isExpanded ? Icons.remove : Icons.add, + size: 14, + ), + ), + ), + // Spacer + const SizedBox(width: 8.0), + // Content + Text(node.content.toString()), + ], + ), + ); + } + + Widget _getTree() { + return DecoratedSliver( + decoration: BoxDecoration( border: Border.all()), + sliver: TreeSliver( + tree: tree, + onNodeToggle: (TreeSliverNode node) { + setState(() { + _selectedNode = node as TreeSliverNode; + }); + }, + treeNodeBuilder: _treeNodeBuilder, + treeRowExtentBuilder: ( + TreeSliverNode node, + SliverLayoutDimensions layoutDimensions, + ) { + // This gives more space to parent nodes. + return node.children.isNotEmpty ? 60.0 : 50.0; + }, + // No internal indentation, the custom treeNodeBuilder applies its + // own indentation to decorate in the indented space. + indentation: TreeSliverIndentationType.none, + ), + ); + } + + @override + Widget build(BuildContext context) { + // This example is assumes the full screen is available. + final Size screenSize = MediaQuery.sizeOf(context); + final List selectedChildren = []; + if (_selectedNode != null) { + selectedChildren.addAll([ + const Spacer(), + Icon( + _selectedNode!.children.isEmpty + ? Icons.file_open_outlined + : Icons.folder_outlined, + ), + const SizedBox(height: 16.0), + Text(_selectedNode!.content), + const Spacer(), + ]); + } + return Scaffold( + body: Row(children: [ + SizedBox( + width: screenSize.width / 2, + height: double.infinity, + child: CustomScrollView( + slivers: [ + _getTree(), + ], + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all(), + ), + child: SizedBox( + width: screenSize.width / 2, + height: double.infinity, + child: Center( + child: Column( + children: selectedChildren, + ), + ), + ), + ), + ]), + ); + } +} diff --git a/examples/api/test/widgets/sliver/sliver_tree.0_test.dart b/examples/api/test/widgets/sliver/sliver_tree.0_test.dart new file mode 100644 index 000000000000..46d77c06f3bd --- /dev/null +++ b/examples/api/test/widgets/sliver/sliver_tree.0_test.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_api_samples/widgets/sliver/sliver_tree.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can toggle nodes in TreeSliver', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TreeSliverExampleApp(), + ); + expect(find.text('Second'), findsOneWidget); + expect(find.text('alpha'), findsNothing); + // Toggle tree node. + await tester.tap(find.text('Second')); + await tester.pumpAndSettle(); + expect(find.text('alpha'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/sliver/sliver_tree.1_test.dart b/examples/api/test/widgets/sliver/sliver_tree.1_test.dart new file mode 100644 index 000000000000..d54f839c6bcd --- /dev/null +++ b/examples/api/test/widgets/sliver/sliver_tree.1_test.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_api_samples/widgets/sliver/sliver_tree.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can toggle nodes in TreeSliver', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TreeSliverExampleApp(), + ); + expect(find.text('lib'), findsOneWidget); + expect(find.text('src'), findsNothing); + // Toggle tree node. + await tester.tap(find.text('lib')); + await tester.pumpAndSettle(); + expect(find.text('src'), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/rendering.dart b/packages/flutter/lib/rendering.dart index 20705ce17cca..dcf36cca135c 100644 --- a/packages/flutter/lib/rendering.dart +++ b/packages/flutter/lib/rendering.dart @@ -67,6 +67,7 @@ export 'src/rendering/sliver_list.dart'; export 'src/rendering/sliver_multi_box_adaptor.dart'; export 'src/rendering/sliver_padding.dart'; export 'src/rendering/sliver_persistent_header.dart'; +export 'src/rendering/sliver_tree.dart'; export 'src/rendering/stack.dart'; export 'src/rendering/table.dart'; export 'src/rendering/table_border.dart'; diff --git a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart index 4812ce726d5a..31d163d32de1 100644 --- a/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart +++ b/packages/flutter/lib/src/rendering/sliver_multi_box_adaptor.dart @@ -8,6 +8,7 @@ import 'package:vector_math/vector_math_64.dart'; import 'box.dart'; import 'object.dart'; import 'sliver.dart'; +import 'sliver_fixed_extent_list.dart'; /// A delegate used by [RenderSliverMultiBoxAdaptor] to manage its children. /// diff --git a/packages/flutter/lib/src/rendering/sliver_tree.dart b/packages/flutter/lib/src/rendering/sliver_tree.dart new file mode 100644 index 000000000000..11f9d5fa982d --- /dev/null +++ b/packages/flutter/lib/src/rendering/sliver_tree.dart @@ -0,0 +1,404 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import 'box.dart'; +import 'layer.dart'; +import 'object.dart'; +import 'sliver.dart'; +import 'sliver_fixed_extent_list.dart'; +import 'sliver_multi_box_adaptor.dart'; + +/// Represents the animation of the children of a parent [TreeSliverNode] that +/// are animating into or out of view. +/// +/// The `fromIndex` and `toIndex` identify the animating children following +/// the parent, with the `value` representing the status of the current +/// animation. The value of `toIndex` is inclusive, meaning the child at that +/// index is included in the animating segment. +/// +/// Provided to [RenderTreeSliver] as part of +/// [RenderTreeSliver.activeAnimations] by [TreeSliver] to properly offset +/// animating children. +typedef TreeSliverNodesAnimation = ({ + int fromIndex, + int toIndex, + double value, +}); + +/// Used to pass information down to [RenderTreeSliver]. +class TreeSliverNodeParentData extends SliverMultiBoxAdaptorParentData { + /// The depth of the node, used by [RenderTreeSliver] to offset children by + /// by the [TreeSliverIndentationType]. + int depth = 0; +} + +/// The style of indentation for [TreeSliverNode]s in a [TreeSliver], as +/// handled by [RenderTreeSliver]. +/// +/// {@template flutter.rendering.TreeSliverIndentationType} +/// By default, the indentation is handled by [RenderTreeSliver]. Child nodes +/// are offset by the indentation specified by +/// [TreeSliverIndentationType.value] in the cross axis of the viewport. This +/// means the space allotted to the indentation will not be part of the space +/// made available to the widget returned by [TreeSliver.treeNodeBuilder]. +/// +/// Alternatively, the indentation can be implemented in +/// [TreeSliver.treeNodeBuilder], with the depth of the given tree row +/// accessed by [TreeSliverNode.depth]. This allows for more customization in +/// building tree rows, such as filling the indented area with decorations or +/// ink effects. +/// +/// {@tool dartpad} +/// This example shows a highly customized [TreeSliver] configured to +/// [TreeSliverIndentationType.none]. This allows the indentation to be handled +/// by the developer in [TreeSliver.treeNodeBuilder], where a decoration is +/// used to fill the indented space. +/// +/// ** See code in examples/api/lib/widgets/sliver/sliver_tree.1.dart ** +/// {@end-tool} +/// +/// {@endtemplate} +class TreeSliverIndentationType { + const TreeSliverIndentationType._internal(double value) : _value = value; + + /// The number of pixels by which [TreeSliverNode]s will be offset according + /// to their [TreeSliverNode.depth]. + double get value => _value; + final double _value; + + /// The default indentation of child [TreeSliverNode]s in a [TreeSliver]. + /// + /// Child nodes will be offset by 10 pixels for each level in the tree. + static const TreeSliverIndentationType standard = TreeSliverIndentationType._internal(10.0); + + /// Configures no offsetting of child nodes in a [TreeSliver]. + /// + /// Useful if the indentation is implemented in the + /// [TreeSliver.treeNodeBuilder] instead for more customization options. + /// + /// Child nodes will not be offset in the tree. + static const TreeSliverIndentationType none = TreeSliverIndentationType._internal(0.0); + + /// Configures a custom offset for indenting child nodes in a + /// [TreeSliver]. + /// + /// Child nodes will be offset by the provided number of pixels in the tree. + /// The [value] must be a non negative number. + static TreeSliverIndentationType custom(double value) { + assert(value >= 0.0); + return TreeSliverIndentationType._internal(value); + } +} + +// Used during paint to delineate animating portions of the tree. +typedef _PaintSegment = ({int leadingIndex, int trailingIndex}); + +/// A sliver that places multiple [TreeSliverNode]s in a linear array along the +/// main axis, while staggering nodes that are animating into and out of view. +/// +/// The extent of each child node is determined by the [itemExtentBuilder]. +/// +/// See also: +/// +/// * [TreeSliver], the widget that creates and manages this render +/// object. +class RenderTreeSliver extends RenderSliverVariedExtentList { + /// Creates the render object that lays out the [TreeSliverNode]s of a + /// [TreeSliver]. + RenderTreeSliver({ + required super.childManager, + required super.itemExtentBuilder, + required Map activeAnimations, + required double indentation, + }) : _activeAnimations = activeAnimations, + _indentation = indentation; + + // TODO(Piinks): There are some opportunities to cache even further as far as + // extents and layout offsets when using itemExtentBuilder from the super + // class as we do here. I want to yak shave that in a separate change. + + /// The currently active [TreeSliverNode] animations. + /// + /// Since the index of animating nodes can change at any time, the unique key + /// is used to track an animation of nodes across frames. + Map get activeAnimations => _activeAnimations; + Map _activeAnimations; + set activeAnimations(Map value) { + if (_activeAnimations == value) { + return; + } + _activeAnimations = value; + markNeedsLayout(); + } + + /// The number of pixels by which child nodes will be offset in the cross axis + /// based on their [TreeSliverNodeParentData.depth]. + /// + /// If zero, can alternatively offset children in + /// [TreeSliver.treeNodeBuilder] for more options to customize the + /// indented space. + double get indentation => _indentation; + double _indentation; + set indentation(double value) { + if (_indentation == value) { + return; + } + assert(indentation >= 0.0); + _indentation = value; + markNeedsLayout(); + } + + // Maps the index of parents to the animation key of their children. + final Map _animationLeadingIndices = {}; + // Maps the key of child node animations to the fixed distance they are + // traversing during the animation. Determined at the start of the animation. + final Map _animationOffsets = {}; + void _updateAnimationCache() { + _animationLeadingIndices.clear(); + _activeAnimations.forEach((UniqueKey key, TreeSliverNodesAnimation animation) { + _animationLeadingIndices[animation.fromIndex - 1] = key; + }); + // Remove any stored offsets or clip layers that are no longer actively + // animating. + _animationOffsets.removeWhere((UniqueKey key, _) => !_activeAnimations.keys.contains(key)); + _clipHandles.removeWhere((UniqueKey key, LayerHandle handle) { + if (!_activeAnimations.keys.contains(key)) { + handle.layer = null; + return true; + } + return false; + }); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! TreeSliverNodeParentData) { + child.parentData = TreeSliverNodeParentData(); + } + } + + @override + void dispose() { + _clipHandles.removeWhere((UniqueKey key, LayerHandle handle) { + handle.layer = null; + return true; + }); + super.dispose(); + } + + // TODO(Piinks): This should be made a public getter on the super class. + // Multiple subclasses are making use of it now, yak shave that refactor + // separately. + late SliverLayoutDimensions _currentLayoutDimensions; + + @override + void performLayout() { + assert( + constraints.axisDirection == AxisDirection.down, + 'TreeSliver is only supported in Viewports with an AxisDirection.down. ' + 'The current axis direction is: ${constraints.axisDirection}.', + ); + _updateAnimationCache(); + _currentLayoutDimensions = SliverLayoutDimensions( + scrollOffset: constraints.scrollOffset, + precedingScrollExtent: constraints.precedingScrollExtent, + viewportMainAxisExtent: constraints.viewportMainAxisExtent, + crossAxisExtent: constraints.crossAxisExtent, + ); + super.performLayout(); + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset, double itemExtent) { + // itemExtent is deprecated in the super class, we ignore it because we use + // the builder anyways. + return _getChildIndexForScrollOffset(scrollOffset); + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset, double itemExtent) { + // itemExtent is deprecated in the super class, we ignore it because we use + // the builder anyways. + return _getChildIndexForScrollOffset(scrollOffset); + } + + int _getChildIndexForScrollOffset(double scrollOffset) { + if (scrollOffset == 0.0) { + return 0; + } + double position = 0.0; + int index = 0; + double totalAnimationOffset = 0.0; + double? itemExtent; + final int? childCount = childManager.estimatedChildCount; + while (position < scrollOffset) { + if (childCount != null && index > childCount - 1) { + break; + } + + itemExtent = itemExtentBuilder(index, _currentLayoutDimensions); + if (itemExtent == null) { + break; + } + if (_animationLeadingIndices.keys.contains(index)) { + final UniqueKey animationKey = _animationLeadingIndices[index]!; + if (_animationOffsets[animationKey] == null) { + // We have not computed the distance this block is traversing over the + // lifetime of the animation. + _computeAnimationOffsetFor(animationKey, position); + } + // We add the offset accounting for the animation value. + totalAnimationOffset += _animationOffsets[animationKey]! * (1 - _activeAnimations[animationKey]!.value); + } + position += itemExtent - totalAnimationOffset; + ++index; + } + return index - 1; + } + + void _computeAnimationOffsetFor(UniqueKey key, double position) { + assert(_activeAnimations[key] != null); + final double targetPosition = constraints.scrollOffset + constraints.remainingCacheExtent; + double currentPosition = position; + final int startingIndex = _activeAnimations[key]!.fromIndex; + final int lastIndex = _activeAnimations[key]!.toIndex; + int currentIndex = startingIndex; + double totalAnimatingOffset = 0.0; + // We animate only a portion of children that would be visible/in the cache + // extent, unless all children would fit on the screen. + while (currentIndex <= lastIndex && currentPosition < targetPosition) { + final double itemExtent = itemExtentBuilder(currentIndex, _currentLayoutDimensions)!; + totalAnimatingOffset += itemExtent; + currentPosition += itemExtent; + currentIndex++; + } + // For the life of this animation, which affects all children following + // startingIndex (regardless of if they are a child of the triggering + // parent), they will be offset by totalAnimatingOffset * the + // animation value. This is because even though more children can be + // scrolled into view, the same distance must be maintained for a smooth + // animation. + _animationOffsets[key] = totalAnimatingOffset; + } + + @override + double indexToLayoutOffset(double itemExtent, int index) { + // itemExtent is deprecated in the super class, we ignore it because we use + // the builder anyways. + double position = 0.0; + int currentIndex = 0; + double totalAnimationOffset = 0.0; + double? itemExtent; + final int? childCount = childManager.estimatedChildCount; + while (currentIndex < index) { + if (childCount != null && currentIndex > childCount - 1) { + break; + } + + itemExtent = itemExtentBuilder(currentIndex, _currentLayoutDimensions); + if (itemExtent == null) { + break; + } + if (_animationLeadingIndices.keys.contains(currentIndex)) { + final UniqueKey animationKey = _animationLeadingIndices[currentIndex]!; + assert(_animationOffsets[animationKey] != null); + // We add the offset accounting for the animation value. + totalAnimationOffset += _animationOffsets[animationKey]! * (1 - _activeAnimations[animationKey]!.value); + } + position += itemExtent; + currentIndex++; + } + return position - totalAnimationOffset; + } + + final Map> _clipHandles = >{}; + + @override + void paint(PaintingContext context, Offset offset) { + if (firstChild == null) { + return; + } + + RenderBox? nextChild = firstChild; + void paintUpTo( + int index, + RenderBox? startWith, + PaintingContext context, + Offset offset, + ) { + RenderBox? child = startWith; + while (child != null && indexOf(child) <= index) { + final double mainAxisDelta = childMainAxisPosition(child); + final TreeSliverNodeParentData parentData = child.parentData! as TreeSliverNodeParentData; + final Offset childOffset = Offset( + parentData.depth * indentation, + parentData.layoutOffset!, + ); + + // If the child's visible interval (mainAxisDelta, mainAxisDelta + paintExtentOf(child)) + // does not intersect the paint extent interval (0, constraints.remainingPaintExtent), it's hidden. + if (mainAxisDelta < constraints.remainingPaintExtent && mainAxisDelta + paintExtentOf(child) > 0) { + context.paintChild(child, childOffset); + } + child = childAfter(child); + } + nextChild = child; + } + if (_animationLeadingIndices.isEmpty) { + // There are no animations running. + paintUpTo(indexOf(lastChild!), firstChild, context, offset); + return; + } + + // We are animating. + // Separate animating segments to clip for any overlap. + int leadingIndex = indexOf(firstChild!); + final List animationIndices = _animationLeadingIndices.keys.toList()..sort(); + final List<_PaintSegment> paintSegments = <_PaintSegment>[]; + while (animationIndices.isNotEmpty) { + final int trailingIndex = animationIndices.removeAt(0); + paintSegments.add((leadingIndex: leadingIndex, trailingIndex: trailingIndex)); + leadingIndex = trailingIndex + 1; + } + paintSegments.add((leadingIndex: leadingIndex, trailingIndex: indexOf(lastChild!))); + + // Paint, clipping for all but the first segment. + paintUpTo(paintSegments.removeAt(0).trailingIndex, nextChild, context, offset); + // Paint the rest with clip layers. + while (paintSegments.isNotEmpty) { + final _PaintSegment segment = paintSegments.removeAt(0); + + // Rect is calculated by the trailing edge of the parent (preceding + // leadingIndex), and the trailing edge of the trailing index. We cannot + // rely on the leading edge of the leading index, because it is currently + // moving. + final int parentIndex = math.max(segment.leadingIndex - 1, 0); + final double leadingOffset = indexToLayoutOffset(0.0, parentIndex) + + (parentIndex == 0 ? 0.0 : itemExtentBuilder(parentIndex, _currentLayoutDimensions)!); + final double trailingOffset = indexToLayoutOffset(0.0, segment.trailingIndex) + + itemExtentBuilder(segment.trailingIndex, _currentLayoutDimensions)!; + final Rect rect = Rect.fromPoints( + Offset(0.0, leadingOffset), + Offset(constraints.crossAxisExtent, trailingOffset), + ); + // We use the same animation key to keep track of the clip layer, unless + // this is the odd man out segment. + final UniqueKey key = _animationLeadingIndices[parentIndex]!; + _clipHandles[key] ??= LayerHandle(); + _clipHandles[key]!.layer = context.pushClipRect( + needsCompositing, + offset, + rect, + (PaintingContext context, Offset offset) { + paintUpTo(segment.trailingIndex, nextChild, context, offset); + }, + oldLayer: _clipHandles[key]!.layer, + ); + } + } +} diff --git a/packages/flutter/lib/src/widgets/sliver_tree.dart b/packages/flutter/lib/src/widgets/sliver_tree.dart new file mode 100644 index 000000000000..9232a23890e4 --- /dev/null +++ b/packages/flutter/lib/src/widgets/sliver_tree.dart @@ -0,0 +1,997 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +import 'basic.dart'; +import 'framework.dart'; +import 'gesture_detector.dart'; +import 'icon.dart'; +import 'icon_data.dart'; +import 'implicit_animations.dart'; +import 'scroll_delegate.dart'; +import 'sliver.dart'; +import 'text.dart'; +import 'ticker_provider.dart'; + +const double _kDefaultRowExtent = 40.0; + +/// A data structure for configuring children of a [TreeSliver]. +/// +/// A [TreeSliverNode.content] can be of any type [T], but must correspond with +/// the same type of the [TreeSliver]. +/// +/// The values returned by [depth], [parent] and [isExpanded] getters are +/// managed by the [TreeSliver]'s state. +class TreeSliverNode { + /// Creates a [TreeSliverNode] instance for use in a [TreeSliver]. + TreeSliverNode( + T content, { + List>? children, + bool expanded = false, + }) : _expanded = children != null && children.isNotEmpty && expanded, + _content = content, + _children = children ?? >[]; + + /// The subject matter of the node. + /// + /// Must correspond with the type of [TreeSliver]. + T get content => _content; + final T _content; + + /// Other [TreeSliverNode]s that this node will be [parent] to. + /// + /// Modifying the children of nodes in a [TreeSliver] will cause the tree to be + /// rebuilt so that newly added active nodes are reflected in the tree. + List> get children => _children; + final List> _children; + + /// Whether or not this node is expanded in the tree. + /// + /// Cannot be expanded if there are no children. + bool get isExpanded => _expanded; + bool _expanded; + + /// The number of parent nodes between this node and the root of the tree. + int? get depth => _depth; + int? _depth; + + /// The parent [TreeSliverNode] of this node. + TreeSliverNode? get parent => _parent; + TreeSliverNode? _parent; + + @override + String toString() { + return 'TreeSliverNode: $content, depth: ${depth == 0 ? 'root' : depth}, ' + '${children.isEmpty ? 'leaf' : 'parent, expanded: $isExpanded'}'; + } +} + +/// Signature for a function that creates a [Widget] to represent the given +/// [TreeSliverNode] in the [TreeSliver]. +/// +/// Used by [TreeSliver.treeNodeBuilder] to build rows on demand for the +/// tree. +typedef TreeSliverNodeBuilder = Widget Function( + BuildContext context, + TreeSliverNode node, + AnimationStyle animationStyle, +); + +/// Signature for a function that returns an extent for the given +/// [TreeSliverNode] in the [TreeSliver]. +/// +/// Used by [TreeSliver.treeRowExtentBuilder] to size rows on demand in the +/// tree. The provided [SliverLayoutDimensions] provide information about the +/// current scroll state and [Viewport] dimensions. +/// +/// See also: +/// +/// * [SliverVariedExtentList], which uses a similar item extent builder for +/// dynamic child sizing in the list. +typedef TreeSliverRowExtentBuilder = double Function( + TreeSliverNode node, + SliverLayoutDimensions dimensions, +); + +/// Signature for a function that is called when a [TreeSliverNode] is toggled, +/// changing its expanded state. +/// +/// See also: +/// +/// * [TreeSliver.onNodeToggle], for controlling node expansion +/// programmatically. +typedef TreeSliverNodeCallback = void Function(TreeSliverNode node); + +/// A mixin for classes implementing a tree structure as expected by a +/// [TreeSliverController]. +/// +/// Used by [TreeSliver] to implement an interface for the +/// [TreeSliverController]. +/// +/// This allows the [TreeSliverController] to be used in other widgets that +/// implement this interface. +/// +/// The type [T] correlates to the type of [TreeSliver] and [TreeSliverNode], +/// representing the type of [TreeSliverNode.content]. +mixin TreeSliverStateMixin { + /// Returns whether or not the given [TreeSliverNode] is expanded. + bool isExpanded(TreeSliverNode node); + + /// Returns whether or not the given [TreeSliverNode] is enclosed within its + /// parent [TreeSliverNode]. + /// + /// If the [TreeSliverNode.parent] [isExpanded] (and all its parents are + /// expanded), or this is a root node, the given node is active and this + /// method will return true. This does not reflect whether or not the node is + /// visible in the [Viewport]. + bool isActive(TreeSliverNode node); + + /// Switches the given [TreeSliverNode]s expanded state. + /// + /// May trigger an animation to reveal or hide the node's children based on + /// the [TreeSliver.toggleAnimationStyle]. + /// + /// If the node does not have any children, nothing will happen. + void toggleNode(TreeSliverNode node); + + /// Closes all parent [TreeSliverNode]s in the tree. + void collapseAll(); + + /// Expands all parent [TreeSliverNode]s in the tree. + void expandAll(); + + /// Retrieves the [TreeSliverNode] containing the associated content, if it + /// exists. + /// + /// If no node exists, this will return null. This does not reflect whether + /// or not a node [isActive], or if it is visible in the viewport. + TreeSliverNode? getNodeFor(T content); + + /// Returns the current row index of the given [TreeSliverNode]. + /// + /// If the node is not currently active in the tree, meaning its parent is + /// collapsed, this will return null. + int? getActiveIndexFor(TreeSliverNode node); +} + +/// Enables control over the [TreeSliverNode]s of a [TreeSliver]. +/// +/// It can be useful to expand or collapse nodes of the tree +/// programmatically, for example to reconfigure an existing node +/// based on a system event. To do so, create a [TreeSliver] +/// with a [TreeSliverController] that's owned by a stateful widget +/// or look up the tree's automatically created [TreeSliverController] +/// with [TreeSliverController.of] +/// +/// The controller's methods to expand or collapse nodes cause the +/// the [TreeSliver] to rebuild, so they may not be called from +/// a build method. +class TreeSliverController { + /// Create a controller to be used with [TreeSliver.controller]. + TreeSliverController(); + + TreeSliverStateMixin? _state; + + /// Whether the given [TreeSliverNode] built with this controller is in an + /// expanded state. + /// + /// See also: + /// + /// * [expandNode], which expands a given [TreeSliverNode]. + /// * [collapseNode], which collapses a given [TreeSliverNode]. + /// * [TreeSliver.controller] to create a TreeSliver with a controller. + bool isExpanded(TreeSliverNode node) { + assert(_state != null); + return _state!.isExpanded(node); + } + + /// Whether or not the given [TreeSliverNode] is enclosed within its parent + /// [TreeSliverNode]. + /// + /// If the [TreeSliverNode.parent] [isExpanded], or this is a root node, the + /// given node is active and this method will return true. This does not + /// reflect whether or not the node is visible in the [Viewport]. + bool isActive(TreeSliverNode node) { + assert(_state != null); + return _state!.isActive(node); + } + + /// Returns the [TreeSliverNode] containing the associated content, if it + /// exists. + /// + /// If no node exists, this will return null. This does not reflect whether + /// or not a node [isActive], or if it is currently visible in the viewport. + TreeSliverNode? getNodeFor(Object? content) { + assert(_state != null); + return _state!.getNodeFor(content); + } + + /// Switches the given [TreeSliverNode]s expanded state. + /// + /// May trigger an animation to reveal or hide the node's children based on + /// the [TreeSliver.toggleAnimationStyle]. + /// + /// If the node does not have any children, nothing will happen. + void toggleNode(TreeSliverNode node) { + assert(_state != null); + return _state!.toggleNode(node); + } + + /// Expands the [TreeSliverNode] that was built with this controller. + /// + /// If the node is already in the expanded state (see [isExpanded]), calling + /// this method has no effect. + /// + /// Calling this method may cause the [TreeSliver] to rebuild, so it may + /// not be called from a build method. + /// + /// Calling this method will trigger the [TreeSliver.onNodeToggle] + /// callback. + /// + /// See also: + /// + /// * [collapseNode], which collapses the [TreeSliverNode]. + /// * [isExpanded] to check whether the tile is expanded. + /// * [TreeSliver.controller] to create a TreeSliver with a controller. + void expandNode(TreeSliverNode node) { + assert(_state != null); + if (!node.isExpanded) { + _state!.toggleNode(node); + } + } + + /// Expands all parent [TreeSliverNode]s in the tree. + void expandAll() { + assert(_state != null); + _state!.expandAll(); + } + + /// Closes all parent [TreeSliverNode]s in the tree. + void collapseAll() { + assert(_state != null); + _state!.collapseAll(); + } + + /// Collapses the [TreeSliverNode] that was built with this controller. + /// + /// If the node is already in the collapsed state (see [isExpanded]), calling + /// this method has no effect. + /// + /// Calling this method may cause the [TreeSliver] to rebuild, so it may + /// not be called from a build method. + /// + /// Calling this method will trigger the [TreeSliver.onNodeToggle] + /// callback. + /// + /// See also: + /// + /// * [expandNode], which expands the tile. + /// * [isExpanded] to check whether the tile is expanded. + /// * [TreeSliver.controller] to create a TreeSliver with a controller. + void collapseNode(TreeSliverNode node) { + assert(_state != null); + if (node.isExpanded) { + _state!.toggleNode(node); + } + } + + /// Returns the current row index of the given [TreeSliverNode]. + /// + /// If the node is not currently active in the tree, meaning its parent is + /// collapsed, this will return null. + int? getActiveIndexFor(TreeSliverNode node) { + assert(_state != null); + return _state!.getActiveIndexFor(node); + } + + /// Finds the [TreeSliverController] for the closest [TreeSliver] instance + /// that encloses the given context. + /// + /// If no [TreeSliver] encloses the given context, calling this + /// method will cause an assert in debug mode, and throw an + /// exception in release mode. + /// + /// To return null if there is no [TreeSliver] use [maybeOf] instead. + /// + /// Typical usage of the [TreeSliverController.of] function is to call it + /// from within the `build` method of a descendant of a [TreeSliver]. + /// + /// When the [TreeSliver] is actually created in the same `build` + /// function as the callback that refers to the controller, then the + /// `context` argument to the `build` function can't be used to find + /// the [TreeSliverController] (since it's "above" the widget + /// being returned in the widget tree). In cases like that you can + /// add a [Builder] widget, which provides a new scope with a + /// [BuildContext] that is "under" the [TreeSliver]. + static TreeSliverController of(BuildContext context) { + final _TreeSliverState? result = + context.findAncestorStateOfType<_TreeSliverState>(); + if (result != null) { + return result.controller; + } + throw FlutterError.fromParts([ + ErrorSummary( + 'TreeController.of() called with a context that does not contain a ' + 'TreeSliver.', + ), + ErrorDescription( + 'No TreeSliver ancestor could be found starting from the context that ' + 'was passed to TreeController.of(). ' + 'This usually happens when the context provided is from the same ' + 'StatefulWidget as that whose build function actually creates the ' + 'TreeSliver widget being sought.', + ), + ErrorHint( + 'There are several ways to avoid this problem. The simplest is to use ' + 'a Builder to get a context that is "under" the TreeSliver.', + ), + ErrorHint( + 'A more efficient solution is to split your build function into ' + 'several widgets. This introduces a new context from which you can ' + 'obtain the TreeSliver. In this solution, you would have an outer ' + 'widget that creates the TreeSliver populated by instances of your new ' + 'inner widgets, and then in these inner widgets you would use ' + 'TreeController.of().', + ), + context.describeElement('The context used was'), + ]); + } + + /// Finds the [TreeSliver] from the closest instance of this class that + /// encloses the given context and returns its [TreeSliverController]. + /// + /// If no [TreeSliver] encloses the given context then return null. + /// To throw an exception instead, use [of] instead of this function. + /// + /// See also: + /// + /// * [of], a similar function to this one that throws if no [TreeSliver] + /// encloses the given context. Also includes some sample code in its + /// documentation. + static TreeSliverController? maybeOf(BuildContext context) { + return context.findAncestorStateOfType<_TreeSliverState>()?.controller; + } +} + +int _kDefaultSemanticIndexCallback(Widget _, int localIndex) => localIndex; + +/// A widget that displays [TreeSliverNode]s that expand and collapse in a +/// vertically and horizontally scrolling [Viewport]. +/// +/// The type [T] correlates to the type of [TreeSliver] and [TreeSliverNode], +/// representing the type of [TreeSliverNode.content]. +/// +/// The rows of the tree are laid out on demand by the [Viewport]'s render +/// object, using [TreeSliver.treeNodeBuilder]. This will only be called for the +/// nodes that are visible, or within the [Viewport.cacheExtent]. +/// +/// The [TreeSliver.treeNodeBuilder] returns the [Widget] that represents the +/// given [TreeSliverNode]. +/// +/// The [TreeSliver.treeRowExtentBuilder] returns a double representing the +/// extent of a given node in the main axis. +/// +/// Providing a [TreeSliverController] will enable querying and controlling the +/// state of nodes in the tree. +/// +/// A [TreeSliver] only supports a vertical axis direction of +/// [AxisDirection.down] and a horizontal axis direction of +/// [AxisDirection.right]. +/// +///{@tool dartpad} +/// This example uses a [TreeSliver] to display nodes, highlighting nodes as +/// they are selected. +/// +/// ** See code in examples/api/lib/widgets/sliver/sliver_tree.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a highly customized [TreeSliver] configured to +/// [TreeSliverIndentationType.none]. This allows the indentation to be handled +/// by the developer in [TreeSliver.treeNodeBuilder], where a decoration is +/// used to fill the indented space. +/// +/// ** See code in examples/api/lib/widgets/sliver/sliver_tree.1.dart ** +/// {@end-tool} +class TreeSliver extends StatefulWidget { + /// Creates an instance of a [TreeSliver] for displaying [TreeSliverNode]s + /// that animate expanding and collapsing of nodes. + const TreeSliver({ + super.key, + required this.tree, + this.treeNodeBuilder = TreeSliver.defaultTreeNodeBuilder, + this.treeRowExtentBuilder = TreeSliver.defaultTreeRowExtentBuilder, + this.controller, + this.onNodeToggle, + this.toggleAnimationStyle, + this.indentation = TreeSliverIndentationType.standard, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.semanticIndexCallback = _kDefaultSemanticIndexCallback, + this.semanticIndexOffset = 0, + this.findChildIndexCallback, + }); + + /// The list of [TreeSliverNode]s that may be displayed in the [TreeSliver]. + /// + /// Beyond root nodes, whether or not a given [TreeSliverNode] is displayed + /// depends on the [TreeSliverNode.isExpanded] value of its parent. The + /// [TreeSliver] will set the [TreeSliverNode.parent] and + /// [TreeSliverNode.depth] as nodes are built on demand to ensure the + /// integrity of the tree. + final List> tree; + + /// Called to build and entry of the [TreeSliver] for the given node. + /// + /// By default, if this is unset, the [TreeSliver.defaultTreeNodeBuilder] + /// is used. + final TreeSliverNodeBuilder treeNodeBuilder; + + /// Called to calculate the extent of the widget built for the given + /// [TreeSliverNode]. + /// + /// By default, if this is unset, the + /// [TreeSliver.defaultTreeRowExtentBuilder] is used. + /// + /// See also: + /// + /// * [SliverVariedExtentList.itemExtentBuilder], a very similar method that + /// allows users to dynamically compute extents on demand. + final TreeSliverRowExtentBuilder treeRowExtentBuilder; + + /// If provided, the controller can be used to expand and collapse + /// [TreeSliverNode]s, or lookup information about the current state of the + /// [TreeSliver]. + final TreeSliverController? controller; + + /// Called when a [TreeSliverNode] expands or collapses. + /// + /// This will not be called if a [TreeSliverNode] does not have any children. + final TreeSliverNodeCallback? onNodeToggle; + + /// The default [AnimationStyle] for expanding and collapsing nodes in the + /// [TreeSliver]. + /// + /// The default [AnimationStyle.duration] uses + /// [TreeSliver.defaultAnimationDuration], which is 150 milliseconds. + /// + /// The default [AnimationStyle.curve] uses [TreeSliver.defaultAnimationCurve], + /// which is [Curves.linear]. + /// + /// To disable the tree animation, use [AnimationStyle.noAnimation]. + final AnimationStyle? toggleAnimationStyle; + + /// The number of pixels children will be offset by in the cross axis based on + /// their [TreeSliverNode.depth]. + /// + /// {@macro flutter.rendering.TreeSliverIndentationType} + final TreeSliverIndentationType indentation; + + /// {@macro flutter.widgets.SliverChildBuilderDelegate.addAutomaticKeepAlives} + final bool addAutomaticKeepAlives; + + /// {@macro flutter.widgets.SliverChildBuilderDelegate.addRepaintBoundaries} + final bool addRepaintBoundaries; + + /// {@macro flutter.widgets.SliverChildBuilderDelegate.addSemanticIndexes} + final bool addSemanticIndexes; + + /// {@macro flutter.widgets.SliverChildBuilderDelegate.semanticIndexCallback} + final SemanticIndexCallback semanticIndexCallback; + + /// {@macro flutter.widgets.SliverChildBuilderDelegate.semanticIndexOffset} + final int semanticIndexOffset; + + /// {@macro flutter.widgets.SliverChildBuilderDelegate.findChildIndexCallback} + final int? Function(Key)? findChildIndexCallback; + + /// The default [AnimationStyle] used for node expand and collapse animations, + /// when one has not been provided in [toggleAnimationStyle]. + static AnimationStyle defaultToggleAnimationStyle = AnimationStyle( + curve: defaultAnimationCurve, + duration: defaultAnimationDuration, + ); + + /// A default of [Curves.linear], which is used in the tree's expanding and + /// collapsing node animation. + static const Curve defaultAnimationCurve = Curves.linear; + + /// A default [Duration] of 150 milliseconds, which is used in the tree's + /// expanding and collapsing node animation. + static const Duration defaultAnimationDuration = Duration(milliseconds: 150); + + /// A wrapper method for triggering the expansion or collapse of a + /// [TreeSliverNode]. + /// + /// Used as part of [TreeSliver.defaultTreeNodeBuilder] to wrap the leading + /// icon of parent [TreeSliverNode]s such that tapping on it triggers the + /// animation. + /// + /// If defining your own [TreeSliver.treeNodeBuilder], this method can be used + /// to wrap any part, or all, of the returned widget in order to trigger the + /// change in state for the node. + static Widget wrapChildToToggleNode({ + required TreeSliverNode node, + required Widget child, + }) { + return Builder(builder: (BuildContext context) { + return GestureDetector( + onTap: () { + TreeSliverController.of(context).toggleNode(node); + }, + child: child, + ); + }); + } + + /// Returns the fixed default extent for rows in the tree, which is 40 pixels. + /// + /// Used by [TreeSliver.treeRowExtentBuilder]. + static double defaultTreeRowExtentBuilder( + TreeSliverNode node, + SliverLayoutDimensions dimensions, + ) { + return _kDefaultRowExtent; + } + + /// Returns the default tree row for a given [TreeSliverNode]. + /// + /// Used by [TreeSliver.treeNodeBuilder]. + /// + /// This will return a [Row] containing the [toString] of + /// [TreeSliverNode.content]. If the [TreeSliverNode] is a parent of + /// additional nodes, a arrow icon will precede the content, and will trigger + /// an expand and collapse animation when tapped. + static Widget defaultTreeNodeBuilder( + BuildContext context, + TreeSliverNode node, + AnimationStyle toggleAnimationStyle + ) { + final Duration animationDuration = toggleAnimationStyle.duration + ?? TreeSliver.defaultAnimationDuration; + final Curve animationCurve = toggleAnimationStyle.curve + ?? TreeSliver.defaultAnimationCurve; + final int index = TreeSliverController.of(context).getActiveIndexFor(node)!; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row(children: [ + // Icon for parent nodes + TreeSliver.wrapChildToToggleNode( + node: node, + child: SizedBox.square( + dimension: 30.0, + child: node.children.isNotEmpty + ? AnimatedRotation( + key: ValueKey(index), + turns: node.isExpanded ? 0.25 : 0.0, + duration: animationDuration, + curve: animationCurve, + // Renders a unicode right-facing arrow. > + child: const Icon(IconData(0x25BA), size: 14), + ) + : null, + ), + ), + // Spacer + const SizedBox(width: 8.0), + // Content + Text(node.content.toString()), + ]), + ); + } + + @override + State> createState() => _TreeSliverState(); +} + +// Used in _SliverTreeState for code simplicity. +typedef _AnimationRecord = ({ + AnimationController controller, + CurvedAnimation animation, + UniqueKey key, +}); + +class _TreeSliverState extends State> with TickerProviderStateMixin, TreeSliverStateMixin { + TreeSliverController get controller => _treeController!; + TreeSliverController? _treeController; + + final List> _activeNodes = >[]; + bool _shouldUnpackNode(TreeSliverNode node) { + if (node.children.isEmpty) { + // No children to unpack. + return false; + } + if (_currentAnimationForParent[node] != null) { + // Whether expanding or collapsing, the child nodes are still active, so + // unpack. + return true; + } + // If we are not animating, respect node.isExpanded. + return node.isExpanded; + } + void _unpackActiveNodes({ + int depth = 0, + List>? nodes, + TreeSliverNode? parent, + }) { + if (nodes == null) { + _activeNodes.clear(); + nodes = widget.tree; + } + for (final TreeSliverNode node in nodes) { + node._depth = depth; + node._parent = parent; + _activeNodes.add(node); + if (_shouldUnpackNode(node)) { + _unpackActiveNodes( + depth: depth + 1, + nodes: node.children, + parent: node, + ); + } + } + } + + final Map, _AnimationRecord> _currentAnimationForParent = , _AnimationRecord>{}; + final Map _activeAnimations = {}; + + @override + void initState() { + _unpackActiveNodes(); + assert( + widget.controller?._state == null, + 'The provided TreeSliverController is already associated with another ' + 'TreeSliver. A TreeSliverController can only be associated with one ' + 'TreeSliver.', + ); + _treeController = widget.controller ?? TreeSliverController(); + _treeController!._state = this; + super.initState(); + } + + @override + void didUpdateWidget(TreeSliver oldWidget) { + super.didUpdateWidget(oldWidget); + // Internal or provided, there is always a tree controller. + assert(_treeController != null); + if (oldWidget.controller == null && widget.controller != null) { + // A new tree controller has been provided, update and dispose of the + // internally generated one. + _treeController!._state = null; + _treeController = widget.controller; + _treeController!._state = this; + } else if (oldWidget.controller != null && widget.controller == null) { + // A tree controller had been provided, but was removed. We need to create + // one internally. + assert(oldWidget.controller == _treeController); + oldWidget.controller!._state = null; + _treeController = TreeSliverController(); + _treeController!._state = this; + } else if (oldWidget.controller != widget.controller) { + assert(oldWidget.controller != null); + assert(widget.controller != null); + assert(oldWidget.controller == _treeController); + // The tree is still being provided a controller, but it has changed. Just + // update it. + _treeController!._state = null; + _treeController = widget.controller; + _treeController!._state = this; + } + // Internal or provided, there is always a tree controller. + assert(_treeController != null); + assert(_treeController!._state != null); + _unpackActiveNodes(); + } + + @override + void dispose() { + _treeController!._state = null; + for (final _AnimationRecord record in _currentAnimationForParent.values) { + record.animation.dispose(); + record.controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _SliverTree( + itemCount: _activeNodes.length, + activeAnimations: _activeAnimations, + itemBuilder: (BuildContext context, int index) { + final TreeSliverNode node = _activeNodes[index]; + Widget child = widget.treeNodeBuilder( + context, + node, + widget.toggleAnimationStyle ?? TreeSliver.defaultToggleAnimationStyle, + ); + + if (widget.addRepaintBoundaries) { + child = RepaintBoundary(child: child); + } + if (widget.addSemanticIndexes) { + final int? semanticIndex = widget.semanticIndexCallback(child, index); + if (semanticIndex != null) { + child = IndexedSemantics( + index: semanticIndex + widget.semanticIndexOffset, + child: child, + ); + } + } + + return _TreeNodeParentDataWidget( + depth: node.depth!, + child: child, + ); + }, + itemExtentBuilder: (int index, SliverLayoutDimensions dimensions) { + return widget.treeRowExtentBuilder(_activeNodes[index], dimensions); + }, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, + indentation: widget.indentation.value, + ); + } + + // TreeStateMixin Implementation + + @override + bool isExpanded(TreeSliverNode node) { + return _getNode(node.content, widget.tree)?.isExpanded ?? false; + } + + @override + bool isActive(TreeSliverNode node) => _activeNodes.contains(node); + + @override + TreeSliverNode? getNodeFor(T content) => _getNode(content, widget.tree); + TreeSliverNode? _getNode(T content, List> tree) { + final List> nextDepth = >[]; + for (final TreeSliverNode node in tree) { + if (node.content == content) { + return node; + } + if (node.children.isNotEmpty) { + nextDepth.addAll(node.children); + } + } + if (nextDepth.isNotEmpty) { + return _getNode(content, nextDepth); + } + return null; + } + + @override + int? getActiveIndexFor(TreeSliverNode node) { + if (_activeNodes.contains(node)) { + return _activeNodes.indexOf(node); + } + return null; + } + + @override + void expandAll() { + final List> activeNodesToExpand = >[]; + _expandAll(widget.tree, activeNodesToExpand); + activeNodesToExpand.reversed.forEach(toggleNode); + } + void _expandAll( + List> tree, + List> activeNodesToExpand, + ) { + for (final TreeSliverNode node in tree) { + if (node.children.isNotEmpty) { + // This is a parent node. + // Expand all the children, and their children. + _expandAll(node.children, activeNodesToExpand); + if (!node.isExpanded) { + // The node itself needs to be expanded. + if (_activeNodes.contains(node)) { + // This is an active node in the tree, add to + // the list to toggle once all hidden nodes + // have been handled. + activeNodesToExpand.add(node); + } else { + // This is a hidden node. Update its expanded state. + node._expanded = true; + } + } + } + } + } + + @override + void collapseAll() { + final List> activeNodesToCollapse = >[]; + _collapseAll(widget.tree, activeNodesToCollapse); + activeNodesToCollapse.reversed.forEach(toggleNode); + } + void _collapseAll( + List> tree, + List> activeNodesToCollapse, + ) { + for (final TreeSliverNode node in tree) { + if (node.children.isNotEmpty) { + // This is a parent node. + // Collapse all the children, and their children. + _collapseAll(node.children, activeNodesToCollapse); + if (node.isExpanded) { + // The node itself needs to be collapsed. + if (_activeNodes.contains(node)) { + // This is an active node in the tree, add to + // the list to toggle once all hidden nodes + // have been handled. + activeNodesToCollapse.add(node); + } else { + // This is a hidden node. Update its expanded state. + node._expanded = false; + } + } + } + } + } + + void _updateActiveAnimations() { + // The indexes of various child node animations can change constantly based + // on more nodes being expanded or collapsed. Compile the indexes and their + // animations keys each time we build with an updated active node list. + _activeAnimations.clear(); + for (final TreeSliverNode node in _currentAnimationForParent.keys) { + final _AnimationRecord animationRecord = _currentAnimationForParent[node]!; + final int leadingChildIndex = _activeNodes.indexOf(node) + 1; + final TreeSliverNodesAnimation animatingChildren = ( + fromIndex: leadingChildIndex, + toIndex: leadingChildIndex + node.children.length - 1, + value: animationRecord.animation.value, + ); + _activeAnimations[animationRecord.key] = animatingChildren; + } + } + + @override + void toggleNode(TreeSliverNode node) { + assert(_activeNodes.contains(node)); + if (node.children.isEmpty) { + // No state to change. + return; + } + setState(() { + node._expanded = !node._expanded; + if (widget.onNodeToggle != null) { + widget.onNodeToggle!(node); + } + final AnimationController controller = _currentAnimationForParent[node]?.controller + ?? AnimationController( + value: node._expanded ? 0.0 : 1.0, + vsync: this, + duration: widget.toggleAnimationStyle?.duration + ?? TreeSliver.defaultAnimationDuration, + )..addStatusListener((AnimationStatus status) { + switch (status) { + case AnimationStatus.dismissed: + case AnimationStatus.completed: + _currentAnimationForParent[node]!.controller.dispose(); + _currentAnimationForParent.remove(node); + _updateActiveAnimations(); + case AnimationStatus.forward: + case AnimationStatus.reverse: + } + })..addListener(() { + setState((){ + _updateActiveAnimations(); + }); + }); + + switch (controller.status) { + case AnimationStatus.forward: + case AnimationStatus.reverse: + // We're interrupting an animation already in progress. + controller.stop(); + case AnimationStatus.dismissed: + case AnimationStatus.completed: + } + + final CurvedAnimation newAnimation = CurvedAnimation( + parent: controller, + curve: widget.toggleAnimationStyle?.curve ?? TreeSliver.defaultAnimationCurve, + ); + _currentAnimationForParent[node] = ( + controller: controller, + animation: newAnimation, + // This key helps us keep track of the lifetime of this animation in the + // render object, since the indexes can change at any time. + key: UniqueKey(), + ); + switch (node._expanded) { + case true: + // Expanding + _unpackActiveNodes(); + controller.forward(); + case false: + // Collapsing + controller.reverse().then((_) { + _unpackActiveNodes(); + }); + } + }); + } +} + +class _TreeNodeParentDataWidget extends ParentDataWidget { + const _TreeNodeParentDataWidget({ + required this.depth, + required super.child, + }) : assert(depth >= 0); + + final int depth; + + @override + void applyParentData(RenderObject renderObject) { + final TreeSliverNodeParentData parentData = renderObject.parentData! as TreeSliverNodeParentData; + bool needsLayout = false; + + if (parentData.depth != depth) { + assert(depth >= 0); + parentData.depth = depth; + needsLayout = true; + } + + if (needsLayout) { + renderObject.parent?.markNeedsLayout(); + } + } + + @override + Type get debugTypicalAncestorWidgetClass => _SliverTree; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('depth', depth)); + } +} + +class _SliverTree extends SliverVariedExtentList { + _SliverTree({ + required NullableIndexedWidgetBuilder itemBuilder, + required super.itemExtentBuilder, + required this.activeAnimations, + required this.indentation, + ChildIndexGetter? findChildIndexCallback, + required int itemCount, + bool addAutomaticKeepAlives = true, + }) : super(delegate: SliverChildBuilderDelegate( + itemBuilder, + findChildIndexCallback: findChildIndexCallback, + childCount: itemCount, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: false, // Added in the _SliverTreeState + addSemanticIndexes: false, // Added in the _SliverTreeState + )); + + final Map activeAnimations; + final double indentation; + + @override + RenderTreeSliver createRenderObject(BuildContext context) { + final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement; + return RenderTreeSliver( + itemExtentBuilder: itemExtentBuilder, + activeAnimations: activeAnimations, + indentation: indentation, + childManager: element, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderTreeSliver renderObject) { + renderObject + ..itemExtentBuilder = itemExtentBuilder + ..activeAnimations = activeAnimations + ..indentation = indentation; + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index a85573de441e..bcee29396877 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -137,6 +137,7 @@ export 'src/widgets/sliver_fill.dart'; export 'src/widgets/sliver_layout_builder.dart'; export 'src/widgets/sliver_persistent_header.dart'; export 'src/widgets/sliver_prototype_extent_list.dart'; +export 'src/widgets/sliver_tree.dart'; export 'src/widgets/slotted_render_object_widget.dart'; export 'src/widgets/snapshot_widget.dart'; export 'src/widgets/spacer.dart'; diff --git a/packages/flutter/test/rendering/sliver_tree_test.dart b/packages/flutter/test/rendering/sliver_tree_test.dart new file mode 100644 index 000000000000..a38981b42e2c --- /dev/null +++ b/packages/flutter/test/rendering/sliver_tree_test.dart @@ -0,0 +1,860 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List> _setUpNodes() { + return >[ + TreeSliverNode('First'), + TreeSliverNode( + 'Second', + children: >[ + TreeSliverNode( + 'alpha', + children: >[ + TreeSliverNode('uno'), + TreeSliverNode('dos'), + TreeSliverNode('tres'), + ], + ), + TreeSliverNode('beta'), + TreeSliverNode('kappa'), + ], + ), + TreeSliverNode( + 'Third', + expanded: true, + children: >[ + TreeSliverNode('gamma'), + TreeSliverNode('delta'), + TreeSliverNode('epsilon'), + ], + ), + TreeSliverNode('Fourth'), + ]; +} + +List> treeNodes = _setUpNodes(); + +void main() { + testWidgets('asserts proper axis directions', (WidgetTester tester) async { + final List exceptions = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + addTearDown(() { + FlutterError.onError = oldHandler; + }); + + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + reverse: true, + slivers: [ + TreeSliver(tree: treeNodes), + ], + ), + )); + + FlutterError.onError = oldHandler; + expect(exceptions.isNotEmpty, isTrue); + expect( + exceptions[0].toString(), + contains('TreeSliver is only supported in Viewports with an AxisDirection.down.'), + ); + + exceptions.clear(); + await tester.pumpWidget(Container()); + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + slivers: [ + TreeSliver(tree: treeNodes), + ], + ), + )); + + FlutterError.onError = oldHandler; + expect(exceptions.isNotEmpty, isTrue); + expect( + exceptions[0].toString(), + contains('TreeSliver is only supported in Viewports with an AxisDirection.down.'), + ); + + exceptions.clear(); + await tester.pumpWidget(Container()); + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + scrollDirection: Axis.horizontal, + slivers: [ + TreeSliver(tree: treeNodes), + ], + ), + )); + + FlutterError.onError = oldHandler; + expect(exceptions.isNotEmpty, isTrue); + expect( + exceptions[0].toString(), + contains('TreeSliver is only supported in Viewports with an AxisDirection.down.'), + ); + }); + + testWidgets('Basic layout', (WidgetTester tester) async { + treeNodes = _setUpNodes(); + // Default layout, custom indentation values, row extents. + TreeSliver treeSliver = TreeSliver( + tree: treeNodes, + ); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + expect(find.text('First'), findsOneWidget); + expect( + tester.getRect(find.text('First')), + const Rect.fromLTRB(46.0, 8.0, 286.0, 32.0), + ); + expect(find.text('Second'), findsOneWidget); + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + expect(find.text('Third'), findsOneWidget); + expect( + tester.getRect(find.text('Third')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + expect(find.text('gamma'), findsOneWidget); + expect( + tester.getRect(find.text('gamma')), + const Rect.fromLTRB(46.0, 128.0, 286.0, 152.0), + ); + expect(find.text('delta'), findsOneWidget); + expect( + tester.getRect(find.text('delta')), + const Rect.fromLTRB(46.0, 168.0, 286.0, 192.0), + ); + expect(find.text('epsilon'), findsOneWidget); + expect( + tester.getRect(find.text('epsilon')), + const Rect.fromLTRB(46.0, 208.0, 382.0, 232.0), + ); + expect(find.text('Fourth'), findsOneWidget); + expect( + tester.getRect(find.text('Fourth')), + const Rect.fromLTRB(46.0, 248.0, 334.0, 272.0), + ); + + treeSliver = TreeSliver( + tree: treeNodes, + indentation: TreeSliverIndentationType.none, + ); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(46.0, 128.0)) + ..paragraph(offset: const Offset(46.0, 168.0)) + ..paragraph(offset: const Offset(46.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + expect(find.text('First'), findsOneWidget); + expect( + tester.getRect(find.text('First')), + const Rect.fromLTRB(46.0, 8.0, 286.0, 32.0), + ); + expect(find.text('Second'), findsOneWidget); + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + expect(find.text('Third'), findsOneWidget); + expect( + tester.getRect(find.text('Third')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + expect(find.text('gamma'), findsOneWidget); + expect( + tester.getRect(find.text('gamma')), + const Rect.fromLTRB(46.0, 128.0, 286.0, 152.0), + ); + expect(find.text('delta'), findsOneWidget); + expect( + tester.getRect(find.text('delta')), + const Rect.fromLTRB(46.0, 168.0, 286.0, 192.0), + ); + expect(find.text('epsilon'), findsOneWidget); + expect( + tester.getRect(find.text('epsilon')), + const Rect.fromLTRB(46.0, 208.0, 382.0, 232.0), + ); + expect(find.text('Fourth'), findsOneWidget); + expect( + tester.getRect(find.text('Fourth')), + const Rect.fromLTRB(46.0, 248.0, 334.0, 272.0), + ); + + treeSliver = TreeSliver( + tree: treeNodes, + indentation: TreeSliverIndentationType.custom(50.0), + ); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(96.0, 128.0)) + ..paragraph(offset: const Offset(96.0, 168.0)) + ..paragraph(offset: const Offset(96.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + expect(find.text('First'), findsOneWidget); + expect( + tester.getRect(find.text('First')), + const Rect.fromLTRB(46.0, 8.0, 286.0, 32.0), + ); + expect(find.text('Second'), findsOneWidget); + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + expect(find.text('Third'), findsOneWidget); + expect( + tester.getRect(find.text('Third')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + expect(find.text('gamma'), findsOneWidget); + expect( + tester.getRect(find.text('gamma')), + const Rect.fromLTRB(46.0, 128.0, 286.0, 152.0), + ); + expect(find.text('delta'), findsOneWidget); + expect( + tester.getRect(find.text('delta')), + const Rect.fromLTRB(46.0, 168.0, 286.0, 192.0), + ); + expect(find.text('epsilon'), findsOneWidget); + expect( + tester.getRect(find.text('epsilon')), + const Rect.fromLTRB(46.0, 208.0, 382.0, 232.0), + ); + expect(find.text('Fourth'), findsOneWidget); + expect( + tester.getRect(find.text('Fourth')), + const Rect.fromLTRB(46.0, 248.0, 334.0, 272.0), + ); + + treeSliver = TreeSliver( + tree: treeNodes, + treeRowExtentBuilder: (_, __) => 100, + ); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 26.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 126.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 226.0)) + ..paragraph(offset: const Offset(56.0, 326.0)) + ..paragraph(offset: const Offset(56.0, 426.0)) + ..paragraph(offset: const Offset(56.0, 526.0)) + ); + expect(find.text('First'), findsOneWidget); + expect( + tester.getRect(find.text('First')), + const Rect.fromLTRB(46.0, 26.0, 286.0, 74.0), + ); + expect(find.text('Second'), findsOneWidget); + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 126.0, 334.0, 174.0), + ); + expect(find.text('Third'), findsOneWidget); + expect( + tester.getRect(find.text('Third')), + const Rect.fromLTRB(46.0, 226.0, 286.0, 274.0), + ); + expect(find.text('gamma'), findsOneWidget); + expect( + tester.getRect(find.text('gamma')), + const Rect.fromLTRB(46.0, 326.0, 286.0, 374.0), + ); + expect(find.text('delta'), findsOneWidget); + expect( + tester.getRect(find.text('delta')), + const Rect.fromLTRB(46.0, 426.0, 286.0, 474.0), + ); + expect(find.text('epsilon'), findsOneWidget); + expect( + tester.getRect(find.text('epsilon')), + const Rect.fromLTRB(46.0, 526.0, 382.0, 574.0), + ); + expect(find.text('Fourth'), findsNothing); + }); + + testWidgets('Animating node segment', (WidgetTester tester) async { + treeNodes = _setUpNodes(); + TreeSliver treeSliver = TreeSliver(tree: treeNodes); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + expect(find.text('alpha'), findsNothing); + await tester.tap(find.byType(Icon).first); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph(offset: const Offset(56.0, 8.0)) // beta animating in + ..paragraph(offset: const Offset(56.0, 48.0)) // kappa animating in + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + // New nodes have been inserted into the tree, alpha + // is not visible yet. + expect(find.text('alpha'), findsNothing); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')), + const Rect.fromLTRB(46.0, 8.0, 238.0, 32.0), + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')), + const Rect.fromLTRB(46.0, 48.0, 286.0, 72.0), + ); + // Progress the animation. + await tester.pump(const Duration(milliseconds: 50)); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // alpha icon + ..paragraph(offset: const Offset(56.0, 8.0)) // alpha animating in + ..paragraph(offset: const Offset(56.0, 48.0)) // beta animating in + ..paragraph(offset: const Offset(56.0, 88.0)) // kappa animating in + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(56.0, 248.0)) + ..paragraph(offset: const Offset(46.0, 288.0)) + ); + expect( + tester.getRect(find.text('alpha')).top.floor(), + 8.0, + ); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')).top.floor(), + 48.0, + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')).top.floor(), + 88.0, + ); + // Complete the animation + await tester.pumpAndSettle(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) // First + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) // Second + ..paragraph() // alpha icon + ..paragraph(offset: const Offset(56.0, 88.0)) // alpha + ..paragraph(offset: const Offset(56.0, 128.0)) // beta + ..paragraph(offset: const Offset(56.0, 168.0)) // kappa + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 208.0)) // Third + ..paragraph(offset: const Offset(56.0, 248.0)) // gamma + ..paragraph(offset: const Offset(56.0, 288.0)) // delta + ..paragraph(offset: const Offset(56.0, 328.0)) // epsilon + ..paragraph(offset: const Offset(46.0, 368.0)) // Fourth + ); + expect(find.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')), + const Rect.fromLTRB(46.0, 128.0, 238.0, 152.0), + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')), + const Rect.fromLTRB(46.0, 168.0, 286.0, 192.0), + ); + + // Customize the animation + treeSliver = TreeSliver( + tree: treeNodes, + toggleAnimationStyle: AnimationStyle( + duration: const Duration(milliseconds: 500), + curve: Curves.bounceIn, + ), + ); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) // First + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) // Second + ..paragraph() // alpha icon + ..paragraph(offset: const Offset(56.0, 88.0)) // alpha + ..paragraph(offset: const Offset(56.0, 128.0)) // beta + ..paragraph(offset: const Offset(56.0, 168.0)) // kappa + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 208.0)) // Third + ..paragraph(offset: const Offset(56.0, 248.0)) // gamma + ..paragraph(offset: const Offset(56.0, 288.0)) // delta + ..paragraph(offset: const Offset(56.0, 328.0)) // epsilon + ..paragraph(offset: const Offset(46.0, 368.0)) // Fourth + ); + // Still visible from earlier. + expect(find.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + // Collapse the node now + await tester.tap(find.byType(Icon).first); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')).top.floor(), + -22, + ); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')).top.floor(), + 18, + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')).top.floor(), + 58, + ); + // Progress the animation. + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')).top.floor(), + -25, + ); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')).top.floor(), + 15, + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')).top.floor(), + 55.0, + ); + // Complete the animation + await tester.pumpAndSettle(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + expect(find.text('alpha'), findsNothing); + + // Disable the animation + treeSliver = TreeSliver( + tree: treeNodes, + toggleAnimationStyle: AnimationStyle.noAnimation, + ); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + // Not in the tree. + expect(find.text('alpha'), findsNothing); + // Collapse the node now + await tester.tap(find.byType(Icon).first); + await tester.pump(); + // No animating, straight to positions. + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) // First + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) // Second + ..paragraph() // alpha icon + ..paragraph(offset: const Offset(56.0, 88.0)) // alpha + ..paragraph(offset: const Offset(56.0, 128.0)) // beta + ..paragraph(offset: const Offset(56.0, 168.0)) // kappa + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 208.0)) // Third + ..paragraph(offset: const Offset(56.0, 248.0)) // gamma + ..paragraph(offset: const Offset(56.0, 288.0)) // delta + ..paragraph(offset: const Offset(56.0, 328.0)) // epsilon + ..paragraph(offset: const Offset(46.0, 368.0)) // Fourth + ); + expect(find.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')), + const Rect.fromLTRB(46.0, 128.0, 238.0, 152.0), + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')), + const Rect.fromLTRB(46.0, 168.0, 286.0, 192.0), + ); + }); + + testWidgets('Multiple animating node segments', (WidgetTester tester) async { + treeNodes = _setUpNodes(); + final TreeSliver treeSliver = TreeSliver(tree: treeNodes); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + expect(find.text('Second'), findsOneWidget); + expect(find.text('alpha'), findsNothing); // Second is collapsed + expect(find.text('Third'), findsOneWidget); + expect(find.text('gamma'), findsOneWidget); // Third is expanded + + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + expect( + tester.getRect(find.text('Third')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + expect( + tester.getRect(find.text('gamma')), + const Rect.fromLTRB(46.0, 128.0, 286.0, 152.0), + ); + + // Trigger two animations to run together. + // Collapse Third + await tester.tap(find.byType(Icon).last); + // Expand Second + await tester.tap(find.byType(Icon).first); + await tester.pump(const Duration(milliseconds: 15)); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph(offset: const Offset(56.0, 8.0)) // beta entering + ..paragraph(offset: const Offset(56.0, 48.0)) // kappa entering + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 88.0)) + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + // Third is collapsing + expect( + tester.getRect(find.text('Third')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + expect( + tester.getRect(find.text('gamma')), + const Rect.fromLTRB(46.0, 128.0, 286.0, 152.0), + ); + // Second is expanding + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + // beta has been added and is animating into view. + expect( + tester.getRect(find.text('beta')).top.floor(), + 8.0, + ); + await tester.pump(const Duration(milliseconds: 15)); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // alpha icon animating + ..paragraph(offset: const Offset(56.0, -20.0)) // alpha naimating + ..paragraph(offset: const Offset(56.0, 20.0)) // beta + ..paragraph(offset: const Offset(56.0, 60.0)) // kappa + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 100.0)) // Third + // Children of Third are animating, but the expand and + // collapse counter each other, so their position is unchanged. + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + // Third is still collapsing. Third is sliding down + // as Seconds's children slide in, gamma is still exiting. + expect( + tester.getRect(find.text('Third')).top.floor(), + 100.0, + ); + // gamma appears to not have moved, this is because it is + // intersecting both animations, the positive offset of + // Second animation == the negative offset of Third + expect( + tester.getRect(find.text('gamma')), + const Rect.fromLTRB(46.0, 128.0, 286.0, 152.0), + ); + // Second is still expanding + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + // alpha is still animating into view. + expect( + tester.getRect(find.text('alpha')).top.floor(), + -20.0, + ); + // Progress the animation further + await tester.pump(const Duration(milliseconds: 15)); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // alpha icon animating + ..paragraph(offset: const Offset(56.0, -8.0)) // alpha animating + ..paragraph(offset: const Offset(56.0, 32.0)) // beta + ..paragraph(offset: const Offset(56.0, 72.0)) // kappa + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 112.0)) // Third + // Children of Third are animating, but the expand and + // collapse counter each other, so their position is unchanged. + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph(offset: const Offset(56.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + // Third is still collapsing. Third is sliding down + // as Seconds's children slide in, gamma is still exiting. + expect( + tester.getRect(find.text('Third')).top.floor(), + 112.0, + ); + // gamma appears to not have moved, this is because it is + // intersecting both animations, the positive offset of + // Second animation == the negative offset of Third + expect( + tester.getRect(find.text('gamma')), + const Rect.fromLTRB(46.0, 128.0, 286.0, 152.0), + ); + // Second is still expanding + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + // alpha is still animating into view. + expect( + tester.getRect(find.text('alpha')).top.floor(), + -8.0, + ); + // Complete the animations + await tester.pumpAndSettle(); + expect( + find.byType(TreeSliver), + paints + ..paragraph(offset: const Offset(46.0, 8.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 48.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(56.0, 88.0)) + ..paragraph(offset: const Offset(56.0, 128.0)) + ..paragraph(offset: const Offset(56.0, 168.0)) + ..paragraph() // Icon + ..paragraph(offset: const Offset(46.0, 208.0)) + ..paragraph(offset: const Offset(46.0, 248.0)) + ); + expect( + tester.getRect(find.text('Third')), + const Rect.fromLTRB(46.0, 208.0, 286.0, 232.0), + ); + // gamma has left the building + expect(find.text('gamma'), findsNothing); + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + // alpha is in place. + expect( + tester.getRect(find.text('alpha')), + const Rect.fromLTRB(46.0, 88.0, 286.0, 112.0), + ); + }); + + testWidgets('only paints visible rows', (WidgetTester tester) async { + treeNodes = _setUpNodes(); + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + treeNodes = _setUpNodes(); + final TreeSliver treeSliver = TreeSliver( + treeRowExtentBuilder: (_, __) => 200, + tree: treeNodes, + ); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + controller: scrollController, + slivers: [ treeSliver ], + ), + )); + await tester.pump(); + expect(scrollController.position.pixels, 0.0); + expect(scrollController.position.maxScrollExtent, 800.0); + bool rowNeedsPaint(String row) { + return find.text(row).evaluate().first.renderObject!.debugNeedsPaint; + } + + expect(rowNeedsPaint('First'), isFalse); + expect(rowNeedsPaint('Second'), isFalse); + expect(rowNeedsPaint('Third'), isFalse); + expect(find.text('gamma'), findsNothing); // Not visible + + // Change the scroll offset + scrollController.jumpTo(200); + await tester.pump(); + expect(find.text('First'), findsNothing); + expect(rowNeedsPaint('Second'), isFalse); + expect(rowNeedsPaint('Third'), isFalse); + expect(rowNeedsPaint('gamma'), isFalse); // Now visible + }); +} diff --git a/packages/flutter/test/widgets/sliver_tree_test.dart b/packages/flutter/test/widgets/sliver_tree_test.dart new file mode 100644 index 000000000000..a10277c7961f --- /dev/null +++ b/packages/flutter/test/widgets/sliver_tree_test.dart @@ -0,0 +1,727 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List> simpleNodeSet = >[ + TreeSliverNode('Root 0'), + TreeSliverNode( + 'Root 1', + expanded: true, + children: >[ + TreeSliverNode('Child 1:0'), + TreeSliverNode('Child 1:1'), + ], + ), + TreeSliverNode( + 'Root 2', + children: >[ + TreeSliverNode('Child 2:0'), + TreeSliverNode('Child 2:1'), + ], + ), + TreeSliverNode('Root 3'), +]; + +void main() { + group('TreeSliverNode', () { + test('getters, toString', () { + final List> children = >[ + TreeSliverNode('child'), + ]; + final TreeSliverNode node = TreeSliverNode( + 'parent', + children: children, + expanded: true, + ); + expect(node.content, 'parent'); + expect(node.children, children); + expect(node.isExpanded, isTrue); + expect(node.children.first.content, 'child'); + expect(node.children.first.children.isEmpty, isTrue); + expect(node.children.first.isExpanded, isFalse); + // Set by TreeSliver when built for tree integrity + expect(node.depth, isNull); + expect(node.parent, isNull); + expect(node.children.first.depth, isNull); + expect(node.children.first.parent, isNull); + + expect( + node.toString(), + 'TreeSliverNode: parent, depth: null, parent, expanded: true', + ); + expect( + node.children.first.toString(), + 'TreeSliverNode: child, depth: null, leaf', + ); + }); + + testWidgets('TreeSliverNode sets ups parent and depth properties', (WidgetTester tester) async { + final List> children = >[ + TreeSliverNode('child'), + ]; + final TreeSliverNode node = TreeSliverNode( + 'parent', + children: children, + expanded: true, + ); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: >[node], + ), + ], + ) + )); + expect(node.content, 'parent'); + expect(node.children, children); + expect(node.isExpanded, isTrue); + expect(node.children.first.content, 'child'); + expect(node.children.first.children.isEmpty, isTrue); + expect(node.children.first.isExpanded, isFalse); + // Set by TreeSliver when built for tree integrity + expect(node.depth, 0); + expect(node.parent, isNull); + expect(node.children.first.depth, 1); + expect(node.children.first.parent, node); + + expect( + node.toString(), + 'TreeSliverNode: parent, depth: root, parent, expanded: true', + ); + expect( + node.children.first.toString(), + 'TreeSliverNode: child, depth: 1, leaf', + ); + }); + }); + + group('TreeController', () { + setUp(() { + // Reset node conditions for each test. + simpleNodeSet = >[ + TreeSliverNode('Root 0'), + TreeSliverNode( + 'Root 1', + expanded: true, + children: >[ + TreeSliverNode('Child 1:0'), + TreeSliverNode('Child 1:1'), + ], + ), + TreeSliverNode( + 'Root 2', + children: >[ + TreeSliverNode('Child 2:0'), + TreeSliverNode('Child 2:1'), + ], + ), + TreeSliverNode('Root 3'), + ]; + }); + testWidgets('Can set controller on TreeSliver', (WidgetTester tester) async { + final TreeSliverController controller = TreeSliverController(); + TreeSliverController? returnedController; + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + treeNodeBuilder: ( + BuildContext context, + TreeSliverNode node, + AnimationStyle toggleAnimationStyle, + ) { + returnedController ??= TreeSliverController.of(context); + return TreeSliver.defaultTreeNodeBuilder( + context, + node, + toggleAnimationStyle, + ); + }, + ), + ], + ), + )); + expect(controller, returnedController); + }); + + testWidgets('Can get default controller on TreeSliver', (WidgetTester tester) async { + TreeSliverController? returnedController; + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + treeNodeBuilder: ( + BuildContext context, + TreeSliverNode node, + AnimationStyle toggleAnimationStyle, + ) { + returnedController ??= TreeSliverController.maybeOf(context); + return TreeSliver.defaultTreeNodeBuilder( + context, + node, + toggleAnimationStyle, + ); + }, + ), + ], + ), + )); + expect(returnedController, isNotNull); + }); + + testWidgets('Can get node for TreeSliverNode.content', (WidgetTester tester) async { + final TreeSliverController controller = TreeSliverController(); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + ), + ], + ), + )); + + expect(controller.getNodeFor('Root 0'), simpleNodeSet[0]); + }); + + testWidgets('Can get isExpanded for a node', (WidgetTester tester) async { + final TreeSliverController controller = TreeSliverController(); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + ), + ], + ), + )); + expect( + controller.isExpanded(simpleNodeSet[0]), + isFalse, + ); + expect( + controller.isExpanded(simpleNodeSet[1]), + isTrue, + ); + }); + + testWidgets('Can get isActive for a node', (WidgetTester tester) async { + final TreeSliverController controller = TreeSliverController(); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + ), + ], + ), + )); + expect( + controller.isActive(simpleNodeSet[0]), + isTrue, + ); + expect( + controller.isActive(simpleNodeSet[1]), + isTrue, + ); + // The parent 'Root 2' is not expanded, so its children are not active. + expect( + controller.isExpanded(simpleNodeSet[2]), + isFalse, + ); + expect( + controller.isActive(simpleNodeSet[2].children[0]), + isFalse, + ); + }); + + testWidgets('Can toggleNode, to collapse or expand', (WidgetTester tester) async { + final TreeSliverController controller = TreeSliverController(); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + ), + ], + ), + )); + + // The parent 'Root 2' is not expanded, so its children are not active. + expect( + controller.isExpanded(simpleNodeSet[2]), + isFalse, + ); + expect( + controller.isActive(simpleNodeSet[2].children[0]), + isFalse, + ); + // Toggle 'Root 2' to expand it + controller.toggleNode(simpleNodeSet[2]); + expect( + controller.isExpanded(simpleNodeSet[2]), + isTrue, + ); + expect( + controller.isActive(simpleNodeSet[2].children[0]), + isTrue, + ); + + // The parent 'Root 1' is expanded, so its children are active. + expect( + controller.isExpanded(simpleNodeSet[1]), + isTrue, + ); + expect( + controller.isActive(simpleNodeSet[1].children[0]), + isTrue, + ); + // Collapse 'Root 1' + controller.toggleNode(simpleNodeSet[1]); + expect( + controller.isExpanded(simpleNodeSet[1]), + isFalse, + ); + expect( + controller.isActive(simpleNodeSet[1].children[0]), + isTrue, + ); + // Nodes are not removed from the active list until the collapse animation + // completes. The parent expansion state also updates. + await tester.pumpAndSettle(); + expect( + controller.isExpanded(simpleNodeSet[1]), + isFalse, + ); + expect( + controller.isActive(simpleNodeSet[1].children[0]), + isFalse, + ); + }); + + testWidgets('Can expandNode, then collapseAll', + (WidgetTester tester) async { + final TreeSliverController controller = TreeSliverController(); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + ), + ], + ), + )); + + // The parent 'Root 2' is not expanded, so its children are not active. + expect( + controller.isExpanded(simpleNodeSet[2]), + isFalse, + ); + expect( + controller.isActive(simpleNodeSet[2].children[0]), + isFalse, + ); + // Expand 'Root 2' + controller.expandNode(simpleNodeSet[2]); + expect( + controller.isExpanded(simpleNodeSet[2]), + isTrue, + ); + expect( + controller.isActive(simpleNodeSet[2].children[0]), + isTrue, + ); + + // Both parents from our simple node set are expanded. + // 'Root 1' + expect(controller.isExpanded(simpleNodeSet[1]), isTrue); + // 'Root 2' + expect(controller.isExpanded(simpleNodeSet[2]), isTrue); + // Collapse both. + controller.collapseAll(); + await tester.pumpAndSettle(); + // Both parents from our simple node set have collapsed. + // 'Root 1' + expect(controller.isExpanded(simpleNodeSet[1]), isFalse); + // 'Root 2' + expect(controller.isExpanded(simpleNodeSet[2]), isFalse); + }); + + testWidgets('Can collapseNode, then expandAll', (WidgetTester tester) async { + final TreeSliverController controller = TreeSliverController(); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + ), + ], + ), + )); + + // The parent 'Root 1' is expanded, so its children are active. + expect( + controller.isExpanded(simpleNodeSet[1]), + isTrue, + ); + expect( + controller.isActive(simpleNodeSet[1].children[0]), + isTrue, + ); + // Collapse 'Root 1' + controller.collapseNode(simpleNodeSet[1]); + expect( + controller.isExpanded(simpleNodeSet[1]), + isFalse, + ); + expect( + controller.isActive(simpleNodeSet[1].children[0]), + isTrue, + ); + // Nodes are not removed from the active list until the collapse animation + // completes. + await tester.pumpAndSettle(); + expect( + controller.isActive(simpleNodeSet[1].children[0]), + isFalse, + ); + + // Both parents from our simple node set are collapsed. + // 'Root 1' + expect(controller.isExpanded(simpleNodeSet[1]), isFalse); + // 'Root 2' + expect(controller.isExpanded(simpleNodeSet[2]), isFalse); + // Expand both. + controller.expandAll(); + // Both parents from our simple node set are expanded. + // 'Root 1' + expect(controller.isExpanded(simpleNodeSet[1]), isTrue); + // 'Root 2' + expect(controller.isExpanded(simpleNodeSet[2]), isTrue); + }); + }); + + test('TreeSliverIndentationType values are properly reflected', () { + double value = TreeSliverIndentationType.standard.value; + expect(value, 10.0); + + value = TreeSliverIndentationType.none.value; + expect(value, 0.0); + + value = TreeSliverIndentationType.custom(50.0).value; + expect(value, 50.0); + }); + + testWidgets('.toggleNodeWith, onNodeToggle', (WidgetTester tester) async { + final TreeSliverController controller = TreeSliverController(); + // The default node builder wraps the leading icon with toggleNodeWith. + bool toggled = false; + TreeSliverNode? toggledNode; + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + onNodeToggle: (TreeSliverNode node) { + toggled = true; + toggledNode = node as TreeSliverNode; + }, + ), + ], + ), + )); + expect(controller.isExpanded(simpleNodeSet[1]), isTrue); + await tester.tap(find.byType(Icon).first); + await tester.pump(); + expect(controller.isExpanded(simpleNodeSet[1]), isFalse); + expect(toggled, isTrue); + expect(toggledNode, simpleNodeSet[1]); + await tester.pumpAndSettle(); + expect(controller.isExpanded(simpleNodeSet[1]), isFalse); + toggled = false; + toggledNode = null; + // Use toggleNodeWith to make the whole row trigger the node state. + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + onNodeToggle: (TreeSliverNode node) { + toggled = true; + toggledNode = node as TreeSliverNode; + }, + treeNodeBuilder: ( + BuildContext context, + TreeSliverNode node, + AnimationStyle toggleAnimationStyle, + ) { + final Duration animationDuration = + toggleAnimationStyle.duration ?? TreeSliver.defaultAnimationDuration; + final Curve animationCurve = + toggleAnimationStyle.curve ?? TreeSliver.defaultAnimationCurve; + // This makes the whole row trigger toggling. + return TreeSliver.wrapChildToToggleNode( + node: node, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row(children: [ + // Icon for parent nodes + SizedBox.square( + dimension: 30.0, + child: node.children.isNotEmpty + ? AnimatedRotation( + turns: node.isExpanded ? 0.25 : 0.0, + duration: animationDuration, + curve: animationCurve, + child: const Icon(IconData(0x25BA), size: 14), + ) + : null, + ), + // Spacer + const SizedBox(width: 8.0), + // Content + Text(node.content.toString()), + ]), + ), + ); + }, + ), + ], + ), + )); + // Still collapsed from earlier + expect(controller.isExpanded(simpleNodeSet[1]), isFalse); + // Tapping on the text instead of the Icon. + await tester.tap(find.text('Root 1')); + await tester.pump(); + expect(controller.isExpanded(simpleNodeSet[1]), isTrue); + expect(toggled, isTrue); + expect(toggledNode, simpleNodeSet[1]); + }); + + testWidgets('AnimationStyle is piped through to node builder', (WidgetTester tester) async { + AnimationStyle? style; + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + treeNodeBuilder: ( + BuildContext context, + TreeSliverNode node, + AnimationStyle toggleAnimationStyle, + ) { + style ??= toggleAnimationStyle; + return Text(node.content.toString()); + }, + ), + ], + ), + )); + // Default + expect(style, TreeSliver.defaultToggleAnimationStyle); + + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + toggleAnimationStyle: AnimationStyle.noAnimation, + treeNodeBuilder: ( + BuildContext context, + TreeSliverNode node, + AnimationStyle toggleAnimationStyle, + ) { + style = toggleAnimationStyle; + return Text(node.content.toString()); + }, + ), + ], + ), + )); + expect(style, isNotNull); + expect(style!.curve, isNull); + expect(style!.duration, Duration.zero); + style = null; + + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + toggleAnimationStyle: AnimationStyle( + curve: Curves.easeIn, + duration: const Duration(milliseconds: 200), + ), + treeNodeBuilder: ( + BuildContext context, + TreeSliverNode node, + AnimationStyle toggleAnimationStyle, + ) { + style ??= toggleAnimationStyle; + return Text(node.content.toString()); + }, + ), + ], + ), + )); + expect(style, isNotNull); + expect(style!.curve, Curves.easeIn); + expect(style!.duration, const Duration(milliseconds: 200)); + }); + + testWidgets('Adding more root TreeViewNodes are reflected in the tree', (WidgetTester tester) async { + simpleNodeSet = >[ + TreeSliverNode('Root 0'), + TreeSliverNode( + 'Root 1', + expanded: true, + children: >[ + TreeSliverNode('Child 1:0'), + TreeSliverNode('Child 1:1'), + ], + ), + TreeSliverNode( + 'Root 2', + children: >[ + TreeSliverNode('Child 2:0'), + TreeSliverNode('Child 2:1'), + ], + ), + TreeSliverNode('Root 3'), + ]; + final TreeSliverController controller = TreeSliverController(); + await tester.pumpWidget(MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + simpleNodeSet.add(TreeSliverNode('Added root')); + }); + }, + ), + ); + }, + ), + )); + await tester.pump(); + + expect(find.text('Root 0'), findsOneWidget); + expect(find.text('Root 1'), findsOneWidget); + expect(find.text('Child 1:0'), findsOneWidget); + expect(find.text('Child 1:1'), findsOneWidget); + expect(find.text('Root 2'), findsOneWidget); + expect(find.text('Child 2:0'), findsNothing); + expect(find.text('Child 2:1'), findsNothing); + expect(find.text('Root 3'), findsOneWidget); + expect(find.text('Added root'), findsNothing); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + + expect(find.text('Root 0'), findsOneWidget); + expect(find.text('Root 1'), findsOneWidget); + expect(find.text('Child 1:0'), findsOneWidget); + expect(find.text('Child 1:1'), findsOneWidget); + expect(find.text('Root 2'), findsOneWidget); + expect(find.text('Child 2:0'), findsNothing); + expect(find.text('Child 2:1'), findsNothing); + expect(find.text('Root 3'), findsOneWidget); + // Node was added + expect(find.text('Added root'), findsOneWidget); + }); + + testWidgets('Adding more TreeViewNodes below the root are reflected in the tree', (WidgetTester tester) async { + simpleNodeSet = >[ + TreeSliverNode('Root 0'), + TreeSliverNode( + 'Root 1', + expanded: true, + children: >[ + TreeSliverNode('Child 1:0'), + TreeSliverNode('Child 1:1'), + ], + ), + TreeSliverNode( + 'Root 2', + children: >[ + TreeSliverNode('Child 2:0'), + TreeSliverNode('Child 2:1'), + ], + ), + TreeSliverNode('Root 3'), + ]; + final TreeSliverController controller = TreeSliverController(); + await tester.pumpWidget(MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: CustomScrollView( + slivers: [ + TreeSliver( + tree: simpleNodeSet, + controller: controller, + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + simpleNodeSet[1].children.add(TreeSliverNode('Added child')); + }); + }, + ), + ); + }, + ), + )); + await tester.pump(); + expect(find.text('Root 0'), findsOneWidget); + expect(find.text('Root 1'), findsOneWidget); + expect(find.text('Child 1:0'), findsOneWidget); + expect(find.text('Child 1:1'), findsOneWidget); + expect(find.text('Added child'), findsNothing); + expect(find.text('Root 2'), findsOneWidget); + expect(find.text('Child 2:0'), findsNothing); + expect(find.text('Child 2:1'), findsNothing); + expect(find.text('Root 3'), findsOneWidget); + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + expect(find.text('Root 0'), findsOneWidget); + expect(find.text('Root 1'), findsOneWidget); + expect(find.text('Child 1:0'), findsOneWidget); + expect(find.text('Child 1:1'), findsOneWidget); + // Child node was added + expect(find.text('Added child'), findsOneWidget); + expect(find.text('Root 2'), findsOneWidget); + expect(find.text('Child 2:0'), findsNothing); + expect(find.text('Child 2:1'), findsNothing); + expect(find.text('Root 3'), findsOneWidget); + }); +}