From b83b63c55eebb76ed838555fcc0cd0be8d16617e Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Thu, 16 May 2024 12:48:09 -0500 Subject: [PATCH] [two_dimensional_scrollables] TreeView (#6592) --- .../two_dimensional_scrollables/CHANGELOG.md | 5 + .../two_dimensional_scrollables/README.md | 35 +- .../example/.pluginToolsConfig.yaml | 4 + .../example/README.md | 4 +- .../example/lib/main.dart | 199 +-- .../lib/table_view/infinite_table.dart | 96 ++ .../example/lib/table_view/merged_table.dart | 145 +++ .../example/lib/table_view/simple_table.dart | 178 +++ .../lib/table_view/table_explorer.dart | 113 ++ .../example/lib/tree_view/custom_tree.dart | 282 +++++ .../example/lib/tree_view/simple_tree.dart | 152 +++ .../example/lib/tree_view/tree_explorer.dart | 96 ++ .../macos/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- .../example/macos/Runner/AppDelegate.swift | 2 +- .../example/pubspec.yaml | 10 +- .../example/test/main_test.dart | 27 + .../test/table_view/infinite_table_test.dart | 74 ++ .../test/table_view/merged_table_test.dart | 45 + .../simple_table_test.dart} | 6 +- .../test/table_view/table_explorer_test.dart | 39 + .../test/tree_view/custom_tree_test.dart | 69 ++ .../test/tree_view/simple_tree_test.dart | 60 + .../test/tree_view/tree_explorer_test.dart | 29 + .../lib/src/common/span.dart | 24 + .../lib/src/tree_view/render_tree.dart | 728 +++++++++++ .../lib/src/tree_view/tree.dart | 1077 +++++++++++++++++ .../lib/src/tree_view/tree_core.dart | 140 +++ .../lib/src/tree_view/tree_delegate.dart | 125 ++ .../lib/src/tree_view/tree_span.dart | 127 ++ .../lib/two_dimensional_scrollables.dart | 8 +- .../two_dimensional_scrollables/pubspec.yaml | 6 +- .../test/table_view/table_test.dart | 2 +- .../test/tree_view/render_tree_test.dart | 971 +++++++++++++++ .../test/tree_view/tree_core_test.dart | 21 + .../test/tree_view/tree_delegate_test.dart | 87 ++ .../test/tree_view/tree_span_test.dart | 208 ++++ .../test/tree_view/tree_test.dart | 799 ++++++++++++ 38 files changed, 5812 insertions(+), 185 deletions(-) create mode 100644 packages/two_dimensional_scrollables/example/.pluginToolsConfig.yaml create mode 100644 packages/two_dimensional_scrollables/example/lib/table_view/infinite_table.dart create mode 100644 packages/two_dimensional_scrollables/example/lib/table_view/merged_table.dart create mode 100644 packages/two_dimensional_scrollables/example/lib/table_view/simple_table.dart create mode 100644 packages/two_dimensional_scrollables/example/lib/table_view/table_explorer.dart create mode 100644 packages/two_dimensional_scrollables/example/lib/tree_view/custom_tree.dart create mode 100644 packages/two_dimensional_scrollables/example/lib/tree_view/simple_tree.dart create mode 100644 packages/two_dimensional_scrollables/example/lib/tree_view/tree_explorer.dart create mode 100644 packages/two_dimensional_scrollables/example/test/main_test.dart create mode 100644 packages/two_dimensional_scrollables/example/test/table_view/infinite_table_test.dart create mode 100644 packages/two_dimensional_scrollables/example/test/table_view/merged_table_test.dart rename packages/two_dimensional_scrollables/example/test/{table_view_example_test.dart => table_view/simple_table_test.dart} (89%) create mode 100644 packages/two_dimensional_scrollables/example/test/table_view/table_explorer_test.dart create mode 100644 packages/two_dimensional_scrollables/example/test/tree_view/custom_tree_test.dart create mode 100644 packages/two_dimensional_scrollables/example/test/tree_view/simple_tree_test.dart create mode 100644 packages/two_dimensional_scrollables/example/test/tree_view/tree_explorer_test.dart create mode 100644 packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart create mode 100644 packages/two_dimensional_scrollables/lib/src/tree_view/tree.dart create mode 100644 packages/two_dimensional_scrollables/lib/src/tree_view/tree_core.dart create mode 100644 packages/two_dimensional_scrollables/lib/src/tree_view/tree_delegate.dart create mode 100644 packages/two_dimensional_scrollables/lib/src/tree_view/tree_span.dart create mode 100644 packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart create mode 100644 packages/two_dimensional_scrollables/test/tree_view/tree_core_test.dart create mode 100644 packages/two_dimensional_scrollables/test/tree_view/tree_delegate_test.dart create mode 100644 packages/two_dimensional_scrollables/test/tree_view/tree_span_test.dart create mode 100644 packages/two_dimensional_scrollables/test/tree_view/tree_test.dart diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 20fe5bf78407..02fedb16e7ea 100644 --- a/packages/two_dimensional_scrollables/CHANGELOG.md +++ b/packages/two_dimensional_scrollables/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.3.0 + +* Adds new TreeView widget and associated classes. +* New example application for exploring both Trees and Tables. + ## 0.2.1 * Refactors TableSpans to use basic Span classes. Clean up for incoming TreeView. diff --git a/packages/two_dimensional_scrollables/README.md b/packages/two_dimensional_scrollables/README.md index 1b5a0e4b3fd0..0b6e8d08f571 100644 --- a/packages/two_dimensional_scrollables/README.md +++ b/packages/two_dimensional_scrollables/README.md @@ -5,8 +5,8 @@ two-dimensional foundation of the Flutter framework. ## Features -This package provides support for a TableView widget that scrolls in both the -vertical and horizontal axes. +This package provides support for TableView and TreeView widgets that scroll +in both the vertical and horizontal axes. ### TableView @@ -14,9 +14,21 @@ vertical and horizontal axes. children lazily in a `TwoDimensionalViewport`. This widget can - Scroll diagonally, or lock axes +- Build infinite rows and columns - Apply decorations to rows and columns - Handle gestures & custom pointers for rows and columns - Pin rows and columns +- Merge table cells + +### TreeView + +`TreeView` is a subclass of `TwoDimensionalScrollView`, building its provided +children lazily in a `TwoDimensionalViewport`. This widget can + +- Scroll diagonally, or lock axes +- Apply decorations to tree rows +- Handle gestures & custom pointers for tree rows +- Animate TreeViewNodes in and out of view ## Getting started @@ -40,12 +52,19 @@ import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; ### TableView -The code in `example/` shows a `TableView` of initially 400 cells, each varying -in sizes with a few `TableSpanDecoration`s like background colors and borders. The -`builder` constructor is called on demand for the cells that are visible in the -TableView. Additional rows can be added on demand while the vertical position -can jump between the first and last row using the buttons at the bottom of the -screen. +The code in `example/lib/table_view` has three `TableView` samples, each +showcasing different features. The `TableExample` demonstrates adding and +removing rows from the table, and applying `TableSpanDecoration`s. The +`MergedTableExample` demonstrates pinned and merged `TableViewCell`s. +Lastly, the `InfiniteTableExample` demonstrates an infinite `TableView`. + +### TreeView + +The code in `example/lib/tree_view` has two `TreeView` samples, each +showcasing different features. The `TreeExample` demonstrates most of +the default builders and animations. The `CustomTreeExample` demonstrates +a highly customized tree, utilizing `TreeView.treeNodeBuilder`, +`TreeView.treeRowBuilder` and `TreeView.onNodeToggle`. ## Changelog diff --git a/packages/two_dimensional_scrollables/example/.pluginToolsConfig.yaml b/packages/two_dimensional_scrollables/example/.pluginToolsConfig.yaml new file mode 100644 index 000000000000..4f59e7431004 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/.pluginToolsConfig.yaml @@ -0,0 +1,4 @@ +# TODO(Piinks): Remove once https://github.com/flutter/flutter/pull/147202 reaches stable +buildFlags: + global: + - "--no-tree-shake-icons" diff --git a/packages/two_dimensional_scrollables/example/README.md b/packages/two_dimensional_scrollables/example/README.md index 0f267cb31f70..e872332ecf63 100644 --- a/packages/two_dimensional_scrollables/example/README.md +++ b/packages/two_dimensional_scrollables/example/README.md @@ -1,3 +1,3 @@ -# TableView Example +# TableView and TreeView Examples -A sample application that utilizes the TableView API. +A sample application that utilizes the TableView and TreeView APIs. diff --git a/packages/two_dimensional_scrollables/example/lib/main.dart b/packages/two_dimensional_scrollables/example/lib/main.dart index ff714af1e497..a065a41938ed 100644 --- a/packages/two_dimensional_scrollables/example/lib/main.dart +++ b/packages/two_dimensional_scrollables/example/lib/main.dart @@ -2,189 +2,70 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; -// Print statements are only for illustrative purposes, not recommended for -// production applications. -// ignore_for_file: avoid_print +import 'table_view/table_explorer.dart'; +import 'tree_view/tree_explorer.dart'; void main() { - runApp(const TableExampleApp()); + runApp(const ExampleApp()); } -/// A sample application that utilizes the TableView API. -class TableExampleApp extends StatelessWidget { - /// Creates an instance of the TableView example app. - const TableExampleApp({super.key}); +/// A sample application that utilizes the TableView and TreeView APIs. +class ExampleApp extends StatelessWidget { + /// Creates an instance of the example app. + const ExampleApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( - title: 'Table Example', + title: '2D Scrolling Examples', theme: ThemeData( - useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple), + appBarTheme: AppBarTheme(backgroundColor: Colors.purple[50]), ), - home: const TableExample(), + home: const ExampleHome(), + routes: { + '/table': (BuildContext context) => const TableExplorer(), + '/tree': (BuildContext context) => const TreeExplorer(), + }, ); } } -/// The class containing the TableView for the sample application. -class TableExample extends StatefulWidget { +/// The home page of the application, which directs to the tree or table +/// explorer. +class ExampleHome extends StatelessWidget { /// Creates a screen that demonstrates the TableView widget. - const TableExample({super.key}); - - @override - State createState() => _TableExampleState(); -} - -class _TableExampleState extends State { - late final ScrollController _verticalController = ScrollController(); - int _rowCount = 20; + const ExampleHome({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Table Example'), - ), - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 50), - child: TableView.builder( - verticalDetails: - ScrollableDetails.vertical(controller: _verticalController), - cellBuilder: _buildCell, - columnCount: 20, - columnBuilder: _buildColumnSpan, - rowCount: _rowCount, - rowBuilder: _buildRowSpan, - ), - ), - persistentFooterButtons: [ - TextButton( - onPressed: () { - _verticalController.jumpTo(0); - }, - child: const Text('Jump to Top'), - ), - TextButton( - onPressed: () { - _verticalController - .jumpTo(_verticalController.position.maxScrollExtent); - }, - child: const Text('Jump to Bottom'), - ), - TextButton( - onPressed: () { - setState(() { - _rowCount += 10; - }); - }, - child: const Text('Add 10 Rows'), - ), - ], - ); - } - - TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) { - return TableViewCell( - child: Center( - child: Text('Tile c: ${vicinity.column}, r: ${vicinity.row}'), + title: const Text('Tables & Trees'), ), - ); - } - - TableSpan _buildColumnSpan(int index) { - const TableSpanDecoration decoration = TableSpanDecoration( - border: TableSpanBorder( - trailing: BorderSide(), + body: Center( + child: Column(children: [ + const Spacer(flex: 3), + FilledButton( + onPressed: () { + // Go to table explorer + Navigator.of(context).pushNamed('/table'); + }, + child: const Text('TableView Explorer'), + ), + const Spacer(), + FilledButton( + onPressed: () { + // Go to tree explorer + Navigator.of(context).pushNamed('/tree'); + }, + child: const Text('TreeView Explorer'), + ), + const Spacer(flex: 3), + ]), ), ); - - switch (index % 5) { - case 0: - return TableSpan( - foregroundDecoration: decoration, - extent: const FixedTableSpanExtent(100), - onEnter: (_) => print('Entered column $index'), - recognizerFactories: { - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (TapGestureRecognizer t) => - t.onTap = () => print('Tap column $index'), - ), - }, - ); - case 1: - return TableSpan( - foregroundDecoration: decoration, - extent: const FractionalTableSpanExtent(0.5), - onEnter: (_) => print('Entered column $index'), - cursor: SystemMouseCursors.contextMenu, - ); - case 2: - return TableSpan( - foregroundDecoration: decoration, - extent: const FixedTableSpanExtent(120), - onEnter: (_) => print('Entered column $index'), - ); - case 3: - return TableSpan( - foregroundDecoration: decoration, - extent: const FixedTableSpanExtent(145), - onEnter: (_) => print('Entered column $index'), - ); - case 4: - return TableSpan( - foregroundDecoration: decoration, - extent: const FixedTableSpanExtent(200), - onEnter: (_) => print('Entered column $index'), - ); - } - throw AssertionError( - 'This should be unreachable, as every index is accounted for in the switch clauses.'); - } - - TableSpan _buildRowSpan(int index) { - final TableSpanDecoration decoration = TableSpanDecoration( - color: index.isEven ? Colors.purple[100] : null, - border: const TableSpanBorder( - trailing: BorderSide( - width: 3, - ), - ), - ); - - switch (index % 3) { - case 0: - return TableSpan( - backgroundDecoration: decoration, - extent: const FixedTableSpanExtent(50), - recognizerFactories: { - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (TapGestureRecognizer t) => - t.onTap = () => print('Tap row $index'), - ), - }, - ); - case 1: - return TableSpan( - backgroundDecoration: decoration, - extent: const FixedTableSpanExtent(65), - cursor: SystemMouseCursors.click, - ); - case 2: - return TableSpan( - backgroundDecoration: decoration, - extent: const FractionalTableSpanExtent(0.15), - ); - } - throw AssertionError( - 'This should be unreachable, as every index is accounted for in the switch clauses.'); } } diff --git a/packages/two_dimensional_scrollables/example/lib/table_view/infinite_table.dart b/packages/two_dimensional_scrollables/example/lib/table_view/infinite_table.dart new file mode 100644 index 000000000000..970991394807 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/lib/table_view/infinite_table.dart @@ -0,0 +1,96 @@ +// Copyright 2013 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:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +/// The class demonstrating an infinite number of rows and columns in +/// TableView. +class InfiniteTableExample extends StatefulWidget { + /// Creates a screen that demonstrates an infinite TableView widget. + const InfiniteTableExample({super.key}); + + @override + State createState() => _InfiniteExampleState(); +} + +class _InfiniteExampleState extends State { + int? _rowCount; + int? _columnCount; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: TableView.builder( + cellBuilder: _buildCell, + columnCount: _columnCount, + columnBuilder: _buildSpan, + rowCount: _rowCount, + rowBuilder: _buildSpan, + diagonalDragBehavior: DiagonalDragBehavior.free, + ), + persistentFooterAlignment: AlignmentDirectional.center, + persistentFooterButtons: [ + Text( + 'Column count is ${_columnCount == null ? 'infinite' : '50 '}', + style: const TextStyle(fontStyle: FontStyle.italic), + ), + FilledButton( + onPressed: () { + setState(() { + if (_columnCount != null) { + _columnCount = null; + } else { + _columnCount = 50; + } + }); + }, + child: Text( + 'Make columns ${_columnCount == null ? 'fixed' : 'infinite'}', + ), + ), + const SizedBox.square(dimension: 10), + Text( + 'Row count is ${_rowCount == null ? 'infinite' : '50 '}', + style: const TextStyle(fontStyle: FontStyle.italic), + ), + FilledButton( + onPressed: () { + setState(() { + if (_rowCount != null) { + _rowCount = null; + } else { + _rowCount = 50; + } + }); + }, + child: Text( + 'Make rows ${_rowCount == null ? 'fixed' : 'infinite'}', + ), + ), + ], + ); + } + + TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) { + final Color boxColor = + switch ((vicinity.row.isEven, vicinity.column.isEven)) { + (true, false) || (false, true) => Colors.white, + (false, false) => Colors.indigo[100]!, + (true, true) => Colors.indigo[200]! + }; + return TableViewCell( + child: ColoredBox( + color: boxColor, + child: Center( + child: Text('${vicinity.column}:${vicinity.row}'), + ), + ), + ); + } + + TableSpan _buildSpan(int index) { + return const TableSpan(extent: FixedTableSpanExtent(100)); + } +} diff --git a/packages/two_dimensional_scrollables/example/lib/table_view/merged_table.dart b/packages/two_dimensional_scrollables/example/lib/table_view/merged_table.dart new file mode 100644 index 000000000000..e5b718ad74b2 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/lib/table_view/merged_table.dart @@ -0,0 +1,145 @@ +// Copyright 2013 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:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +/// The class demonstrating merged cells in TableView. +class MergedTableExample extends StatefulWidget { + /// Creates a screen that shows a color palette in the TableView widget. + const MergedTableExample({super.key}); + + @override + State createState() => _MergedTableExampleState(); +} + +class _MergedTableExampleState extends State { + ({String name, Color color}) _getColorForVicinity(TableVicinity vicinity) { + final int colorIndex = (vicinity.row / 3).floor(); + final MaterialColor primary = Colors.primaries[colorIndex]; + if (vicinity.column == 0) { + // Leading primary color + return ( + color: primary[500]!, + name: '${_getPrimaryNameFor(colorIndex)}, 500', + ); + } + final int leadingRow = colorIndex * 3; + final int middleRow = leadingRow + 1; + int? colorValue; + if (vicinity.row == leadingRow) { + colorValue = switch (vicinity.column) { + 1 => 50, + 2 => 100, + 3 => 200, + _ => throw AssertionError('This should be unreachable.'), + }; + } else if (vicinity.row == middleRow) { + colorValue = switch (vicinity.column) { + 1 => 300, + 2 => 400, + 3 => 600, + _ => throw AssertionError('This should be unreachable.'), + }; + } else { + // last row + colorValue = switch (vicinity.column) { + 1 => 700, + 2 => 800, + 3 => 900, + _ => throw AssertionError('This should be unreachable.'), + }; + } + return (color: primary[colorValue]!, name: colorValue.toString()); + } + + String _getPrimaryNameFor(int index) { + return switch (index) { + 0 => 'Red', + 1 => 'Pink', + 2 => 'Purple', + 3 => 'DeepPurple', + 4 => 'Indigo', + 5 => 'Blue', + 6 => 'LightBlue', + 7 => 'Cyan', + 8 => 'Teal', + 9 => 'Green', + 10 => 'LightGreen', + 11 => 'Lime', + 12 => 'Yellow', + 13 => 'Amber', + 14 => 'Orange', + 15 => 'DeepOrange', + 16 => 'Brown', + 17 => 'BlueGrey', + _ => throw AssertionError('This should be unreachable.'), + }; + } + + @override + Widget build(BuildContext context) { + final Size screenSize = MediaQuery.sizeOf(context); + return Scaffold( + body: Padding( + padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.15), + child: TableView.builder( + cellBuilder: _buildCell, + columnCount: 4, + pinnedColumnCount: 1, + columnBuilder: _buildColumnSpan, + rowCount: 51, // 17 primary colors * 3 rows each + rowBuilder: _buildRowSpan, + ), + ), + ); + } + + TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) { + final int colorIndex = (vicinity.row / 3).floor(); + final ({String name, Color color}) cell = _getColorForVicinity(vicinity); + final Color textColor = + ThemeData.estimateBrightnessForColor(cell.color) == Brightness.light + ? Colors.black + : Colors.white; + final TextStyle style = TextStyle( + color: textColor, + fontSize: 18.0, + fontWeight: vicinity.column == 0 ? FontWeight.bold : null, + ); + return TableViewCell( + rowMergeStart: vicinity.column == 0 ? colorIndex * 3 : null, + rowMergeSpan: vicinity.column == 0 ? 3 : null, + child: ColoredBox( + color: cell.color, + child: Center( + child: Text(cell.name, style: style), + ), + ), + ); + } + + TableSpan _buildColumnSpan(int index) { + return TableSpan( + extent: FixedTableSpanExtent(index == 0 ? 220 : 180), + foregroundDecoration: index == 0 + ? const TableSpanDecoration( + border: TableSpanBorder( + trailing: BorderSide( + width: 5, + color: Colors.white, + ), + ), + ) + : null, + ); + } + + TableSpan _buildRowSpan(int index) { + return TableSpan( + extent: const FixedTableSpanExtent(120), + padding: index % 3 == 0 ? const TableSpanPadding(leading: 5.0) : null, + ); + } +} diff --git a/packages/two_dimensional_scrollables/example/lib/table_view/simple_table.dart b/packages/two_dimensional_scrollables/example/lib/table_view/simple_table.dart new file mode 100644 index 000000000000..5e23bb20e959 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/lib/table_view/simple_table.dart @@ -0,0 +1,178 @@ +// Copyright 2013 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +// Print statements are only for illustrative purposes, not recommended for +// production applications. +// ignore_for_file: avoid_print + +/// The class containing the TableView for the sample application. +class TableExample extends StatefulWidget { + /// Creates a screen that demonstrates the TableView widget. + const TableExample({super.key}); + + @override + State createState() => _TableExampleState(); +} + +class _TableExampleState extends State { + late final ScrollController _verticalController = ScrollController(); + int _rowCount = 20; + + @override + void dispose() { + _verticalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 50.0), + child: TableView.builder( + verticalDetails: ScrollableDetails.vertical( + controller: _verticalController, + ), + cellBuilder: _buildCell, + columnCount: 20, + columnBuilder: _buildColumnSpan, + rowCount: _rowCount, + rowBuilder: _buildRowSpan, + ), + ), + persistentFooterButtons: [ + TextButton( + onPressed: () { + _verticalController.jumpTo(0); + }, + child: const Text('Jump to Top'), + ), + TextButton( + onPressed: () { + _verticalController.jumpTo( + _verticalController.position.maxScrollExtent, + ); + }, + child: const Text('Jump to Bottom'), + ), + TextButton( + onPressed: () { + setState(() { + _rowCount += 10; + }); + }, + child: const Text('Add 10 Rows'), + ), + ], + ); + } + + TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) { + return TableViewCell( + child: Center( + child: Text('Tile c: ${vicinity.column}, r: ${vicinity.row}'), + ), + ); + } + + TableSpan _buildColumnSpan(int index) { + const TableSpanDecoration decoration = TableSpanDecoration( + border: TableSpanBorder( + trailing: BorderSide(), + ), + ); + + switch (index % 5) { + case 0: + return TableSpan( + foregroundDecoration: decoration, + extent: const FixedTableSpanExtent(100), + onEnter: (_) => print('Entered column $index'), + recognizerFactories: { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (TapGestureRecognizer t) => + t.onTap = () => print('Tap column $index'), + ), + }, + ); + case 1: + return TableSpan( + foregroundDecoration: decoration, + extent: const FractionalTableSpanExtent(0.5), + onEnter: (_) => print('Entered column $index'), + cursor: SystemMouseCursors.contextMenu, + ); + case 2: + return TableSpan( + foregroundDecoration: decoration, + extent: const FixedTableSpanExtent(120), + onEnter: (_) => print('Entered column $index'), + ); + case 3: + return TableSpan( + foregroundDecoration: decoration, + extent: const FixedTableSpanExtent(145), + onEnter: (_) => print('Entered column $index'), + ); + case 4: + return TableSpan( + foregroundDecoration: decoration, + extent: const FixedTableSpanExtent(200), + onEnter: (_) => print('Entered column $index'), + ); + } + throw AssertionError( + 'This should be unreachable, as every index is accounted for in the ' + 'switch clauses.', + ); + } + + TableSpan _buildRowSpan(int index) { + final TableSpanDecoration decoration = TableSpanDecoration( + color: index.isEven ? Colors.purple[100] : null, + border: const TableSpanBorder( + trailing: BorderSide( + width: 3, + ), + ), + ); + + switch (index % 3) { + case 0: + return TableSpan( + backgroundDecoration: decoration, + extent: const FixedTableSpanExtent(50), + recognizerFactories: { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (TapGestureRecognizer t) => + t.onTap = () => print('Tap row $index'), + ), + }, + ); + case 1: + return TableSpan( + backgroundDecoration: decoration, + extent: const FixedTableSpanExtent(65), + cursor: SystemMouseCursors.click, + ); + case 2: + return TableSpan( + backgroundDecoration: decoration, + extent: const FractionalTableSpanExtent(0.15), + ); + } + throw AssertionError( + 'This should be unreachable, as every index is accounted for in the ' + 'switch clauses.', + ); + } +} diff --git a/packages/two_dimensional_scrollables/example/lib/table_view/table_explorer.dart b/packages/two_dimensional_scrollables/example/lib/table_view/table_explorer.dart new file mode 100644 index 000000000000..1536fa88fca4 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/lib/table_view/table_explorer.dart @@ -0,0 +1,113 @@ +// Copyright 2013 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 'infinite_table.dart'; +import 'merged_table.dart'; +import 'simple_table.dart'; + +/// The page containing the interactive controls that modify the sample +/// TableView. +class TableExplorer extends StatefulWidget { + /// Creates a screen that demonstrates the TableView widget in varying + /// configurations. + const TableExplorer({super.key}); + + @override + State createState() => _TableExplorerState(); +} + +/// Which example is being displayed. +enum TableType { + /// Displays TableExample. + simple, + + /// Displays MergedTableExample. + merged, + + /// Displays InfiniteTableExample. + infinite, +} + +class _TableExplorerState extends State { + final SizedBox _spacer = const SizedBox.square(dimension: 20.0); + TableType _currentExample = TableType.simple; + String _getTitle() { + return switch (_currentExample) { + TableType.simple => 'Simple TableView', + TableType.merged => 'Merged cells in TableView', + TableType.infinite => 'Infinite TableView', + }; + } + + Widget _getTable() { + return switch (_currentExample) { + TableType.simple => const TableExample(), + TableType.merged => const MergedTableExample(), + TableType.infinite => const InfiniteTableExample(), + }; + } + + Widget _getRadioRow() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const Spacer(), + Radio( + value: TableType.simple, + groupValue: _currentExample, + onChanged: (TableType? value) { + setState(() { + _currentExample = value!; + }); + }, + ), + const Text('Simple'), + _spacer, + Radio( + value: TableType.merged, + groupValue: _currentExample, + onChanged: (TableType? value) { + setState(() { + _currentExample = value!; + }); + }, + ), + const Text('Merged'), + _spacer, + Radio( + value: TableType.infinite, + groupValue: _currentExample, + onChanged: (TableType? value) { + setState(() { + _currentExample = value!; + }); + }, + ), + const Text('Infinite'), + const Spacer(), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_getTitle()), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(50), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: _getRadioRow(), + ), + ), + ), + body: _getTable(), + ); + } +} diff --git a/packages/two_dimensional_scrollables/example/lib/tree_view/custom_tree.dart b/packages/two_dimensional_scrollables/example/lib/tree_view/custom_tree.dart new file mode 100644 index 000000000000..2cbaf0417783 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/lib/tree_view/custom_tree.dart @@ -0,0 +1,282 @@ +// Copyright 2013 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +/// The class containing a TreeView that highlights the selected node. +/// The custom TreeView.treeNodeBuilder makes tapping the whole row of a parent +/// toggle the node open and closed with TreeView.toggleNodeWith. The +/// scrollbars will appear as the content exceeds the bounds of the viewport. +class CustomTreeExample extends StatefulWidget { + /// Creates a screen that demonstrates the TreeView widget. + const CustomTreeExample({super.key}); + + @override + State createState() => CustomTreeExampleState(); +} + +/// The state of the [CustomTreeExample]. +class CustomTreeExampleState extends State { + /// The [TreeViewController] associated with this [TreeView]. + @visibleForTesting + final TreeViewController treeController = TreeViewController(); + + /// The [ScrollController] associated with the vertical axis. + @visibleForTesting + final ScrollController verticalController = ScrollController(); + + TreeViewNode? _selectedNode; + final ScrollController _horizontalController = ScrollController(); + final List> _tree = >[ + TreeViewNode('README.md'), + TreeViewNode('analysis_options.yaml'), + TreeViewNode( + 'lib', + children: >[ + TreeViewNode( + 'src', + children: >[ + TreeViewNode( + 'common', + children: >[ + TreeViewNode('span.dart'), + ], + ), + TreeViewNode( + 'table_view', + children: >[ + TreeViewNode('table_cell.dart'), + TreeViewNode('table_delegate.dart'), + TreeViewNode('table_span.dart'), + TreeViewNode('table.dart'), + ], + ), + TreeViewNode( + 'tree_view', + children: >[ + TreeViewNode('render_tree.dart'), + TreeViewNode('tree_core.dart'), + TreeViewNode('tree_delegate.dart'), + TreeViewNode('tree_span.dart'), + TreeViewNode('tree.dart'), + ], + ), + ], + ), + TreeViewNode('two_dimensional_scrollables.dart'), + ], + ), + TreeViewNode('pubspec.lock'), + TreeViewNode('pubspec.yaml'), + TreeViewNode( + 'test', + children: >[ + TreeViewNode( + 'common', + children: >[ + TreeViewNode('span_test.dart'), + ], + ), + TreeViewNode( + 'table_view', + children: >[ + TreeViewNode('table_cell_test.dart'), + TreeViewNode('table_delegate_test.dart'), + TreeViewNode('table_span_test.dart'), + TreeViewNode('table_test.dart'), + ], + ), + TreeViewNode( + 'tree_view', + children: >[ + TreeViewNode('render_tree_test.dart'), + TreeViewNode('tree_core_test.dart'), + TreeViewNode('tree_delegate_test.dart'), + TreeViewNode('tree_span_test.dart'), + TreeViewNode('tree_test.dart'), + ], + ), + ], + ), + ]; + + Widget _treeNodeBuilder( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) { + final bool isParentNode = node.children.isNotEmpty; + final BorderSide border = BorderSide( + width: 2, + color: Colors.purple[300]!, + ); + // TRY THIS: TreeView.toggleNodeWith can be wrapped around any Widget (even + // the whole row) to trigger parent nodes to toggle opened and closed. + // Currently, the toggle is triggered in _getTapRecognizer below using the + // TreeViewController. + return 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()), + ], + ); + } + + Map _getTapRecognizer( + TreeViewNode node, + ) { + return { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (TapGestureRecognizer t) => t.onTap = () { + setState(() { + // Toggling the node here instead means any tap on the row can + // toggle parent nodes opened and closed. + treeController.toggleNode(node); + _selectedNode = node; + }); + }, + ), + }; + } + + Widget _getTree() { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(), + ), + child: Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + child: Scrollbar( + controller: verticalController, + thumbVisibility: true, + child: TreeView( + controller: treeController, + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: _horizontalController, + ), + tree: _tree, + onNodeToggle: (TreeViewNode node) { + setState(() { + _selectedNode = node as TreeViewNode; + }); + }, + treeNodeBuilder: _treeNodeBuilder, + treeRowBuilder: (TreeViewNode node) { + if (_selectedNode == (node as TreeViewNode)) { + return TreeRow( + extent: FixedTreeRowExtent( + node.children.isNotEmpty ? 60.0 : 50.0, + ), + recognizerFactories: _getTapRecognizer(node), + backgroundDecoration: TreeRowDecoration( + color: Colors.amber[100], + ), + foregroundDecoration: const TreeRowDecoration( + border: TreeRowBorder.all(BorderSide())), + ); + } + return TreeRow( + extent: FixedTreeRowExtent( + node.children.isNotEmpty ? 60.0 : 50.0, + ), + recognizerFactories: _getTapRecognizer(node), + ); + }, + // No internal indentation, the custom treeNodeBuilder applies its + // own indentation to decorate in the indented space. + indentation: TreeViewIndentationType.none, + ), + ), + ), + ); + } + + @override + void dispose() { + verticalController.dispose(); + _horizontalController.dispose(); + super.dispose(); + } + + @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: 25.0), + Text(_selectedNode!.content), + const Spacer(), + ]); + } + return Scaffold( + body: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 25.0), + child: Row(children: [ + SizedBox( + width: (screenSize.width - 50) / 2, + height: double.infinity, + child: _getTree(), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all(), + ), + child: SizedBox( + width: (screenSize.width - 50) / 2, + height: double.infinity, + child: Center( + child: Column( + children: selectedChildren, + ), + ), + ), + ), + ]), + ), + ), + ); + } +} diff --git a/packages/two_dimensional_scrollables/example/lib/tree_view/simple_tree.dart b/packages/two_dimensional_scrollables/example/lib/tree_view/simple_tree.dart new file mode 100644 index 000000000000..bb6c6767308b --- /dev/null +++ b/packages/two_dimensional_scrollables/example/lib/tree_view/simple_tree.dart @@ -0,0 +1,152 @@ +// Copyright 2013 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +/// The class containing a TreeView that highlights the selected row. The +/// default TreeView.treeNodeBuilder, makes tapping the leading icon of a parent +/// toggle the node open and closed. The scrollbars will appear as the content +/// exceeds the bounds of the viewport. +class TreeExample extends StatefulWidget { + /// Creates a screen that demonstrates the TreeView widget. + const TreeExample({super.key}); + + @override + State createState() => TreeExampleState(); +} + +/// The state of the [TreeExample]. +class TreeExampleState extends State { + /// The [TreeViewController] associated with this [TreeView]. + @visibleForTesting + final TreeViewController treeController = TreeViewController(); + + /// The [ScrollController] associated with the horizontal axis. + @visibleForTesting + final ScrollController horizontalController = ScrollController(); + TreeViewNode? _selectedNode; + final ScrollController _verticalController = ScrollController(); + final List> _tree = >[ + TreeViewNode( + "It's supercalifragilisticexpialidocious", + children: >[ + TreeViewNode( + 'Even though the sound of it is something quite atrocious', + ), + TreeViewNode( + "If you say it loud enough you'll always sound precocious", + ), + ], + ), + TreeViewNode( + 'Supercalifragilisticexpialidocious', + children: >[ + TreeViewNode( + 'Um-dittle-ittl-um-dittle-I', + children: >[ + TreeViewNode( + 'Um-dittle-ittl-um-dittle-I', + children: >[ + TreeViewNode( + 'Um-dittle-ittl-um-dittle-I', + children: >[ + TreeViewNode( + 'Um-dittle-ittl-um-dittle-I', + ), + ], + ), + ], + ), + ], + ), + ], + ), + ]; + + Map _getTapRecognizer( + TreeViewNode node, + ) { + return { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (TapGestureRecognizer t) => t.onTap = () { + setState(() { + _selectedNode = node; + }); + }, + ), + }; + } + + Widget _getTree() { + return DecoratedBox( + decoration: BoxDecoration(border: Border.all()), + child: Scrollbar( + controller: horizontalController, + thumbVisibility: true, + child: Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: TreeView( + controller: treeController, + verticalDetails: ScrollableDetails.vertical( + controller: _verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + tree: _tree, + onNodeToggle: (TreeViewNode node) { + setState(() { + _selectedNode = node as TreeViewNode; + }); + }, + treeRowBuilder: (TreeViewNode node) { + if (_selectedNode == (node as TreeViewNode)) { + return TreeView.defaultTreeRowBuilder(node).copyWith( + recognizerFactories: _getTapRecognizer(node), + backgroundDecoration: TreeRowDecoration( + color: Colors.purple[100], + ), + ); + } + return TreeView.defaultTreeRowBuilder(node).copyWith( + recognizerFactories: _getTapRecognizer(node), + ); + }, + // Exaggerated indentation to exceed viewport bounds. + indentation: TreeViewIndentationType.custom(50.0), + ), + ), + ), + ); + } + + @override + void dispose() { + _verticalController.dispose(); + horizontalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Size screenSize = MediaQuery.sizeOf(context); + return Scaffold( + body: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: screenSize.width * 0.25, + vertical: 25.0, + ), + child: _getTree(), + ), + ), + ); + } +} diff --git a/packages/two_dimensional_scrollables/example/lib/tree_view/tree_explorer.dart b/packages/two_dimensional_scrollables/example/lib/tree_view/tree_explorer.dart new file mode 100644 index 000000000000..ceee073685be --- /dev/null +++ b/packages/two_dimensional_scrollables/example/lib/tree_view/tree_explorer.dart @@ -0,0 +1,96 @@ +// Copyright 2013 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 'custom_tree.dart'; +import 'simple_tree.dart'; + +/// The page containing the interactive controls that modify the sample +/// TreeView. +class TreeExplorer extends StatefulWidget { + /// Creates a screen that demonstrates the TreeView widget in varying + /// configurations. + const TreeExplorer({super.key}); + + @override + State createState() => _TreeExplorerState(); +} + +/// Which example is being displayed. +enum TreeType { + /// Displays TreeExample. + simple, + + /// Displays CustomTreeExample. + custom, +} + +class _TreeExplorerState extends State { + final SizedBox _spacer = const SizedBox.square(dimension: 20.0); + TreeType _currentExample = TreeType.simple; + String _getTitle() { + return switch (_currentExample) { + TreeType.simple => 'Simple TreeView', + TreeType.custom => 'Customizing TreeView', + }; + } + + Widget _getTree() { + return switch (_currentExample) { + TreeType.simple => const TreeExample(), + TreeType.custom => const CustomTreeExample(), + }; + } + + Widget _getRadioRow() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const Spacer(), + Radio( + value: TreeType.simple, + groupValue: _currentExample, + onChanged: (TreeType? value) { + setState(() { + _currentExample = value!; + }); + }, + ), + const Text('Simple'), + _spacer, + Radio( + value: TreeType.custom, + groupValue: _currentExample, + onChanged: (TreeType? value) { + setState(() { + _currentExample = value!; + }); + }, + ), + const Text('Custom'), + const Spacer(), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_getTitle()), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(50), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: _getRadioRow(), + ), + ), + ), + body: _getTree(), + ); + } +} diff --git a/packages/two_dimensional_scrollables/example/macos/Runner.xcodeproj/project.pbxproj b/packages/two_dimensional_scrollables/example/macos/Runner.xcodeproj/project.pbxproj index 27e0f506b609..7d9ca676a43d 100644 --- a/packages/two_dimensional_scrollables/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/two_dimensional_scrollables/example/macos/Runner.xcodeproj/project.pbxproj @@ -227,7 +227,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { diff --git a/packages/two_dimensional_scrollables/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/two_dimensional_scrollables/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 397f3d339fde..15368eccb25a 100644 --- a/packages/two_dimensional_scrollables/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/two_dimensional_scrollables/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ Bool { return true diff --git a/packages/two_dimensional_scrollables/example/pubspec.yaml b/packages/two_dimensional_scrollables/example/pubspec.yaml index 675a9c3991f1..864ffc566cff 100644 --- a/packages/two_dimensional_scrollables/example/pubspec.yaml +++ b/packages/two_dimensional_scrollables/example/pubspec.yaml @@ -1,13 +1,13 @@ -name: table_view_example -description: 'A sample application that uses TableView' +name: two_dimensional_examples +description: 'A sample application that uses TableView and TreeView' publish_to: 'none' # The following defines the version and build number for your application. -version: 1.0.0+1 +version: 2.0.0 environment: - sdk: '>=3.2.0 <4.0.0' - flutter: ">=3.16.0" + sdk: '>=3.3.0 <4.0.0' + flutter: ">=3.19.0" dependencies: flutter: diff --git a/packages/two_dimensional_scrollables/example/test/main_test.dart b/packages/two_dimensional_scrollables/example/test/main_test.dart new file mode 100644 index 000000000000..b5cacfeccccc --- /dev/null +++ b/packages/two_dimensional_scrollables/example/test/main_test.dart @@ -0,0 +1,27 @@ +// Copyright 2013 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_test/flutter_test.dart'; +import 'package:two_dimensional_examples/main.dart'; + +void main() { + testWidgets('Main builds & buttons work', (WidgetTester tester) async { + await tester.pumpWidget(const ExampleApp()); + expect(find.text('TableView Explorer'), findsOneWidget); + expect(find.text('TreeView Explorer'), findsOneWidget); + + await tester.tap(find.text('TableView Explorer')); + await tester.pumpAndSettle(); + // First example on the TableView page. + expect(find.text('Simple TableView'), findsOneWidget); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Simple TableView'), findsNothing); + await tester.tap(find.text('TreeView Explorer')); + await tester.pumpAndSettle(); + // First example on the TreeView page. + expect(find.text('Simple TreeView'), findsOneWidget); + }); +} diff --git a/packages/two_dimensional_scrollables/example/test/table_view/infinite_table_test.dart b/packages/two_dimensional_scrollables/example/test/table_view/infinite_table_test.dart new file mode 100644 index 000000000000..1f0f5e6b676f --- /dev/null +++ b/packages/two_dimensional_scrollables/example/test/table_view/infinite_table_test.dart @@ -0,0 +1,74 @@ +// Copyright 2013 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_test/flutter_test.dart'; +import 'package:two_dimensional_examples/table_view/infinite_table.dart'; + +void main() { + testWidgets('Builds, can scroll, buttons work', (WidgetTester tester) async { + tester.view.physicalSize = const Size.square(800.0); + await tester.pumpWidget(const MaterialApp(home: InfiniteTableExample())); + await tester.pump(); + expect(find.text('0:0'), findsOneWidget); + + final Finder verticalScrollable = find.byWidgetPredicate((Widget widget) { + if (widget is Scrollable) { + return widget.axisDirection == AxisDirection.down; + } + return false; + }); + final ScrollPosition verticalPosition = + (tester.state(verticalScrollable) as ScrollableState).position; + + final Finder horizontalScrollable = find.byWidgetPredicate((Widget widget) { + if (widget is Scrollable) { + return widget.axisDirection == AxisDirection.right; + } + return false; + }); + final ScrollPosition horizontalPosition = + (tester.state(horizontalScrollable) as ScrollableState).position; + + expect(verticalPosition.pixels, 0.0); + expect(verticalPosition.maxScrollExtent, double.infinity); + expect(horizontalPosition.pixels, 0.0); + expect(horizontalPosition.maxScrollExtent, double.infinity); + verticalPosition.jumpTo(300.0); + horizontalPosition.jumpTo(20.0); + await tester.pump(); + expect(verticalPosition.pixels, 300.0); + expect(verticalPosition.maxScrollExtent, double.infinity); + expect(horizontalPosition.pixels, 20.0); + expect(horizontalPosition.maxScrollExtent, double.infinity); + + await tester.tap(find.text('Make rows fixed')); + await tester.pump(); + expect(verticalPosition.pixels, 300.0); + expect(verticalPosition.maxScrollExtent, lessThan(5000)); + expect(horizontalPosition.pixels, 20.0); + expect(horizontalPosition.maxScrollExtent, double.infinity); + + await tester.tap(find.text('Make columns fixed')); + await tester.pump(); + expect(verticalPosition.pixels, 300.0); + expect(verticalPosition.maxScrollExtent, lessThan(5000)); + expect(horizontalPosition.pixels, 20.0); + expect(horizontalPosition.maxScrollExtent, lessThan(4800)); + + await tester.tap(find.text('Make rows infinite')); + await tester.pump(); + expect(verticalPosition.pixels, 300.0); + expect(verticalPosition.maxScrollExtent, double.infinity); + expect(horizontalPosition.pixels, 20.0); + expect(horizontalPosition.maxScrollExtent, lessThan(4800)); + + await tester.tap(find.text('Make columns infinite')); + await tester.pump(); + expect(verticalPosition.pixels, 300.0); + expect(verticalPosition.maxScrollExtent, double.infinity); + expect(horizontalPosition.pixels, 20.0); + expect(horizontalPosition.maxScrollExtent, double.infinity); + }); +} diff --git a/packages/two_dimensional_scrollables/example/test/table_view/merged_table_test.dart b/packages/two_dimensional_scrollables/example/test/table_view/merged_table_test.dart new file mode 100644 index 000000000000..b00c8b1ec871 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/test/table_view/merged_table_test.dart @@ -0,0 +1,45 @@ +// Copyright 2013 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_test/flutter_test.dart'; +import 'package:two_dimensional_examples/table_view/merged_table.dart'; + +void main() { + testWidgets('Builds and can scroll', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: MergedTableExample())); + await tester.pump(); + expect(find.text('Red, 500'), findsOneWidget); + + final Finder verticalScrollable = find.byWidgetPredicate((Widget widget) { + if (widget is Scrollable) { + return widget.axisDirection == AxisDirection.down; + } + return false; + }); + final ScrollPosition verticalPosition = + (tester.state(verticalScrollable) as ScrollableState).position; + + final Finder horizontalScrollable = find.byWidgetPredicate((Widget widget) { + if (widget is Scrollable) { + return widget.axisDirection == AxisDirection.right; + } + return false; + }); + final ScrollPosition horizontalPosition = + (tester.state(horizontalScrollable) as ScrollableState).position; + + expect(verticalPosition.pixels, 0.0); + expect(verticalPosition.maxScrollExtent, 5605.0); + expect(horizontalPosition.pixels, 0.0); + expect(horizontalPosition.maxScrollExtent, 200.0); + verticalPosition.jumpTo(300.0); + horizontalPosition.jumpTo(20.0); + await tester.pump(); + expect(verticalPosition.pixels, 300.0); + expect(verticalPosition.maxScrollExtent, 5605.0); + expect(horizontalPosition.pixels, 20.0); + expect(horizontalPosition.maxScrollExtent, 200.0); + }); +} diff --git a/packages/two_dimensional_scrollables/example/test/table_view_example_test.dart b/packages/two_dimensional_scrollables/example/test/table_view/simple_table_test.dart similarity index 89% rename from packages/two_dimensional_scrollables/example/test/table_view_example_test.dart rename to packages/two_dimensional_scrollables/example/test/table_view/simple_table_test.dart index f5cfee647663..0186c4d68ba0 100644 --- a/packages/two_dimensional_scrollables/example/test/table_view_example_test.dart +++ b/packages/two_dimensional_scrollables/example/test/table_view/simple_table_test.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:table_view_example/main.dart'; +import 'package:two_dimensional_examples/table_view/simple_table.dart'; void main() { testWidgets('Example app builds & scrolls', (WidgetTester tester) async { - await tester.pumpWidget(const TableExampleApp()); + await tester.pumpWidget(const MaterialApp(home: TableExample())); await tester.pump(); expect(find.text('Jump to Top'), findsOneWidget); @@ -31,7 +31,7 @@ void main() { }); testWidgets('Example app buttons work', (WidgetTester tester) async { - await tester.pumpWidget(const TableExampleApp()); + await tester.pumpWidget(const MaterialApp(home: TableExample())); await tester.pump(); final Finder scrollable = find.byWidgetPredicate((Widget widget) { diff --git a/packages/two_dimensional_scrollables/example/test/table_view/table_explorer_test.dart b/packages/two_dimensional_scrollables/example/test/table_view/table_explorer_test.dart new file mode 100644 index 000000000000..3de3b4204f7d --- /dev/null +++ b/packages/two_dimensional_scrollables/example/test/table_view/table_explorer_test.dart @@ -0,0 +1,39 @@ +// Copyright 2013 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_test/flutter_test.dart'; +import 'package:two_dimensional_examples/table_view/infinite_table.dart'; +import 'package:two_dimensional_examples/table_view/merged_table.dart'; +import 'package:two_dimensional_examples/table_view/simple_table.dart'; +import 'package:two_dimensional_examples/table_view/table_explorer.dart'; + +void main() { + testWidgets('Table explorer switches between samples', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: TableExplorer())); + await tester.pumpAndSettle(); + // The first example + expect(find.byType(TableExample), findsOneWidget); + expect(find.byType(MergedTableExample), findsNothing); + expect(find.byType(InfiniteTableExample), findsNothing); + expect(find.byType(Radio), findsNWidgets(3)); + final Finder buttons = find.byType(Radio); + await tester.tap(buttons.at(1)); + await tester.pumpAndSettle(); + expect(find.byType(TableExample), findsNothing); + expect(find.byType(MergedTableExample), findsOneWidget); + expect(find.byType(InfiniteTableExample), findsNothing); + await tester.tap(buttons.at(2)); + await tester.pumpAndSettle(); + expect(find.byType(TableExample), findsNothing); + expect(find.byType(MergedTableExample), findsNothing); + expect(find.byType(InfiniteTableExample), findsOneWidget); + await tester.tap(buttons.at(0)); + await tester.pumpAndSettle(); + expect(find.byType(TableExample), findsOneWidget); + expect(find.byType(MergedTableExample), findsNothing); + expect(find.byType(InfiniteTableExample), findsNothing); + }); +} diff --git a/packages/two_dimensional_scrollables/example/test/tree_view/custom_tree_test.dart b/packages/two_dimensional_scrollables/example/test/tree_view/custom_tree_test.dart new file mode 100644 index 000000000000..4146fb3f544b --- /dev/null +++ b/packages/two_dimensional_scrollables/example/test/tree_view/custom_tree_test.dart @@ -0,0 +1,69 @@ +// Copyright 2013 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_test/flutter_test.dart'; +import 'package:two_dimensional_examples/tree_view/custom_tree.dart'; + +void main() { + testWidgets('Example builds and can be interacted with', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: CustomTreeExample())); + await tester.pumpAndSettle(); + expect(find.text('README.md'), findsOneWidget); + expect(find.text('common'), findsNothing); + await tester.tap(find.byType(Icon).last); + await tester.pumpAndSettle(); + expect(find.text('common'), findsOneWidget); + }); + + testWidgets('Can scroll', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: CustomTreeExample())); + await tester.pumpAndSettle(); + + final Finder verticalScrollable = find.byWidgetPredicate((Widget widget) { + if (widget is Scrollable) { + return widget.axisDirection == AxisDirection.down; + } + return false; + }); + ScrollPosition verticalPosition = + (tester.state(verticalScrollable) as ScrollableState).position; + + expect(verticalPosition.maxScrollExtent, 0.0); + expect(verticalPosition.pixels, 0.0); + + final CustomTreeExampleState state = tester.state( + find.byType(CustomTreeExample), + ) as CustomTreeExampleState; + + state.treeController.toggleNode(state.treeController.getNodeFor('lib')!); + await tester.pumpAndSettle(); + verticalPosition = + (tester.state(verticalScrollable) as ScrollableState).position; + expect(verticalPosition.maxScrollExtent, 0.0); + expect(verticalPosition.pixels, 0.0); + state.treeController.toggleNode(state.treeController.getNodeFor('test')!); + await tester.pumpAndSettle(); + + verticalPosition = + (tester.state(verticalScrollable) as ScrollableState).position; + + expect(verticalPosition.maxScrollExtent, 10.0); + expect(verticalPosition.pixels, 0.0); + state.treeController.toggleNode(state.treeController.getNodeFor('src')!); + await tester.pumpAndSettle(); + + verticalPosition = + (tester.state(verticalScrollable) as ScrollableState).position; + + // Enough nodes expanded to allow us to scroll + expect(verticalPosition.maxScrollExtent, 190.0); + expect(verticalPosition.pixels, 0.0); + state.verticalController.jumpTo(10.0); + await tester.pumpAndSettle(); + expect(verticalPosition.maxScrollExtent, 190.0); + expect(verticalPosition.pixels, 10.0); + }); +} diff --git a/packages/two_dimensional_scrollables/example/test/tree_view/simple_tree_test.dart b/packages/two_dimensional_scrollables/example/test/tree_view/simple_tree_test.dart new file mode 100644 index 000000000000..6c15316923dc --- /dev/null +++ b/packages/two_dimensional_scrollables/example/test/tree_view/simple_tree_test.dart @@ -0,0 +1,60 @@ +// Copyright 2013 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_test/flutter_test.dart'; +import 'package:two_dimensional_examples/tree_view/simple_tree.dart'; + +void main() { + testWidgets('Example builds and can be interacted with', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: TreeExample())); + await tester.pumpAndSettle(); + expect( + find.text("It's supercalifragilisticexpialidocious"), + findsOneWidget, + ); + expect( + find.text('Um-dittle-ittl-um-dittle-I'), + findsNothing, + ); + await tester.tap(find.byType(Icon).last); + await tester.pumpAndSettle(); + expect( + find.text('Um-dittle-ittl-um-dittle-I'), + findsOneWidget, + ); + }); + + testWidgets('Can scroll ', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: TreeExample())); + await tester.pumpAndSettle(); + final Finder horizontalScrollable = find.byWidgetPredicate((Widget widget) { + if (widget is Scrollable) { + return widget.axisDirection == AxisDirection.right; + } + return false; + }); + ScrollPosition horizontalPosition = + (tester.state(horizontalScrollable) as ScrollableState).position; + + expect(horizontalPosition.maxScrollExtent, greaterThan(190)); + expect(horizontalPosition.pixels, 0.0); + final TreeExampleState state = tester.state( + find.byType(TreeExample), + ) as TreeExampleState; + + state.treeController.expandAll(); + await tester.pumpAndSettle(); + horizontalPosition = + (tester.state(horizontalScrollable) as ScrollableState).position; + // Expanding all of the node increased the max extent. + expect(horizontalPosition.maxScrollExtent, 502.0); + expect(horizontalPosition.pixels, 0.0); + state.horizontalController.jumpTo(10.0); + await tester.pumpAndSettle(); + expect(horizontalPosition.maxScrollExtent, 502.0); + expect(horizontalPosition.pixels, 10.0); + }); +} diff --git a/packages/two_dimensional_scrollables/example/test/tree_view/tree_explorer_test.dart b/packages/two_dimensional_scrollables/example/test/tree_view/tree_explorer_test.dart new file mode 100644 index 000000000000..d9be8e93cc66 --- /dev/null +++ b/packages/two_dimensional_scrollables/example/test/tree_view/tree_explorer_test.dart @@ -0,0 +1,29 @@ +// Copyright 2013 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_test/flutter_test.dart'; +import 'package:two_dimensional_examples/tree_view/custom_tree.dart'; +import 'package:two_dimensional_examples/tree_view/simple_tree.dart'; +import 'package:two_dimensional_examples/tree_view/tree_explorer.dart'; + +void main() { + testWidgets('Tree explorer switches between samples', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: TreeExplorer())); + await tester.pumpAndSettle(); + // The first example + expect(find.byType(TreeExample), findsOneWidget); + expect(find.byType(CustomTreeExample), findsNothing); + expect(find.byType(Radio), findsNWidgets(2)); + await tester.tap(find.byType(Radio).last); + await tester.pumpAndSettle(); + expect(find.byType(TreeExample), findsNothing); + expect(find.byType(CustomTreeExample), findsOneWidget); + await tester.tap(find.byType(Radio).first); + await tester.pumpAndSettle(); + expect(find.byType(TreeExample), findsOneWidget); + expect(find.byType(CustomTreeExample), findsNothing); + }); +} diff --git a/packages/two_dimensional_scrollables/lib/src/common/span.dart b/packages/two_dimensional_scrollables/lib/src/common/span.dart index d2e88fc4aca9..fcf4e0070e33 100644 --- a/packages/two_dimensional_scrollables/lib/src/common/span.dart +++ b/packages/two_dimensional_scrollables/lib/src/common/span.dart @@ -63,6 +63,30 @@ class Span { this.foregroundDecoration, }) : padding = padding ?? const SpanPadding(); + /// Create a clone of the current [Span] but with provided + /// parameters overridden. + Span copyWith({ + SpanExtent? extent, + SpanPadding? padding, + Map? recognizerFactories, + PointerEnterEventListener? onEnter, + PointerExitEventListener? onExit, + MouseCursor? cursor, + SpanDecoration? backgroundDecoration, + SpanDecoration? foregroundDecoration, + }) { + return Span( + extent: extent ?? this.extent, + padding: padding ?? this.padding, + recognizerFactories: recognizerFactories ?? this.recognizerFactories, + onEnter: onEnter ?? this.onEnter, + onExit: onExit ?? this.onExit, + cursor: cursor ?? this.cursor, + backgroundDecoration: backgroundDecoration ?? this.backgroundDecoration, + foregroundDecoration: foregroundDecoration ?? this.foregroundDecoration, + ); + } + /// Defines the extent of the span. /// /// If the span represents a row, this is the height of the row. If it diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart new file mode 100644 index 000000000000..ab00f7cf7fa7 --- /dev/null +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart @@ -0,0 +1,728 @@ +// Copyright 2013 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:collection' show LinkedHashMap; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'tree_core.dart'; +import 'tree_delegate.dart'; +import 'tree_span.dart'; + +// Used during paint to delineate animating portions of the tree. +typedef _PaintSegment = ({int leadingIndex, int trailingIndex}); + +/// A render object for viewing [RenderBox]es in a tree format that extends in +/// both the horizontal and vertical dimensions. +/// +/// [RenderTreeViewport] is the visual workhorse of the [TreeView]. It +/// displays a subset of its [TreeViewNode] rows according to its own dimensions +/// and the given [verticalOffset] and [horizontalOffset]. As the offset varies, +/// different nodes are visible through the viewport. +class RenderTreeViewport extends RenderTwoDimensionalViewport { + /// Creates a viewport for [RenderBox] objects in a tree format of rows. + RenderTreeViewport({ + required Map activeAnimations, + required Map rowDepths, + required double indentation, + required super.horizontalOffset, + required super.horizontalAxisDirection, + required super.verticalOffset, + required super.verticalAxisDirection, + required TreeRowDelegateMixin super.delegate, + required super.childManager, + super.cacheExtent, + super.clipBehavior, + }) : _activeAnimations = activeAnimations, + _rowDepths = rowDepths, + _indentation = indentation, + assert(indentation >= 0), + assert(verticalAxisDirection == AxisDirection.down && + horizontalAxisDirection == AxisDirection.right), + // This is fixed as there is currently only one traversal pattern, https://github.com/flutter/flutter/issues/148357 + super(mainAxis: Axis.vertical); + + @override + TreeRowDelegateMixin get delegate => super.delegate as TreeRowDelegateMixin; + @override + set delegate(TreeRowDelegateMixin value) { + super.delegate = value; + } + + /// The currently active [TreeViewNode] 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 { + return _activeAnimations; + } + + Map _activeAnimations; + set activeAnimations(Map value) { + if (_activeAnimations == value) { + return; + } + _activeAnimations = value; + markNeedsLayout(withDelegateRebuild: true); + } + + /// The depth of each currently active node in the tree. + /// + /// This is used to properly set the [TreeVicinity]. + Map get rowDepths => _rowDepths; + Map _rowDepths; + set rowDepths(Map value) { + if (_rowDepths == value) { + return; + } + _rowDepths = value; + markNeedsLayout(); + } + + /// The number of pixels by which child nodes will be offset in the cross axis + /// based on [rowDepths]. + /// + /// If zero, children can alternatively be offset in [TreeView.treeRowBuilder] + /// 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(); + } + + // Cached metrics + Map _rowMetrics = {}; + int? _firstRow; + int? _lastRow; + double _furthestHorizontalExtent = 0.0; + // How far rows should be laid out in a given frame. + double get _targetRowPixel { + return cacheExtent + verticalOffset.pixels + viewportDimension.height; + } + + // Whether or not there is visual overflow in the viewport. + bool get _hasVisualOverflow => _verticalOverflows || _horizontalOverflows; + bool _verticalOverflows = false; + bool _horizontalOverflows = false; + + // Since the index of animating children can change at anytime, we use a + // UniqueKey to track them during the lifetime of the animation. + // 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 = {}; + // Updates the cache at the start of eah layout pass. + void _updateAnimationCache() { + _animationLeadingIndices.clear(); + _activeAnimations.forEach( + (UniqueKey key, TreeViewNodesAnimation animation) { + _animationLeadingIndices[animation.fromIndex] = key; + }, + ); + // Remove any stored offsets or clip layers that are no longer actively + // animating. + _animationOffsets.removeWhere((UniqueKey key, _) { + return !_activeAnimations.keys.contains(key); + }); + _clipHandles.removeWhere( + (UniqueKey key, LayerHandle handle) { + if (!_activeAnimations.keys.contains(key)) { + handle.layer = null; + return true; + } + return false; + }, + ); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + RenderBox? row = firstChild; + while (row != null) { + final TwoDimensionalViewportParentData parentData = parentDataOf(row); + if (!parentData.isVisible) { + // This row is not visible, so it cannot be hit. + row = childAfter(row); + continue; + } + final Rect rowRect = parentData.paintOffset! & + Size(viewportDimension.width, row.size.height); + if (rowRect.contains(position)) { + result.addWithPaintOffset( + offset: parentData.paintOffset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - parentData.paintOffset!); + return row!.hitTest(result, position: transformed); + }, + ); + result.add( + HitTestEntry(_rowMetrics[parentData.vicinity.yIndex]!), + ); + return true; + } + row = childAfter(row); + } + return false; + } + + @override + void dispose() { + _clipHandles.removeWhere( + (UniqueKey key, LayerHandle handle) { + handle.layer = null; + return true; + }, + ); + for (final _Span span in _rowMetrics.values) { + span.dispose(); + } + super.dispose(); + } + + void _computeAnimationOffsetFor(UniqueKey key, double position) { + // `position` represents the trailing edge of the parent node that initiated + // the animation. + assert(_activeAnimations[key] != null); + 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 animating children would fit on the screen. + while (currentIndex <= lastIndex && currentPosition < _targetRowPixel) { + _Span? span = _rowMetrics.remove(currentIndex); + assert(needsDelegateRebuild || span != null); + final TreeRow configuration = needsDelegateRebuild + ? delegate.buildRow(TreeVicinity( + depth: _rowDepths[currentIndex]!, + row: currentIndex, + )) + : span!.configuration; + span ??= _Span(); + final double extent = configuration.extent.calculateExtent( + TreeRowExtentDelegate( + viewportExtent: viewportDimension.height, + precedingExtent: position, + ), + ); + totalAnimatingOffset += extent; + currentPosition += extent; + 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; + } + + void _updateRowMetrics() { + assert(needsDelegateRebuild || didResize); + _firstRow = null; + _lastRow = null; + double totalAnimationOffset = 0.0; + double startOfRow = 0; + final Map newRowMetrics = {}; + for (int row = 0; row < delegate.rowCount; row++) { + final double leadingOffset = startOfRow; + _Span? span = _rowMetrics.remove(row); + assert(needsDelegateRebuild || span != null); + final TreeRow configuration = needsDelegateRebuild + ? delegate.buildRow(TreeVicinity( + depth: _rowDepths[row]!, + row: row, + )) + : span!.configuration; + span ??= _Span(); + final double extent = configuration.extent.calculateExtent( + TreeRowExtentDelegate( + viewportExtent: viewportDimension.height, + precedingExtent: leadingOffset, + ), + ); + if (_animationLeadingIndices.keys.contains(row)) { + final UniqueKey animationKey = _animationLeadingIndices[row]!; + if (_animationOffsets[animationKey] == null) { + // We have not computed the distance this block is traversing over the + // lifetime of the animation. + _computeAnimationOffsetFor(animationKey, startOfRow); + } + // We add the offset accounting for the animation value. + totalAnimationOffset += _animationOffsets[animationKey]! * + (1 - _activeAnimations[animationKey]!.value); + } + span.update( + configuration: configuration, + leadingOffset: leadingOffset, + extent: extent, + animationOffset: totalAnimationOffset, + ); + newRowMetrics[row] = span; + if (span.trailingOffset >= verticalOffset.pixels && _firstRow == null) { + _firstRow = row; + } + if (span.trailingOffset - totalAnimationOffset >= _targetRowPixel && + _lastRow == null) { + _lastRow = row; + } + startOfRow = span.trailingOffset; + totalAnimationOffset = 0.0; + } + for (final _Span span in _rowMetrics.values) { + span.dispose(); + } + _rowMetrics = newRowMetrics; + if (_firstRow != null) { + _lastRow ??= _rowMetrics.length - 1; + } + } + + void _updateFirstAndLastVisibleRow() { + _firstRow = null; + _lastRow = null; + for (int row = 0; row < _rowMetrics.length; row++) { + final double endOfRow = _rowMetrics[row]!.trailingOffset; + if (endOfRow >= verticalOffset.pixels && _firstRow == null) { + _firstRow = row; + } + if (endOfRow >= _targetRowPixel && _lastRow == null) { + _lastRow = row; + break; + } + } + if (_firstRow != null) { + _lastRow ??= _rowMetrics.length - 1; + } + } + + void _updateScrollBounds() { + final double maxHorizontalExtent = math.max( + 0.0, + _furthestHorizontalExtent - viewportDimension.width, + ); + _horizontalOverflows = maxHorizontalExtent > 0.0; + + final double maxVerticalExtent = math.max( + 0.0, + _rowMetrics[_lastRow!]!.trailingOffset - viewportDimension.height, + ); + _verticalOverflows = maxVerticalExtent > 0.0; + + final bool acceptedDimension = horizontalOffset.applyContentDimensions( + 0.0, + maxHorizontalExtent, + ) && + verticalOffset.applyContentDimensions( + 0.0, + maxVerticalExtent, + ); + + if (!acceptedDimension) { + _updateFirstAndLastVisibleRow(); + } + } + + @override + void layoutChildSequence() { + _updateAnimationCache(); + if (needsDelegateRebuild || didResize) { + // Recomputes the tree row metrics, invalidates any cached information. + _furthestHorizontalExtent = 0.0; + _updateRowMetrics(); + } else { + // Updates the visible rows based on cached _rowMetrics. + _updateFirstAndLastVisibleRow(); + } + + if (_firstRow == null) { + assert(_lastRow == null); + return; + } + assert(_firstRow != null && _lastRow != null); + + _Span rowSpan; + double rowOffset = + -verticalOffset.pixels + _rowMetrics[_firstRow!]!.leadingOffset; + for (int row = _firstRow!; row <= _lastRow!; row++) { + rowSpan = _rowMetrics[row]!; + final double rowHeight = rowSpan.extent; + if (_animationLeadingIndices.keys.contains(row)) { + rowOffset -= rowSpan.animationOffset; + } + rowOffset += rowSpan.configuration.padding.leading; + + final TreeVicinity vicinity = TreeVicinity( + depth: _rowDepths[row]!, + row: row, + ); + final RenderBox child = buildOrObtainChildFor(vicinity)!; + final TwoDimensionalViewportParentData parentData = parentDataOf(child); + final BoxConstraints childConstraints = BoxConstraints( + minHeight: rowHeight, + maxHeight: rowHeight, + // Width is allowed to be unbounded. + ); + child.layout(childConstraints, parentUsesSize: true); + parentData.layoutOffset = Offset( + (_rowDepths[row]! * indentation) - horizontalOffset.pixels, + rowOffset, + ); + rowOffset += rowHeight + rowSpan.configuration.padding.trailing; + _furthestHorizontalExtent = math.max( + parentData.layoutOffset!.dx + child.size.width, + _furthestHorizontalExtent, + ); + } + _updateScrollBounds(); + } + + // Maps the UniqueKey associated with animating node segments with the clip + // LayerHandle. + final Map> _clipHandles = + >{}; + // Used as the UniqueKey for the viewport or leading segment that does not + // have an animation key. When we are not animating, this clips the viewport + // bounds if there is visual overflow. When we are animating, it clips the + // leading segment if there is visual overflow. + final UniqueKey _viewportClipKey = UniqueKey(); + + @override + void paint(PaintingContext context, Offset offset) { + if (_firstRow == null) { + assert(_lastRow == null); + return; + } + assert(_firstRow != null && _lastRow != null); + + if (_animationLeadingIndices.isEmpty) { + // There are no animations running. Clip only if there is visual overflow. + if (_hasVisualOverflow && clipBehavior != Clip.none) { + _clipHandles[_viewportClipKey] ??= LayerHandle(); + _clipHandles[_viewportClipKey]!.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + (PaintingContext context, Offset offset) { + _paintRows( + context, + offset, + leadingRow: _firstRow!, + trailingRow: _lastRow!, + ); + }, + clipBehavior: clipBehavior, + oldLayer: _clipHandles[_viewportClipKey]!.layer, + ); + } else { + _clipHandles[_viewportClipKey]?.layer = null; + _paintRows( + context, + offset, + leadingRow: _firstRow!, + trailingRow: _lastRow!, + ); + } + return; + } + + // We are animating. + // Separate animating segments to clip for any overlap. + int leadingIndex = _firstRow!; + 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 - 1, + )); + leadingIndex = trailingIndex; + } + paintSegments.add((leadingIndex: leadingIndex, trailingIndex: _lastRow!)); + + // Paint, clipping for all but the first segment, unless there is visual + // overflow. + final _PaintSegment firstSegment = paintSegments.removeAt(0); + if (_hasVisualOverflow && clipBehavior != Clip.none) { + _clipHandles[_viewportClipKey] ??= LayerHandle(); + _clipHandles[_viewportClipKey]!.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + (PaintingContext context, Offset offset) { + _paintRows( + context, + offset, + leadingRow: firstSegment.leadingIndex, + trailingRow: firstSegment.trailingIndex, + ); + }, + clipBehavior: clipBehavior, + oldLayer: _clipHandles[_viewportClipKey]!.layer, + ); + } else { + _clipHandles[_viewportClipKey]?.layer = null; + _paintRows( + context, + offset, + leadingRow: firstSegment.leadingIndex, + trailingRow: firstSegment.trailingIndex, + ); + } + // Paint the rest with clip layers. + while (paintSegments.isNotEmpty) { + final _PaintSegment segment = paintSegments.removeAt(0); + final int parentIndex = segment.leadingIndex - 1; + final double leadingOffset = _rowMetrics[parentIndex]!.trailingOffset; + final double trailingOffset = + _rowMetrics[segment.trailingIndex]!.trailingOffset; + final Rect rect = Rect.fromPoints( + Offset(0.0, leadingOffset - verticalOffset.pixels), + Offset( + viewportDimension.width, + math.min( + trailingOffset - verticalOffset.pixels, + viewportDimension.height, + ), + ), + ); + // 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[leadingIndex]!; + _clipHandles[key] ??= LayerHandle(); + _clipHandles[key]!.layer = context.pushClipRect( + needsCompositing, + offset, + rect, + (PaintingContext context, Offset offset) { + _paintRows( + context, + offset, + leadingRow: segment.leadingIndex, + trailingRow: segment.trailingIndex, + ); + }, + oldLayer: _clipHandles[key]!.layer, + ); + } + } + + void _paintRows( + PaintingContext context, + Offset offset, { + required int leadingRow, + required int trailingRow, + }) { + // Row decorations + final LinkedHashMap foregroundRows = + LinkedHashMap(); + final LinkedHashMap backgroundRows = + LinkedHashMap(); + + int currentRow = leadingRow; + while (currentRow <= trailingRow) { + final _Span rowSpan = _rowMetrics[currentRow]!; + final TreeRow configuration = rowSpan.configuration; + if (configuration.backgroundDecoration != null || + configuration.foregroundDecoration != null) { + final RenderBox child = getChildFor( + TreeVicinity(depth: _rowDepths[currentRow]!, row: currentRow), + )!; + + Rect getRowRect(bool consumePadding) { + final TwoDimensionalViewportParentData parentData = + parentDataOf(child); + // Decoration rects cover the whole row from the left and right + // edge of the viewport. + return Rect.fromPoints( + Offset(0.0, parentData.layoutOffset!.dy), + Offset( + viewportDimension.width, + rowSpan.trailingOffset - verticalOffset.pixels, + ), + ); + } + + if (configuration.backgroundDecoration != null) { + final Rect rect = getRowRect( + configuration.backgroundDecoration!.consumeSpanPadding, + ); + backgroundRows[rect] = configuration.backgroundDecoration!; + } + if (configuration.foregroundDecoration != null) { + final Rect rect = getRowRect( + configuration.foregroundDecoration!.consumeSpanPadding, + ); + foregroundRows[rect] = configuration.foregroundDecoration!; + } + } + currentRow++; + } + + // Get to painting. + // Background decorations first. + backgroundRows.forEach((Rect rect, TreeRowDecoration decoration) { + final TreeRowDecorationPaintDetails paintingDetails = + TreeRowDecorationPaintDetails( + canvas: context.canvas, + rect: rect, + axisDirection: horizontalAxisDirection, + ); + decoration.paint(paintingDetails); + }); + // Child nodes. + for (int row = leadingRow; row <= trailingRow; row++) { + final RenderBox child = getChildFor( + TreeVicinity(depth: _rowDepths[row]!, row: row), + )!; + final TwoDimensionalViewportParentData rowParentData = + parentDataOf(child); + if (rowParentData.isVisible) { + context.paintChild(child, offset + rowParentData.paintOffset!); + } + } + // Foreground decorations. + foregroundRows.forEach((Rect rect, TreeRowDecoration decoration) { + final TreeRowDecorationPaintDetails paintingDetails = + TreeRowDecorationPaintDetails( + canvas: context.canvas, + rect: rect, + axisDirection: horizontalAxisDirection, + ); + decoration.paint(paintingDetails); + }); + } +} + +class _Span + with Diagnosticable + implements HitTestTarget, MouseTrackerAnnotation { + double get leadingOffset => _leadingOffset; + late double _leadingOffset; + + double get extent => _extent; + late double _extent; + + TreeRow get configuration => _configuration!; + TreeRow? _configuration; + + double get animationOffset => _animationOffset; + late double _animationOffset; + + double get trailingOffset { + return leadingOffset + + extent + + configuration.padding.leading + + configuration.padding.trailing; + } + + // ---- Span Management ---- + + void update({ + required TreeRow configuration, + required double leadingOffset, + required double extent, + required double animationOffset, + }) { + _leadingOffset = leadingOffset; + _extent = extent; + _animationOffset = animationOffset; + if (configuration == _configuration) { + return; + } + _configuration = configuration; + // Only sync recognizers if they are in use already. + if (_recognizers != null) { + _syncRecognizers(); + } + } + + void dispose() { + _disposeRecognizers(); + } + + // ---- Recognizers management ---- + + Map? _recognizers; + + void _syncRecognizers() { + if (configuration.recognizerFactories.isEmpty) { + _disposeRecognizers(); + return; + } + final Map newRecognizers = + {}; + for (final Type type in configuration.recognizerFactories.keys) { + assert(!newRecognizers.containsKey(type)); + newRecognizers[type] = _recognizers?.remove(type) ?? + configuration.recognizerFactories[type]!.constructor(); + assert( + newRecognizers[type].runtimeType == type, + 'GestureRecognizerFactory of type $type created a GestureRecognizer of ' + 'type ${newRecognizers[type].runtimeType}. The ' + 'GestureRecognizerFactory must be specialized with the type of the ' + 'class that it returns from its constructor method.', + ); + configuration.recognizerFactories[type]! + .initializer(newRecognizers[type]!); + } + _disposeRecognizers(); // only disposes the ones that where not re-used above. + _recognizers = newRecognizers; + } + + void _disposeRecognizers() { + if (_recognizers != null) { + for (final GestureRecognizer recognizer in _recognizers!.values) { + recognizer.dispose(); + } + _recognizers = null; + } + } + + // ---- HitTestTarget ---- + + @override + void handleEvent(PointerEvent event, HitTestEntry entry) { + if (event is PointerDownEvent && + configuration.recognizerFactories.isNotEmpty) { + if (_recognizers == null) { + _syncRecognizers(); + } + assert(_recognizers != null); + for (final GestureRecognizer recognizer in _recognizers!.values) { + recognizer.addPointer(event); + } + } + } + + // ---- MouseTrackerAnnotation ---- + + @override + MouseCursor get cursor => configuration.cursor; + + @override + PointerEnterEventListener? get onEnter => configuration.onEnter; + + @override + PointerExitEventListener? get onExit => configuration.onExit; + + @override + bool get validForMouseTracker => true; +} diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/tree.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/tree.dart new file mode 100644 index 000000000000..33257e1e7b1d --- /dev/null +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/tree.dart @@ -0,0 +1,1077 @@ +// Copyright 2013 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/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'render_tree.dart'; +import 'tree_core.dart'; +import 'tree_delegate.dart'; +import 'tree_span.dart'; + +// The classes in these files follow the same pattern as the one dimensional +// sliver tree in the framework. +// +// After rolling to stable, these classes may be deprecated, or more likely +// made to be typedefs/subclasses of the framework core tree components. They +// could also live on if at a later date the 2D TreeView deviates or adds +// special features not relevant to the 1D sliver components of the framework. + +const double _kDefaultRowExtent = 40.0; + +/// A data structure for configuring children of a [TreeView]. +/// +/// A [TreeViewNode.content] can be of any type [T], but must correspond with +/// the same type of the [TreeView]. +/// +/// Getters for [depth], [parent] and [isExpanded] are managed by the +/// [TreeView]'s state. +class TreeViewNode { + /// Creates a [TreeViewNode] instance for use in a [TreeView]. + TreeViewNode( + 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 [TreeView]. + T get content => _content; + final T _content; + + /// Other [TreeViewNode]s that this node will be [parent] to. + /// + /// Modifying the children of nodes in a [TreeView] 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 [TreeViewNode] of this node. + TreeViewNode? get parent => _parent; + TreeViewNode? _parent; + + @override + String toString() { + return 'TreeViewNode: $content, depth: ${depth == 0 ? 'root' : depth}, ' + '${children.isEmpty ? 'leaf' : 'parent, expanded: $isExpanded'}'; + } +} + +/// Enables control over the [TreeViewNodes] of a [TreeView]. +/// +/// 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 [TreeView] +/// with a [TreeViewController] that's owned by a stateful widget +/// or look up the tree's automatically created [TreeViewController] +/// with [TreeViewController.of] +/// +/// The controller's methods to expand or collapse nodes cause the +/// the [TreeView] to rebuild, so they may not be called from +/// a build method. +class TreeViewController { + /// Create a controller to be used with [TreeView.controller]. + TreeViewController(); + + TreeViewStateMixin? _state; + + /// Whether the given [TreeViewNode] built with this controller is in an + /// expanded state. + /// + /// See also: + /// + /// * [expandNode], which expands a given [TreeViewNode]. + /// * [collapseNode], which collapses a given [TreeViewNode]. + /// * [TreeView.controller] to create an TreeView with a controller. + bool isExpanded(TreeViewNode node) { + assert(_state != null); + return _state!.isExpanded(node); + } + + /// Whether or not the given [TreeViewNode] is enclosed within its parent + /// [TreeViewNode]. + /// + /// If the [TreeViewNode.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(TreeViewNode node) { + assert(_state != null); + return _state!.isActive(node); + } + + /// Returns the [TreeViewNode] 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. + TreeViewNode? getNodeFor(Object? content) { + assert(_state != null); + return _state!.getNodeFor(content); + } + + /// Switches the given [TreeViewNode]s expanded state. + /// + /// May trigger an animation to reveal or hide the node's children based on + /// the [TreeView.toggleAnimationStyle]. + /// + /// If the node does not have any children, nothing will happen. + void toggleNode(TreeViewNode node) { + assert(_state != null); + return _state!.toggleNode(node); + } + + /// Expands the [TreeViewNode] 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 [TreeView] to rebuild, so it may + /// not be called from a build method. + /// + /// Calling this method will trigger the [TreeView.onNodeToggle] callback. + /// + /// See also: + /// + /// * [collapseNode], which collapses the [TreeViewNode]. + /// * [isExpanded] to check whether the tile is expanded. + /// * [TreeView.controller] to create an TreeView with a controller. + void expandNode(TreeViewNode node) { + assert(_state != null); + if (!node.isExpanded) { + _state!.toggleNode(node); + } + } + + /// Expands all parent [TreeViewNode]s in the tree. + void expandAll() { + assert(_state != null); + _state!.expandAll(); + } + + /// Closes all parent [TreeViewNode]s in the tree. + void collapseAll() { + assert(_state != null); + _state!.collapseAll(); + } + + /// Returns the current row index of the given [TreeViewNode]. + /// + /// If the node is not currently active in the tree, meaning its parent is + /// collapsed, this will return null. + int? getActiveIndexFor(TreeViewNode node) { + assert(_state != null); + return _state!.getActiveIndexFor(node); + } + + /// Collapses the [TreeViewNode] 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 [TreeView] to rebuild, so it may + /// not be called from a build method. + /// + /// Calling this method will trigger the [TreeView.onNodeToggle] callback. + /// + /// See also: + /// + /// * [expandNode], which expands the tile. + /// * [isExpanded] to check whether the tile is expanded. + /// * [TreeView.controller] to create an TreeView with a controller. + void collapseNode(TreeViewNode node) { + assert(_state != null); + if (node.isExpanded) { + _state!.toggleNode(node); + } + } + + /// Finds the [TreeViewController] for the closest [TreeView] instance + /// that encloses the given context. + /// + /// If no [TreeView] 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 [TreeView] use [maybeOf] instead. + /// + /// Typical usage of the [TreeViewController.of] function is to call it + /// from within the `build` method of a descendant of an [TreeView]. + /// + /// When the [TreeView] 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 [TreeViewController] (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 [TreeView]. + static TreeViewController of(BuildContext context) { + final _TreeViewState? result = + context.findAncestorStateOfType<_TreeViewState>(); + if (result != null) { + return result.controller; + } + throw FlutterError.fromParts([ + ErrorSummary( + 'TreeViewController.of() called with a context that does not contain a ' + 'TreeView.', + ), + ErrorDescription( + 'No TreeView ancestor could be found starting from the context that ' + 'was passed to TreeViewController.of(). ' + 'This usually happens when the context provided is from the same ' + 'StatefulWidget as that whose build function actually creates the ' + 'TreeView 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 TreeView.', + ), + 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 TreeView. In this solution, you would have an outer ' + 'widget that creates the TreeView populated by instances of your new ' + 'inner widgets, and then in these inner widgets you would use ' + 'TreeViewController.of().', + ), + context.describeElement('The context used was'), + ]); + } + + /// Finds the [TreeView] from the closest instance of this class that + /// encloses the given context and returns its [TreeViewController]. + /// + /// If no [TreeView] 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 [TreeView] + /// encloses the given context. Also includes some sample code in its + /// documentation. + static TreeViewController? maybeOf(BuildContext context) { + return context + .findAncestorStateOfType<_TreeViewState>() + ?.controller; + } +} + +// END of shared surfaces from the framework. + +/// A widget that displays [TreeViewNode]s that expand and collapse in a +/// vertically and horizontally scrolling [TreeViewport]. +/// +/// The type [T] correlates to the type of [TreeView] and [TreeViewNode], +/// representing the type of [TreeViewNode.content]. +/// +/// The rows of the tree are laid out on demand by the [TreeViewport]'s render +/// object, using [TreeView.treeNodeBuilder]. This will only be called for the +/// nodes that are visible, or within the [TreeViewport.cacheExtent]. +/// +/// The [TreeView.treeNodeBuilder] returns the [Widget] that represents the +/// given [TreeViewNode]. +/// +/// The [TreeView.treeRowBuilder] returns a [TreeRow], +/// which provides details about the row such as the [TreeRowExtent], as well as +/// any [TreeRow.recognizerFactories], [TreeRowDecoration]s, and more. +/// +/// Providing a [TreeController] will enable querying and controlling the state +/// of nodes in the tree. +/// +/// Each active node of the tree will have a [TreeVicinity], representing the +/// resolved row index of the node, based on what nodes are active, as well as +/// the depth. +/// +/// A [TreeView] only supports a vertical axis direction of +/// [AxisDirection.down] and a horizontal axis direction of +/// [AxisDirection.right]. +class TreeView extends StatefulWidget { + /// Creates an instance of a [TreeView] for displaying [TreeViewNode]s + /// that animate expanding and collapsing of nodes. + TreeView({ + super.key, + required this.tree, + this.treeNodeBuilder = TreeView.defaultTreeNodeBuilder, + this.treeRowBuilder = TreeView.defaultTreeRowBuilder, + this.controller, + this.onNodeToggle, + this.toggleAnimationStyle, + this.indentation = TreeViewIndentationType.standard, + this.primary, + this.mainAxis = Axis.vertical, + this.verticalDetails = const ScrollableDetails.vertical(), + this.horizontalDetails = const ScrollableDetails.horizontal(), + this.cacheExtent, + this.diagonalDragBehavior = DiagonalDragBehavior.none, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.clipBehavior = Clip.hardEdge, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + }) : assert(verticalDetails.direction == AxisDirection.down && + horizontalDetails.direction == AxisDirection.right); + + /// The list of [TreeViewNode]s that may be displayed in the [TreeView]. + /// + /// Beyond root nodes, or those in this list, whether or not a given + /// [TreeViewNode] is displayed depends on the [TreeViewNode.isExpanded] value + /// of its parent. The [TreeView] will set the [TreeViewNode.parent] and + /// [TreeViewNode.depth] as nodes are built on demand to ensure the integrity + /// of the tree. + final List> tree; + + /// Called to build an entry of the [TreeView] for the given [TreeViewNode]. + /// + /// By default, if this is unset, the [TreeView.defaultTreeNodeBuilder] is + /// used. + final TreeViewNodeBuilder treeNodeBuilder; + + /// Builds the [TreeRow] that describes the row for the provided + /// [TreeViewNode]. + /// + /// By default, if this is unset, the [TreeView.defaultTreeRowBuilder] + /// is used. + final TreeViewRowBuilder treeRowBuilder; + + /// If provided, the controller can be used to expand and collapse + /// [TreeViewNode]s, or lookup information about the current state of the + /// [TreeView]. + final TreeViewController? controller; + + /// Called when a [TreeViewNode] is toggled to expand or collapse. + /// + /// This will be called before the collapse or expand animation starts, but + /// after the [TreeViewNode.isExpanded] value is updated. This means that + /// [TreeViewNode.isExpanded] will reflect the value it is transitioning to as + /// a result of being toggled. + /// + /// This will not be called if a [TreeViewNode] does not have any children. + final TreeViewNodeCallback? onNodeToggle; + + /// The default [AnimationStyle] for expanding and collapsing nodes in the + /// [TreeView]. + /// + /// The default [AnimationStyle.duration] uses + /// [TreeView.defaultAnimationDuration], which is 150 milliseconds. + /// + /// The default [AnimationStyle.curve] uses [TreeView.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 [TreeViewNode.depth]. + /// + /// By default, the indentation is handled by [RenderTreeViewport]. Child + /// nodes are offset by the indentation specified by + /// [TreeViewIndentationType.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 [TreeView.treeNodeBuilder]. + /// + /// Alternatively, the indentation can be implemented in + /// [TreeView.treeNodeBuilder]. By providing [TreeViewIndentationType.none], + /// the depth of the given tree row can be accessed + /// in [TreeView.treeNodeBuilder] through [TreeViewNode.depth]. This allows + /// for more customization in building tree rows, such as filling the indented + /// area with decorations or ink effects. + final TreeViewIndentationType indentation; + + /// Whether this is the primary scroll view associated with the parent + /// [PrimaryScrollController]. + /// + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. + /// + /// See also: + /// + /// * [TwoDimensionalScrollView.primary], whether or not the + /// [TreeView.mainAxis] will use the [PrimaryScrollController]. + final bool? primary; + + /// The main axis of the two. + /// + /// Used to determine how to apply [primary] when true. This will not affect + /// paint order or traversal order of [TreeViewNode]s. Nodes will be painted + /// in the order they are laid out in the vertical axis. For tree traversal, + /// see [TreeViewTraversalOrder]. + /// + /// Defaults to [Axis.vertical]. + final Axis mainAxis; + + /// The configuration of the vertical Scrollable. + /// + /// These [ScrollableDetails] can be used to set the [AxisDirection], + /// [ScrollController], [ScrollPhysics] and more for the vertical axis. + final ScrollableDetails verticalDetails; + + /// The configuration of the horizontal Scrollable. + /// + /// These [ScrollableDetails] can be used to set the [AxisDirection], + /// [ScrollController], [ScrollPhysics] and more for the horizontal axis. + final ScrollableDetails horizontalDetails; + + /// The [TreeViewport] has an area before and after the visible area to cache + /// rows that are about to become visible when the user scrolls. + /// + /// [TreeRow]s that fall in this cache area are laid out even though they are + /// not (yet) visible on screen. The [cacheExtent] describes how many pixels + /// the cache area extends before the leading edge and after the trailing edge + /// of the viewport. + /// + /// See also: + /// + /// * [TwoDimensionalScrollView.cacheExtent], the area beyond the viewport + /// bounds where soon-to-be-visible children are rendered. + final double? cacheExtent; + + /// Whether scrolling gestures should lock to one axes, allow free movement + /// in both axes, or be evaluated on a weighted scale. + /// + /// Defaults to [DiagonalDragBehavior.none], locking axes to receive input one + /// at a time. + final DiagonalDragBehavior diagonalDragBehavior; + + /// Determines the way that drag start behavior is handled. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [TwoDimensionalScrollView.dragStartBehavior] + final DragStartBehavior dragStartBehavior; + + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// The bounds of the [TreeViewport] will be clipped (or not) according to + /// this option. + /// + /// See the enum [Clip] for details of all possible options and their common + /// use cases. + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// Whether to wrap each row of the tree in an [AutomaticKeepAlive]. + /// + /// Typically, lazily laid out children are wrapped in [AutomaticKeepAlive] + /// widgets so that the children can use [KeepAliveNotification]s to preserve + /// their state when they would otherwise be garbage collected off-screen. + /// + /// This feature (and [addRepaintBoundaries]) must be disabled if the children + /// are going to manually maintain their [KeepAlive] state. It may also be + /// more efficient to disable this feature if it is known ahead of time that + /// none of the children will ever try to keep themselves alive. + /// + /// Defaults to true. + final bool addAutomaticKeepAlives; + + /// Whether to wrap each row in a [RepaintBoundary]. + /// + /// Typically, children in a scrolling container are wrapped in repaint + /// boundaries so that they do not need to be repainted as the list scrolls. + /// If the children are easy to repaint (e.g., solid color blocks or a short + /// snippet of text), it might be more efficient to not add a repaint boundary + /// and instead always repaint the children during scrolling. + /// + /// Defaults to true. + final bool addRepaintBoundaries; + + /// 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 + /// [TreeViewNode]. + /// + /// Use as part of [TreeView.defaultTreeNodeBuilder] to wrap the leading icon + /// of parent [TreeViewNode]s such that tapping on it triggers the animation. + /// + /// If defining your own [TreeView.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 when tapped. + /// + /// The gesture uses [HitTestBehavior.translucent], so as to not conflict + /// with any [TreeRow.recognizerFactories] or other interactive content in the + /// [TreeRow]. + static Widget wrapChildToToggleNode({ + required TreeViewNode node, + required Widget child, + }) { + return Builder(builder: (BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + TreeViewController.of(context).toggleNode(node); + }, + child: child, + ); + }); + } + + /// Returns the fixed height, default [TreeRow] for rows in the tree, + /// which is 40 pixels. + /// + /// Used by [TreeView.treeRowBuilder]. + static TreeRow defaultTreeRowBuilder(TreeViewNode node) { + return const TreeRow( + extent: FixedTreeRowExtent(_kDefaultRowExtent), + ); + } + + /// Default builder for the widget representing a given [TreeViewNode] in the + /// tree. + /// + /// Used by [TreeView.treeNodeBuilder]. + /// + /// This will return a [Row] containing the [toString] of + /// [TreeViewNode.content]. If the [TreeViewNode] is a parent of additional + /// nodes, an arrow icon will precede the content, and will trigger an expand + /// and collapse animation when tapped based on the + /// [TreeView.toggleAnimationStyle]. + static Widget defaultTreeNodeBuilder( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) { + final Duration animationDuration = + toggleAnimationStyle.duration ?? TreeView.defaultAnimationDuration; + final Curve animationCurve = + toggleAnimationStyle.curve ?? TreeView.defaultAnimationCurve; + final int index = TreeViewController.of(context).getActiveIndexFor(node)!; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row(children: [ + // Icon for parent nodes + TreeView.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() => _TreeViewState(); +} + +// Used in TreeViewState for code simplicity. +typedef _AnimationRecord = ({ + AnimationController controller, + CurvedAnimation animation, + UniqueKey key, +}); + +class _TreeViewState extends State> + with TickerProviderStateMixin, TreeViewStateMixin { + TreeViewController get controller => _treeController!; + TreeViewController? _treeController; + + // The flat representation of the tree, omitting nodes that are not active. + final List> _activeNodes = >[]; + final Map _rowDepths = {}; + bool _shouldUnpackNode(TreeViewNode 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; + } + + // Flattens the tree, omitting nodes that are not active. + void _unpackActiveNodes({ + int depth = 0, + List>? nodes, + TreeViewNode? parent, + }) { + if (nodes == null) { + _activeNodes.clear(); + _rowDepths.clear(); + nodes = widget.tree; + } + for (final TreeViewNode node in nodes) { + node._depth = depth; + node._parent = parent; + _activeNodes.add(node); + _rowDepths[_activeNodes.length - 1] = depth; + 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 TreeViewController is already associated with another ' + 'TreeView. A TreeViewController can only be associated with one ' + 'TreeView.', + ); + _treeController = widget.controller ?? TreeViewController(); + _treeController!._state = this; + super.initState(); + } + + @override + void didUpdateWidget(TreeView 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 = TreeViewController(); + _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 _TreeView( + primary: widget.primary, + mainAxis: widget.mainAxis, + horizontalDetails: widget.horizontalDetails, + verticalDetails: widget.verticalDetails, + cacheExtent: widget.cacheExtent, + diagonalDragBehavior: widget.diagonalDragBehavior, + dragStartBehavior: widget.dragStartBehavior, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + clipBehavior: widget.clipBehavior, + rowCount: _activeNodes.length, + activeAnimations: _activeAnimations, + rowDepths: _rowDepths, + nodeBuilder: (BuildContext context, ChildVicinity vicinity) { + vicinity = vicinity as TreeVicinity; + final TreeViewNode node = _activeNodes[vicinity.row]; + assert(vicinity.depth == node.depth); + Widget child = widget.treeNodeBuilder( + context, + node, + widget.toggleAnimationStyle ?? TreeView.defaultToggleAnimationStyle, + ); + + if (widget.addRepaintBoundaries) { + child = RepaintBoundary(child: child); + } + + return child; + }, + rowBuilder: (TreeVicinity vicinity) { + return widget.treeRowBuilder(_activeNodes[vicinity.row]); + }, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + indentation: widget.indentation.value, + ); + } + + // TreeViewStateMixin Implementation + + @override + bool isExpanded(TreeViewNode node) { + return _getNode(node.content, widget.tree)?.isExpanded ?? false; + } + + @override + bool isActive(TreeViewNode node) => _activeNodes.contains(node); + + @override + TreeViewNode? getNodeFor(T content) => _getNode(content, widget.tree); + TreeViewNode? _getNode(T content, List> tree) { + final List> nextDepth = >[]; + for (final TreeViewNode 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(TreeViewNode 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 TreeViewNode 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 TreeViewNode 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 TreeViewNode node in _currentAnimationForParent.keys) { + final _AnimationRecord animationRecord = + _currentAnimationForParent[node]!; + final int leadingChildIndex = _activeNodes.indexOf(node) + 1; + final TreeViewNodesAnimation animatingChildren = ( + fromIndex: leadingChildIndex, + toIndex: leadingChildIndex + node.children.length - 1, + value: animationRecord.animation.value, + ); + _activeAnimations[animationRecord.key] = animatingChildren; + } + } + + @override + void toggleNode(TreeViewNode 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 ?? + TreeView.defaultAnimationDuration, + ); + controller + ..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 ?? + TreeView.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 _TreeView extends TwoDimensionalScrollView { + _TreeView({ + super.primary, + super.mainAxis, + super.horizontalDetails, + super.verticalDetails, + super.cacheExtent, + super.diagonalDragBehavior = DiagonalDragBehavior.none, + super.dragStartBehavior, + super.keyboardDismissBehavior, + super.clipBehavior, + required TwoDimensionalIndexedWidgetBuilder nodeBuilder, + required TreeVicinityToRowBuilder rowBuilder, + required this.activeAnimations, + required this.rowDepths, + required this.indentation, + required int rowCount, + bool addAutomaticKeepAlives = true, + }) : assert(verticalDetails.direction == AxisDirection.down), + assert(horizontalDetails.direction == AxisDirection.right), + super( + delegate: TreeRowBuilderDelegate( + nodeBuilder: nodeBuilder, + rowBuilder: rowBuilder, + rowCount: rowCount, + addAutomaticKeepAlives: addAutomaticKeepAlives, + )); + + final Map activeAnimations; + final Map rowDepths; + final double indentation; + + @override + TreeViewport buildViewport( + BuildContext context, + ViewportOffset verticalOffset, + ViewportOffset horizontalOffset, + ) { + return TreeViewport( + verticalOffset: verticalOffset, + verticalAxisDirection: verticalDetails.direction, + horizontalOffset: horizontalOffset, + horizontalAxisDirection: horizontalDetails.direction, + delegate: delegate as TreeRowDelegateMixin, + cacheExtent: cacheExtent, + clipBehavior: clipBehavior, + activeAnimations: activeAnimations, + rowDepths: rowDepths, + indentation: indentation, + ); + } +} + +/// A widget through which a portion of a tree of [TreeViewNode] children are +/// viewed as rows, typically in combination with a [TreeView]. +class TreeViewport extends TwoDimensionalViewport { + /// Creates a viewport for [Widget]s that extend and scroll in both + /// horizontal and vertical dimensions. + const TreeViewport({ + super.key, + required super.verticalOffset, + required super.verticalAxisDirection, + required super.horizontalOffset, + required super.horizontalAxisDirection, + required TreeRowDelegateMixin super.delegate, + super.cacheExtent, + super.clipBehavior, + required this.activeAnimations, + required this.rowDepths, + required this.indentation, + }) : assert(verticalAxisDirection == AxisDirection.down && + horizontalAxisDirection == AxisDirection.right), + // This is fixed as there is currently only one traversal pattern, https://github.com/flutter/flutter/issues/148357 + super(mainAxis: Axis.vertical); + + /// The currently active [TreeViewNode] animations. + /// + /// Since the indexing of animating nodes can change at any time from + /// inserting and removing them from the tree, the unique key is used to track + /// an animation of nodes independent of their indexing across frames. + final Map activeAnimations; + + /// The depth of each active [TreeNode]. + final Map rowDepths; + + /// The number of pixels by which child nodes will be offset in the cross axis + /// based on their [TreeViewNode.depth]. + /// + /// If zero, can alternatively offset children in [TreeView.treeRowBuilder] + /// for more options to customize the indented space. + final double indentation; + + @override + RenderTreeViewport createRenderObject(BuildContext context) { + return RenderTreeViewport( + activeAnimations: activeAnimations, + rowDepths: rowDepths, + indentation: indentation, + horizontalOffset: horizontalOffset, + horizontalAxisDirection: horizontalAxisDirection, + verticalOffset: verticalOffset, + verticalAxisDirection: verticalAxisDirection, + cacheExtent: cacheExtent, + clipBehavior: clipBehavior, + delegate: delegate as TreeRowDelegateMixin, + childManager: context as TwoDimensionalChildManager, + ); + } + + @override + void updateRenderObject( + BuildContext context, + RenderTreeViewport renderObject, + ) { + renderObject + ..activeAnimations = activeAnimations + ..rowDepths = rowDepths + ..indentation = indentation + ..horizontalOffset = horizontalOffset + ..horizontalAxisDirection = horizontalAxisDirection + ..verticalOffset = verticalOffset + ..verticalAxisDirection = verticalAxisDirection + ..cacheExtent = cacheExtent + ..clipBehavior = clipBehavior + ..delegate = delegate as TreeRowDelegateMixin; + } +} diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/tree_core.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/tree_core.dart new file mode 100644 index 000000000000..f853cd88e283 --- /dev/null +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/tree_core.dart @@ -0,0 +1,140 @@ +// Copyright 2013 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/widgets.dart'; + +import 'tree.dart'; + +// The classes in these files follow the same pattern as the one dimensional +// sliver tree in the framework. +// +// After rolling to stable, these classes may be deprecated, or more likely +// made to be typedefs/subclasses of the framework core tree components. They +// could also live on if at a later date the 2D TreeView deviates or adds +// special features not relevant to the 1D sliver components of the framework. + +/// Signature for a function that is called when a [TreeViewNode] is toggled, +/// changing its expanded state. +/// +/// See also: +/// +/// * [TreeViewNode.toggleNode], for controlling node expansion +/// programmatically. +typedef TreeViewNodeCallback = void Function(TreeViewNode node); + +/// A mixin for classes implementing a tree structure as expected by a +/// [TreeViewController]. +/// +/// Used by [TreeView] to implement an interface for the [TreeViewController]. +/// +/// This allows the [TreeViewController] to be used in other widgets that +/// implement this interface. +/// +/// The type [T] correlates to the type of [TreeView] and [TreeViewNode], +/// representing the type of [TreeViewNode.content]. +mixin TreeViewStateMixin { + /// Returns whether or not the given [TreeViewNode] is expanded, regardless of + /// whether or not it is active in the tree. + bool isExpanded(TreeViewNode node); + + /// Returns whether or not the given [TreeViewNode] is enclosed within its + /// parent [TreeViewNode]. + /// + /// If the [TreeViewNode.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(TreeViewNode node); + + /// Switches the given [TreeViewNode]s expanded state. + /// + /// May trigger an animation to reveal or hide the node's children based on + /// the [TreeView.toggleAnimationStyle]. + /// + /// If the node does not have any children, nothing will happen. + void toggleNode(TreeViewNode node); + + /// Closes all parent [TreeViewNode]s in the tree. + void collapseAll(); + + /// Expands all parent [TreeViewNode]s in the tree. + void expandAll(); + + /// Retrieves the [TreeViewNode] 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. + TreeViewNode? getNodeFor(T content); + + /// Returns the current row index of the given [TreeViewNode]. + /// + /// If the node is not currently active in the tree, meaning its parent is + /// collapsed, this will return null. + int? getActiveIndexFor(TreeViewNode node); +} + +/// Represents the animation of the children of a parent [TreeViewNode] that +/// are animating into or out of view. +/// +/// The [fromIndex] and [toIndex] are 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 [RenderTreeViewport] as part of +/// [RenderTreeViewport.activeAnimations] by [TreeView] to properly offset +/// animating children. +typedef TreeViewNodesAnimation = ({ + int fromIndex, + int toIndex, + double value, +}); + +/// The style of indentation for [TreeViewNode]s in a [TreeView], as handled +/// by [RenderTreeViewport]. +/// +/// By default, the indentation is handled by [RenderTreeViewport]. Child nodes +/// are offset by the indentation specified by +/// [TreeViewIndentationType.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 [TreeView.treeNodeBuilder]. +/// +/// Alternatively, the indentation can be implemented in +/// [TreeView.treeNodeBuilder], with the depth of the given tree row accessed +/// by [TreeViewNode.depth]. This allows for more customization in building +/// tree rows, such as filling the indented area with decorations or ink +/// effects. +class TreeViewIndentationType { + const TreeViewIndentationType._internal(double value) : _value = value; + + /// The number of pixels by which [TreeViewNode]s will be offset according + /// to their [TreeViewNode.depth]. + double get value => _value; + final double _value; + + /// The default indentation of child [TreeViewNode]s in a [TreeView]. + /// + /// Child nodes will be offset by 10 pixels for each level in the tree. + static const TreeViewIndentationType standard = + TreeViewIndentationType._internal(10.0); + + /// Configures no offsetting of child nodes in a [TreeView]. + /// + /// Useful if the indentation is implemented in the + /// [TreeView.treeNodeBuilder] instead for more customization options. + /// + /// Child nodes will not be offset in the tree. + static const TreeViewIndentationType none = + TreeViewIndentationType._internal(0.0); + + /// Configures a custom offset for indenting child nodes in a [TreeView]. + /// + /// Child nodes will be offset by the provided number of pixels in the tree. + /// The [value] must be a non negative number. + static TreeViewIndentationType custom(double value) { + assert(value >= 0.0); + return TreeViewIndentationType._internal(value); + } +} diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/tree_delegate.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/tree_delegate.dart new file mode 100644 index 000000000000..3cac931ed4c7 --- /dev/null +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/tree_delegate.dart @@ -0,0 +1,125 @@ +// Copyright 2013 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/widgets.dart'; + +import 'tree.dart'; +import 'tree_span.dart'; + +/// Signature for a function that creates a [TreeRow] for a given +/// [TreeViewNode] in a [TreeView]. +/// +/// Used by the [TreeViewDelegateMixin.buildRow] to configure rows in the +/// [TreeView]. +typedef TreeViewRowBuilder = TreeRow Function(TreeViewNode node); + +/// Signature for a function that creates a [Widget] to represent the given +/// [TreeViewNode] in the [TreeView]. +/// +/// Used by [TreeView.treeRowBuilder] to build rows on demand for the +/// tree. +typedef TreeViewNodeBuilder = Widget Function( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, +); + +/// The position of a [TreeRow] in a [TreeViewport] in relation +/// to other children of the viewport. +/// +/// This subclass translates the abstract [ChildVicinity.xIndex] and +/// [ChildVicinity.yIndex] into terms of row index and depth for ease of use +/// within the context of a [TreeView]. +@immutable +class TreeVicinity extends ChildVicinity { + /// Creates a reference to a [TreeRow] in a [TreeView], with the [xIndex] and + /// [yIndex] converted to terms of [depth] and [row], respectively. + const TreeVicinity({ + required int depth, + required int row, + }) : super(xIndex: depth, yIndex: row); + + /// The row index of the [TreeRow] in the [TreeView]. + /// + /// Equivalent to the [yIndex]. + int get row => yIndex; + + /// The depth of the [TreeRow] in the [TreeView]. + /// + /// Root [TreeViewNode]s have a depth of 0. + /// + /// Equivalent to the [xIndex]. + int get depth => xIndex; + + @override + String toString() => '(row: $row, depth: $depth)'; +} + +/// A mixin that defines the model for a [TwoDimensionalChildDelegate] to be +/// used with a [TreeView]. +mixin TreeRowDelegateMixin on TwoDimensionalChildDelegate { + /// The number of rows that the tree has active nodes for. + /// + /// The [buildRow] method will be called for [TreeViewNode]s that are + /// currently active, meaning they are not contained within an unexpanded + /// parent node. + /// + /// The [buildRow] method must provide a valid [TreeRow] for all active nodes. + /// + /// If the value returned by this getter changes throughout the lifetime of + /// the delegate object, [notifyListeners] must be called. + int get rowCount; + + /// Builds the [TreeRow] that describe the row for the provided + /// [TreeVicinity]. + /// + /// The builder must return a valid [TreeRow] for all active nodes in the + /// tree. + TreeRow buildRow(TreeVicinity vicinity); +} + +/// Returns a [TreeRow] for the given [TreeVicinity] in the [TreeView]. +typedef TreeVicinityToRowBuilder = TreeRow Function(TreeVicinity); + +/// A delegate that supplies nodes for a [TreeViewport] on demand using a +/// builder callback. +/// +/// This is not typically used directly, instead being created and managed by +/// the [TreeView] so that the builder can be called for only those +/// [TreeViewNode]s that are currently active in the [TreeView]. +/// +/// The [rowCount] is determined by the number of active nodes in the +/// [TreeView]. +class TreeRowBuilderDelegate extends TwoDimensionalChildBuilderDelegate + with TreeRowDelegateMixin { + /// Creates a lazy building delegate to use with a [TreeView]. + TreeRowBuilderDelegate({ + required int rowCount, + super.addAutomaticKeepAlives, + required TwoDimensionalIndexedWidgetBuilder nodeBuilder, + required TreeVicinityToRowBuilder rowBuilder, + }) : assert(rowCount >= 0), + _rowBuilder = rowBuilder, + super( + builder: nodeBuilder, + // No maxXIndex, since we do not know the max depth. + maxYIndex: rowCount - 1, + // repaintBoundaries handled by TreeView + addRepaintBoundaries: false, + ); + + @override + int get rowCount => maxYIndex! + 1; + + set rowCount(int value) { + assert(value >= 0); + maxYIndex = value - 1; + } + + /// Builds the [TreeRow] that describes the row for the provided + /// [TreeVicinity]. + final TreeVicinityToRowBuilder _rowBuilder; + @override + TreeRow buildRow(TreeVicinity vicinity) => _rowBuilder(vicinity); +} diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/tree_span.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/tree_span.dart new file mode 100644 index 000000000000..b909279482e7 --- /dev/null +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/tree_span.dart @@ -0,0 +1,127 @@ +// Copyright 2013 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/painting.dart'; + +import '../common/span.dart'; + +/// Defines the leading and trailing padding values of a [TreeRow]. +typedef TreeRowPadding = SpanPadding; + +/// Defines the extent, visual appearance, and gesture handling of a row in a +/// [TreeView]. +typedef TreeRow = Span; + +/// Delegate passed to [TreeSpanExtent.calculateExtent] from the +/// [RenderTreeViewport] during layout. +/// +/// Provides access to metrics from the [TreeView] that a [TreeRowExtent] may +/// need to calculate its extent. +/// +/// Extents will not be computed for every frame unless the delegate has been +/// updated. Otherwise, after the extents are computed during the first layout +/// pass, they are cached and reused in subsequent frames. +typedef TreeRowExtentDelegate = SpanExtentDelegate; + +/// Defines the extent, or height, of a [TreeRow]. +typedef TreeRowExtent = SpanExtent; + +/// A [TreeRow] with a fixed [pixels] height. +typedef FixedTreeRowExtent = FixedSpanExtent; + +/// Specified the [TreeRow] height as a fraction of the viewport extent. +/// +/// For example, a row with a 1.0 as [fraction] will be as tall as the +/// viewport. +typedef FractionalTreeRowExtent = FractionalSpanExtent; + +/// Specifies that the row should occupy the remaining space in the viewport. +/// +/// If the previous [TreeRow]s can already fill out the viewport, this will +/// evaluate the row's height to zero. If the previous rows cannot fill out the +/// viewport, this row's extent will be whatever space is left to fill out the +/// viewport. +/// +/// To avoid that the row's extent evaluates to zero, consider combining this +/// extent with another extent. The following example will make sure that the +/// span's extent is at least 200 pixels, but if there's more than that available +/// in the viewport, it will fill all that space: +/// +/// ```dart +/// const MaxTreeRowExtent(FixedTreeRowExtent(200.0), RemainingTreeRowExtent()); +/// ``` +typedef RemainingTreeRowExtent = RemainingSpanExtent; + +/// Signature for a function that combines the result of two +/// [TreeRowExtent.calculateExtent] invocations. +/// +/// Used by [CombiningTreeRowExtent]; +typedef TreeRowExtentCombiner = SpanExtentCombiner; + +/// Runs the result of two [TreeRowExtent]s through a `combiner` function +/// to determine the ultimate pixel height of a tree row. +typedef CombiningTreeRowExtent = CombiningSpanExtent; + +/// Returns the larger pixel extent of the two provided [TreeRowExtent]. +typedef MaxTreeRowExtent = MaxSpanExtent; + +/// Returns the smaller pixel extent of the two provided [TreeRowExtent]. +typedef MinTreeRowExtent = MinSpanExtent; + +/// A decoration for a [TreeRow]. +typedef TreeRowDecoration = SpanDecoration; + +/// Describes the border for a [TreeRow]. +class TreeRowBorder extends SpanBorder { + /// Creates a [TreeRowBorder]. + const TreeRowBorder({ + BorderSide top = BorderSide.none, + BorderSide bottom = BorderSide.none, + this.left = BorderSide.none, + this.right = BorderSide.none, + }) : super(leading: top, trailing: bottom); + + /// Creates a [TreeRowBorder] with the provided [BorderSide] applied to all + /// sides. + const TreeRowBorder.all(BorderSide side) + : left = side, + right = side, + super(leading: side, trailing: side); + + /// The border to paint on the top, or leading edge of the [TreeRow]. + BorderSide get top => leading; + + /// The border to paint on the top, or leading edge of the [TreeRow]. + BorderSide get bottom => trailing; + + /// The border to draw on the left side of the [TreeRow]. + final BorderSide left; + + /// The border to draw on the right side of the [TreeRow]. + final BorderSide right; + + @override + void paint( + SpanDecorationPaintDetails details, + BorderRadius? borderRadius, + ) { + final Border border = Border( + top: top, + bottom: bottom, + left: left, + right: right, + ); + border.paint( + details.canvas, + details.rect, + borderRadius: borderRadius, + ); + } +} + +/// Provides the details of a given [TreeRowDecoration] for painting. +/// +/// Created during paint by the [RenderTreeViewport] for the +/// [TreeRow.foregroundDecoration] and [TreeRow.backgroundDecoration]. +typedef TreeRowDecorationPaintDetails = SpanDecorationPaintDetails; diff --git a/packages/two_dimensional_scrollables/lib/two_dimensional_scrollables.dart b/packages/two_dimensional_scrollables/lib/two_dimensional_scrollables.dart index f19cdb4e343f..9e8ecc384879 100644 --- a/packages/two_dimensional_scrollables/lib/two_dimensional_scrollables.dart +++ b/packages/two_dimensional_scrollables/lib/two_dimensional_scrollables.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -/// The [TableView] and associated widgets. +/// The [TableView], [TreeView], and associated widgets. /// /// To use, import `package:two_dimensional_scrollables/two_dimensional_scrollables.dart`. library two_dimensional_scrollables; @@ -13,3 +13,9 @@ export 'src/table_view/table.dart'; export 'src/table_view/table_cell.dart'; export 'src/table_view/table_delegate.dart'; export 'src/table_view/table_span.dart'; + +export 'src/tree_view/render_tree.dart'; +export 'src/tree_view/tree.dart'; +export 'src/tree_view/tree_core.dart'; +export 'src/tree_view/tree_delegate.dart'; +export 'src/tree_view/tree_span.dart'; diff --git a/packages/two_dimensional_scrollables/pubspec.yaml b/packages/two_dimensional_scrollables/pubspec.yaml index 1376d5fc29b2..4c48c55b65b3 100644 --- a/packages/two_dimensional_scrollables/pubspec.yaml +++ b/packages/two_dimensional_scrollables/pubspec.yaml @@ -1,12 +1,12 @@ name: two_dimensional_scrollables description: Widgets that scroll using the two dimensional scrolling foundation. -version: 0.2.1 +version: 0.3.0 repository: https://github.com/flutter/packages/tree/main/packages/two_dimensional_scrollables issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+two_dimensional_scrollables%22+ environment: - sdk: '>=3.2.0 <4.0.0' - flutter: ">=3.16.0" + sdk: '>=3.3.0 <4.0.0' + flutter: ">=3.19.0" dependencies: flutter: diff --git a/packages/two_dimensional_scrollables/test/table_view/table_test.dart b/packages/two_dimensional_scrollables/test/table_view/table_test.dart index ce879b75b277..94955c149362 100644 --- a/packages/two_dimensional_scrollables/test/table_view/table_test.dart +++ b/packages/two_dimensional_scrollables/test/table_view/table_test.dart @@ -4135,7 +4135,7 @@ void main() { class _NullBuildContext implements BuildContext, TwoDimensionalChildManager { @override - dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); + Object? noSuchMethod(Invocation invocation) => throw UnimplementedError(); } RenderTableViewport getViewport(WidgetTester tester, Key childKey) { diff --git a/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart b/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart new file mode 100644 index 000000000000..71d531e667c3 --- /dev/null +++ b/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart @@ -0,0 +1,971 @@ +// Copyright 2013 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +const TreeRow row = TreeRow(extent: FixedTreeRowExtent(100)); + +TreeRow getTappableRow(TreeViewNode node, VoidCallback callback) { + return TreeRow( + extent: const FixedTreeRowExtent(100), + recognizerFactories: { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (TapGestureRecognizer t) => t.onTap = () => callback(), + ), + }, + ); +} + +TreeRow getMouseTrackingRow({ + PointerEnterEventListener? onEnter, + PointerExitEventListener? onExit, +}) { + return TreeRow( + extent: const FixedTreeRowExtent(100), + onEnter: onEnter, + onExit: onExit, + cursor: SystemMouseCursors.cell, + ); +} + +List> _setUpNodes() { + return >[ + TreeViewNode('First'), + TreeViewNode( + 'Second', + children: >[ + TreeViewNode( + 'alpha', + children: >[ + TreeViewNode('uno'), + TreeViewNode('dos'), + TreeViewNode('tres'), + ], + ), + TreeViewNode('beta'), + TreeViewNode('kappa'), + ], + ), + TreeViewNode( + 'Third', + expanded: true, + children: >[ + TreeViewNode('gamma'), + TreeViewNode('delta'), + TreeViewNode('epsilon'), + ], + ), + TreeViewNode('Fourth'), + ]; +} + +List> treeNodes = _setUpNodes(); + +void main() { + group('RenderTreeViewport', () { + setUp(() { + treeNodes = _setUpNodes(); + }); + + test('asserts proper axis directions', () { + RenderTreeViewport? treeViewport; + expect( + () { + treeViewport = RenderTreeViewport( + verticalOffset: TestOffset(), + verticalAxisDirection: AxisDirection.up, + horizontalOffset: TestOffset(), + horizontalAxisDirection: AxisDirection.right, + delegate: TreeRowBuilderDelegate( + rowCount: 0, + nodeBuilder: (_, __) => const SizedBox(), + rowBuilder: (_) => const TreeRow( + extent: FixedTreeRowExtent(40.0), + ), + ), + activeAnimations: const {}, + rowDepths: const {}, + indentation: 0.0, + childManager: _NullBuildContext(), + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('verticalAxisDirection == AxisDirection.down'), + ), + ), + ); + expect( + () { + treeViewport = RenderTreeViewport( + verticalOffset: TestOffset(), + verticalAxisDirection: AxisDirection.down, + horizontalOffset: TestOffset(), + horizontalAxisDirection: AxisDirection.left, + delegate: TreeRowBuilderDelegate( + rowCount: 0, + nodeBuilder: (_, __) => const SizedBox(), + rowBuilder: (_) => const TreeRow( + extent: FixedTreeRowExtent(40.0), + ), + ), + activeAnimations: const {}, + rowDepths: const {}, + indentation: 0.0, + childManager: _NullBuildContext(), + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('horizontalAxisDirection == AxisDirection.right'), + ), + ), + ); + expect(treeViewport, isNull); + }); + + testWidgets('TreeRow gesture hit testing', (WidgetTester tester) async { + int tapCounter = 0; + final List log = []; + final TreeView treeView = TreeView( + tree: treeNodes, + treeRowBuilder: (TreeViewNode node) { + if (node.depth! == 0) { + return getTappableRow( + node as TreeViewNode, + () { + log.add(node.content); + tapCounter++; + }, + ); + } + return row; + }, + ); + + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pumpAndSettle(); + + // Root level rows are set up for taps. + expect(tapCounter, 0); + await tester.tap(find.text('First')); + await tester.tap(find.text('Second')); + await tester.tap(find.text('Third')); + // Should not be logged. + await tester.tap(find.text('gamma')); + expect(tapCounter, 3); + expect(log, ['First', 'Second', 'Third']); + }); + + testWidgets('mouse handling', (WidgetTester tester) async { + int enterCounter = 0; + int exitCounter = 0; + final TreeView treeView = TreeView( + tree: treeNodes, + treeRowBuilder: (TreeViewNode node) { + if (node.depth! == 0) { + return getMouseTrackingRow( + onEnter: (_) => enterCounter++, + onExit: (_) => exitCounter++, + ); + } + return row; + }, + ); + + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pumpAndSettle(); + // Root row will respond to mouse, child will not + final Offset rootRow = tester.getCenter(find.text('Second')); + final Offset childRow = tester.getCenter(find.text('gamma')); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: childRow); + expect(enterCounter, 0); + expect(exitCounter, 0); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(rootRow); + await tester.pumpAndSettle(); + expect(enterCounter, 1); + expect(exitCounter, 0); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + await gesture.moveTo(childRow); + await tester.pumpAndSettle(); + expect(enterCounter, 1); + expect(exitCounter, 1); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('Scrolls when there is enough content', + (WidgetTester tester) async { + final ScrollController verticalController = ScrollController(); + final ScrollController horizontalController = ScrollController(); + final TreeViewController treeController = TreeViewController(); + addTearDown(verticalController.dispose); + addTearDown(horizontalController.dispose); + final TreeView treeView = TreeView( + controller: treeController, + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + tree: treeNodes, + // Exaggerated to exceed viewport bounds. + indentation: TreeViewIndentationType.custom(500), + treeRowBuilder: (_) => row, + ); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + // Room to scroll + expect(verticalController.position.maxScrollExtent, 100.0); + expect(horizontalController.position.pixels, 0.0); + // Room to scroll + expect(horizontalController.position.maxScrollExtent, 90.0); + + verticalController.jumpTo(10.0); + horizontalController.jumpTo(10.0); + await tester.pump(); + expect(verticalController.position.pixels, 10.0); + expect(verticalController.position.maxScrollExtent, 100.0); + expect(horizontalController.position.pixels, 10.0); + expect(horizontalController.position.maxScrollExtent, 90.0); + + // Collapse a node. The horizontal extent should change to zero, + // and the position should corrrect. + treeController.toggleNode(treeController.getNodeFor('Third')!); + await tester.pumpAndSettle(); + expect(horizontalController.position.pixels, 0.0); + expect(horizontalController.position.maxScrollExtent, 0.0); + }); + + group('Layout', () { + setUp(() { + treeNodes = _setUpNodes(); + }); + + testWidgets('Basic', (WidgetTester tester) async { + // Default layout, custom indentation values, row extents. + TreeView treeView = TreeView( + tree: treeNodes, + ); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + 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(56.0, 128.0, 296.0, 152.0), + ); + expect(find.text('delta'), findsOneWidget); + expect( + tester.getRect(find.text('delta')), + const Rect.fromLTRB(56.0, 168.0, 296.0, 192.0), + ); + expect(find.text('epsilon'), findsOneWidget); + expect( + tester.getRect(find.text('epsilon')), + const Rect.fromLTRB(56.0, 208.0, 392.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), + ); + + treeView = TreeView( + tree: treeNodes, + indentation: TreeViewIndentationType.none, + ); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + 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), + ); + + treeView = TreeView( + tree: treeNodes, + indentation: TreeViewIndentationType.custom(50.0), + ); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + 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(96.0, 128.0, 336.0, 152.0), + ); + expect(find.text('delta'), findsOneWidget); + expect( + tester.getRect(find.text('delta')), + const Rect.fromLTRB(96.0, 168.0, 336.0, 192.0), + ); + expect(find.text('epsilon'), findsOneWidget); + expect( + tester.getRect(find.text('epsilon')), + const Rect.fromLTRB(96.0, 208.0, 432.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), + ); + + treeView = TreeView( + tree: treeNodes, + treeRowBuilder: (TreeViewNode node) { + if (node.depth! == 1) { + // extent == 100 + return row; + } + return TreeView.defaultTreeRowBuilder(node); + }, + ); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + 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(56.0, 146.0, 296.0, 194.0), + ); + expect(find.text('delta'), findsOneWidget); + expect( + tester.getRect(find.text('delta')), + const Rect.fromLTRB(56.0, 246.0, 296.0, 294.0), + ); + expect(find.text('epsilon'), findsOneWidget); + expect( + tester.getRect(find.text('epsilon')), + const Rect.fromLTRB(56.0, 346.0, 392.0, 394.0), + ); + expect(find.text('Fourth'), findsOneWidget); + expect( + tester.getRect(find.text('Fourth')), + const Rect.fromLTRB(46.0, 428.0, 334.0, 452.0), + ); + }); + + testWidgets('Animating node segment', (WidgetTester tester) async { + TreeView treeView = TreeView(tree: treeNodes); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + expect(find.text('alpha'), findsNothing); + await tester.tap(find.byType(Icon).first); + await tester.pump(); + // It has now been inserted into the tree, along with the other children + // of the node. + expect(find.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')), + const Rect.fromLTRB(56.0, -32.0, 296.0, -8.0), + ); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')), + const Rect.fromLTRB(56.0, 8.0, 248.0, 32.0), + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')), + const Rect.fromLTRB(56.0, 48.0, 296.0, 72.0), + ); + // Progress the animation. + await tester.pump(const Duration(milliseconds: 50)); + 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.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')), + const Rect.fromLTRB(56.0, 88.0, 296.0, 112.0), + ); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')), + const Rect.fromLTRB(56.0, 128.0, 248.0, 152.0), + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')), + const Rect.fromLTRB(56.0, 168.0, 296.0, 192.0), + ); + + // Customize the animation + treeView = TreeView( + tree: treeNodes, + toggleAnimationStyle: AnimationStyle( + duration: const Duration(milliseconds: 500), + curve: Curves.bounceIn, + ), + ); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + // Still visible from earlier. + expect(find.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')), + const Rect.fromLTRB(56.0, 88.0, 296.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.text('alpha'), findsNothing); + + // Disable the animation + treeView = TreeView( + tree: treeNodes, + toggleAnimationStyle: AnimationStyle.noAnimation, + ); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + // 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 + expect(find.text('alpha'), findsOneWidget); + expect( + tester.getRect(find.text('alpha')), + const Rect.fromLTRB(56.0, 88.0, 296.0, 112.0), + ); + expect(find.text('beta'), findsOneWidget); + expect( + tester.getRect(find.text('beta')), + const Rect.fromLTRB(56.0, 128.0, 248.0, 152.0), + ); + expect(find.text('kappa'), findsOneWidget); + expect( + tester.getRect(find.text('kappa')), + const Rect.fromLTRB(56.0, 168.0, 296.0, 192.0), + ); + }); + + testWidgets('Multiple animating node segments', + (WidgetTester tester) async { + final TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: TreeView( + tree: treeNodes, + controller: controller, + ), + )); + await tester.pump(); + 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(56.0, 128.0, 296.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)); + // 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(56.0, 128.0, 296.0, 152.0), + ); + // Second is expanding + expect( + tester.getRect(find.text('Second')), + const Rect.fromLTRB(46.0, 48.0, 334.0, 72.0), + ); + // alpha has been added and is animating into view. + expect( + tester.getRect(find.text('alpha')).top.floor(), + -32.0, + ); + await tester.pump(const Duration(milliseconds: 15)); + // 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(56.0, 128.0, 296.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)); + // 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(56.0, 128.0, 296.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( + 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(56.0, 88.0, 296.0, 112.0), + ); + }); + }); + + group('Painting', () { + setUp(() { + treeNodes = _setUpNodes(); + }); + + testWidgets('only paints visible rows', (WidgetTester tester) async { + final ScrollController verticalController = ScrollController(); + addTearDown(verticalController.dispose); + final TreeView treeView = TreeView( + treeRowBuilder: (_) => const TreeRow(extent: FixedTreeRowExtent(400)), + tree: treeNodes, + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + ); + + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 600.0); + + bool rowNeedsPaint(String row) { + return find.text(row).evaluate().first.renderObject!.debugNeedsPaint; + } + + expect(rowNeedsPaint('First'), isFalse); + expect(rowNeedsPaint('Second'), isFalse); + expect(rowNeedsPaint('Third'), isTrue); // In cacheExtent + expect(find.text('gamma'), findsNothing); // outside of cacheExtent + }); + + testWidgets('paints decorations correctly', (WidgetTester tester) async { + final ScrollController verticalController = ScrollController(); + final ScrollController horizontalController = ScrollController(); + addTearDown(verticalController.dispose); + addTearDown(horizontalController.dispose); + const TreeRowDecoration rootForegroundDecoration = TreeRowDecoration( + color: Colors.red, + ); + const TreeRowDecoration rootBackgroundDecoration = TreeRowDecoration( + color: Colors.blue, + ); + const TreeRowDecoration foregroundDecoration = TreeRowDecoration( + color: Colors.orange, + ); + const TreeRowDecoration backgroundDecoration = TreeRowDecoration( + color: Colors.green, + ); + final TreeView treeView = TreeView( + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + ), + tree: treeNodes, + treeRowBuilder: (TreeViewNode node) { + return row.copyWith( + backgroundDecoration: node.depth! == 0 + ? rootBackgroundDecoration + : backgroundDecoration, + foregroundDecoration: node.depth! == 0 + ? rootForegroundDecoration + : foregroundDecoration, + ); + }, + ); + await tester.pumpWidget(MaterialApp(home: treeView)); + await tester.pump(); + expect(verticalController.position.pixels, 0.0); + expect(verticalController.position.maxScrollExtent, 100.0); + + expect( + find.byType(TreeViewport), + paints + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0), + color: const Color(0xff2196f3), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 100.0, 800.0, 200.0), + color: const Color(0xff2196f3), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 200.0, 800.0, 300.0), + color: const Color(0xff2196f3), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 300.0, 800.0, 400.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 400.0, 800.0, 500.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 500.0, 800.0, 600.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 600.0, 800.0, 700.0), + color: const Color(0xff2196f3), + ) + ..paragraph() + ..paragraph() + ..paragraph() + ..paragraph() + ..paragraph() + ..paragraph() + ..paragraph() + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0), + color: const Color(0xFFF44336), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 100.0, 800.0, 200.0), + color: const Color(0xFFF44336), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 200.0, 800.0, 300.0), + color: const Color(0xFFF44336), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 300.0, 800.0, 400.0), + color: const Color(0xFFFF9800), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 400.0, 800.0, 500.0), + color: const Color(0xFFFF9800), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 500.0, 800.0, 600.0), + color: const Color(0xFFFF9800), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 600.0, 800.0, 700.0), + color: const Color(0xFFF44336), + ), + ); + // Change the scroll offset + verticalController.jumpTo(10.0); + await tester.pump(); + expect( + find.byType(TreeViewport), + paints + ..rect( + rect: const Rect.fromLTRB(0.0, -10.0, 800.0, 90.0), + color: const Color(0xff2196f3), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 90.0, 800.0, 190.0), + color: const Color(0xff2196f3), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 190.0, 800.0, 290.0), + color: const Color(0xff2196f3), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 290.0, 800.0, 390.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 390.0, 800.0, 490.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 490.0, 800.0, 590.0), + color: const Color(0xff4caf50), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 590.0, 800.0, 690.0), + color: const Color(0xff2196f3), + ) + ..paragraph() + ..paragraph() + ..paragraph() + ..paragraph() + ..paragraph() + ..paragraph() + ..paragraph() + ..rect( + rect: const Rect.fromLTRB(0.0, -10.0, 800.0, 90.0), + color: const Color(0xFFF44336), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 90.0, 800.0, 190.0), + color: const Color(0xFFF44336), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 190.0, 800.0, 290.0), + color: const Color(0xFFF44336), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 290.0, 800.0, 390.0), + color: const Color(0xFFFF9800), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 390.0, 800.0, 490.0), + color: const Color(0xFFFF9800), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 490.0, 800.0, 590.0), + color: const Color(0xFFFF9800), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 590.0, 800.0, 690.0), + color: const Color(0xFFF44336), + ), + ); + }); + }); + }); +} + +class TestOffset extends ViewportOffset { + TestOffset(); + + @override + bool get allowImplicitScrolling => throw UnimplementedError(); + + @override + Future animateTo( + double to, { + required Duration duration, + required Curve curve, + }) { + throw UnimplementedError(); + } + + @override + bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { + throw UnimplementedError(); + } + + @override + bool applyViewportDimension(double viewportDimension) { + throw UnimplementedError(); + } + + @override + void correctBy(double correction) {} + + @override + bool get hasPixels => throw UnimplementedError(); + + @override + void jumpTo(double pixels) {} + + @override + double get pixels => throw UnimplementedError(); + + @override + ScrollDirection get userScrollDirection => throw UnimplementedError(); +} + +class _NullBuildContext implements BuildContext, TwoDimensionalChildManager { + @override + Object? noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} diff --git a/packages/two_dimensional_scrollables/test/tree_view/tree_core_test.dart b/packages/two_dimensional_scrollables/test/tree_view/tree_core_test.dart new file mode 100644 index 000000000000..d1c4d425f8d1 --- /dev/null +++ b/packages/two_dimensional_scrollables/test/tree_view/tree_core_test.dart @@ -0,0 +1,21 @@ +// Copyright 2013 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_test/flutter_test.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +void main() { + group('TreeViewIndentationType', () { + test('Values are properly reflected', () { + double value = TreeViewIndentationType.standard.value; + expect(value, 10.0); + + value = TreeViewIndentationType.none.value; + expect(value, 0.0); + + value = TreeViewIndentationType.custom(50.0).value; + expect(value, 50.0); + }); + }); +} diff --git a/packages/two_dimensional_scrollables/test/tree_view/tree_delegate_test.dart b/packages/two_dimensional_scrollables/test/tree_view/tree_delegate_test.dart new file mode 100644 index 000000000000..139a153d9398 --- /dev/null +++ b/packages/two_dimensional_scrollables/test/tree_view/tree_delegate_test.dart @@ -0,0 +1,87 @@ +// Copyright 2013 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/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +const TreeRow span = TreeRow(extent: FixedTreeRowExtent(50)); + +void main() { + test('TreeVicinity converts ChildVicinity', () { + const TreeVicinity vicinity = TreeVicinity(depth: 5, row: 10); + expect(vicinity.xIndex, 5); + expect(vicinity.yIndex, 10); + expect(vicinity.row, 10); + expect(vicinity.depth, 5); + expect(vicinity.toString(), '(row: 10, depth: 5)'); + }); + + group('TreeRowBuilderDelegate', () { + test('exposes addAutomaticKeepAlives from super class', () { + final TreeRowBuilderDelegate delegate = TreeRowBuilderDelegate( + nodeBuilder: (_, __) => const SizedBox(), + rowBuilder: (_) => span, + rowCount: 6, + addAutomaticKeepAlives: false, + ); + expect(delegate.addAutomaticKeepAlives, isFalse); + }); + + test('asserts valid counts for rows', () { + TreeRowBuilderDelegate? delegate; + expect( + () { + delegate = TreeRowBuilderDelegate( + nodeBuilder: (_, __) => const SizedBox(), + rowBuilder: (_) => span, + rowCount: -1, // asserts + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('rowCount >= 0'), + ), + ), + ); + + expect(delegate, isNull); + }); + + test('sets max y index (not x) of super class', () { + final TreeRowBuilderDelegate delegate = TreeRowBuilderDelegate( + nodeBuilder: (_, __) => const SizedBox(), + rowBuilder: (_) => span, + rowCount: 6, + ); + expect(delegate.maxYIndex, 5); // rows + expect(delegate.maxXIndex, isNull); // unknown max depth + }); + + test('Notifies listeners & rebuilds', () { + bool notified = false; + TreeRowBuilderDelegate oldDelegate; + + final TreeRowBuilderDelegate delegate = TreeRowBuilderDelegate( + nodeBuilder: (_, __) => const SizedBox(), + rowBuilder: (_) => span, + rowCount: 6, + ); + delegate.addListener(() { + notified = true; + }); + + // change row count + oldDelegate = delegate; + delegate.rowCount = 7; + expect(notified, isTrue); + expect(delegate.shouldRebuild(oldDelegate), isTrue); + + // Builder delegate always returns true. + expect(delegate.shouldRebuild(delegate), isTrue); + }); + }); +} diff --git a/packages/two_dimensional_scrollables/test/tree_view/tree_span_test.dart b/packages/two_dimensional_scrollables/test/tree_view/tree_span_test.dart new file mode 100644 index 000000000000..0ccf49297e47 --- /dev/null +++ b/packages/two_dimensional_scrollables/test/tree_view/tree_span_test.dart @@ -0,0 +1,208 @@ +// Copyright 2013 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/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +void main() { + group('TreeRowExtent', () { + test('FixedTreeRowExtent', () { + FixedTreeRowExtent extent = const FixedTreeRowExtent(150); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate(precedingExtent: 0, viewportExtent: 0), + ), + 150, + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate( + precedingExtent: 100, viewportExtent: 1000), + ), + 150, + ); + // asserts value is valid + expect( + () { + extent = FixedTreeRowExtent(-100); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('pixels >= 0.0'), + ), + ), + ); + }); + + test('FractionalTreeRowExtent', () { + FractionalTreeRowExtent extent = const FractionalTreeRowExtent(0.5); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate(precedingExtent: 0, viewportExtent: 0), + ), + 0.0, + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate( + precedingExtent: 100, viewportExtent: 1000), + ), + 500, + ); + // asserts value is valid + expect( + () { + extent = FractionalTreeRowExtent(-20); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('fraction >= 0.0'), + ), + ), + ); + }); + + test('RemainingTreeRowExtent', () { + const RemainingTreeRowExtent extent = RemainingTreeRowExtent(); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate(precedingExtent: 0, viewportExtent: 0), + ), + 0.0, + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate( + precedingExtent: 100, viewportExtent: 1000), + ), + 900, + ); + }); + + test('CombiningTreeRowExtent', () { + final CombiningTreeRowExtent extent = CombiningTreeRowExtent( + const FixedTreeRowExtent(100), + const RemainingTreeRowExtent(), + (double a, double b) { + return a + b; + }, + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate(precedingExtent: 0, viewportExtent: 0), + ), + 100, + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate( + precedingExtent: 100, viewportExtent: 1000), + ), + 1000, + ); + }); + + test('MaxTreeRowExtent', () { + const MaxTreeRowExtent extent = MaxTreeRowExtent( + FixedTreeRowExtent(100), + RemainingTreeRowExtent(), + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate(precedingExtent: 0, viewportExtent: 0), + ), + 100, + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate( + precedingExtent: 100, viewportExtent: 1000), + ), + 900, + ); + }); + + test('MinTreeRowExtent', () { + const MinTreeRowExtent extent = MinTreeRowExtent( + FixedTreeRowExtent(100), + RemainingTreeRowExtent(), + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate(precedingExtent: 0, viewportExtent: 0), + ), + 0, + ); + expect( + extent.calculateExtent( + const TreeRowExtentDelegate( + precedingExtent: 100, viewportExtent: 1000), + ), + 100, + ); + }); + }); + + test('TreeRowDecoration', () { + TreeRowDecoration decoration = const TreeRowDecoration( + color: Color(0xffff0000), + ); + final TestCanvas canvas = TestCanvas(); + const Rect rect = Rect.fromLTWH(0, 0, 10, 10); + final TreeRowDecorationPaintDetails details = TreeRowDecorationPaintDetails( + canvas: canvas, + rect: rect, + axisDirection: AxisDirection.down, + ); + final BorderRadius radius = BorderRadius.circular(10.0); + decoration.paint(details); + expect(canvas.rect, rect); + expect(canvas.paint.color, const Color(0xffff0000)); + expect(canvas.paint.isAntiAlias, isFalse); + final TestTreeRowBorder border = TestTreeRowBorder( + top: const BorderSide(), + ); + decoration = TreeRowDecoration( + border: border, + borderRadius: radius, + ); + decoration.paint(details); + expect(border.details, details); + expect(border.radius, radius); + }); +} + +class TestCanvas implements Canvas { + final List noSuchMethodInvocations = []; + late Rect rect; + late Paint paint; + + @override + void drawRect(Rect rect, Paint paint) { + this.rect = rect; + this.paint = paint; + } + + @override + void noSuchMethod(Invocation invocation) { + noSuchMethodInvocations.add(invocation); + } +} + +class TestTreeRowBorder extends TreeRowBorder { + TestTreeRowBorder({super.top}); + TreeRowDecorationPaintDetails? details; + BorderRadius? radius; + @override + void paint(TreeRowDecorationPaintDetails details, BorderRadius? radius) { + this.details = details; + this.radius = radius; + } +} diff --git a/packages/two_dimensional_scrollables/test/tree_view/tree_test.dart b/packages/two_dimensional_scrollables/test/tree_view/tree_test.dart new file mode 100644 index 000000000000..bf8924a8ae81 --- /dev/null +++ b/packages/two_dimensional_scrollables/test/tree_view/tree_test.dart @@ -0,0 +1,799 @@ +// Copyright 2013 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'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; + +List> simpleNodeSet = >[ + TreeViewNode('Root 0'), + TreeViewNode( + 'Root 1', + expanded: true, + children: >[ + TreeViewNode('Child 1:0'), + TreeViewNode('Child 1:1'), + ], + ), + TreeViewNode( + 'Root 2', + children: >[ + TreeViewNode('Child 2:0'), + TreeViewNode('Child 2:1'), + ], + ), + TreeViewNode('Root 3'), +]; + +void main() { + group('TreeViewNode', () { + test('getters, toString', () { + final List> children = >[ + TreeViewNode('child'), + ]; + final TreeViewNode node = TreeViewNode( + '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 TreeView 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(), + 'TreeViewNode: parent, depth: null, parent, expanded: true', + ); + expect( + node.children.first.toString(), + 'TreeViewNode: child, depth: null, leaf', + ); + }); + + testWidgets('TreeView sets ups parent and depth properties', + (WidgetTester tester) async { + final List> children = >[ + TreeViewNode('child'), + ]; + final TreeViewNode node = TreeViewNode( + 'parent', + children: children, + expanded: true, + ); + await tester.pumpWidget(MaterialApp( + home: TreeView( + 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 TreeView 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(), + 'TreeViewNode: parent, depth: root, parent, expanded: true', + ); + expect( + node.children.first.toString(), + 'TreeViewNode: child, depth: 1, leaf', + ); + }); + }); + + group('TreeViewController', () { + setUp(() { + // Reset node conditions for each test. + simpleNodeSet = >[ + TreeViewNode('Root 0'), + TreeViewNode( + 'Root 1', + expanded: true, + children: >[ + TreeViewNode('Child 1:0'), + TreeViewNode('Child 1:1'), + ], + ), + TreeViewNode( + 'Root 2', + children: >[ + TreeViewNode('Child 2:0'), + TreeViewNode('Child 2:1'), + ], + ), + TreeViewNode('Root 3'), + ]; + }); + testWidgets('Can set controller on TreeView', (WidgetTester tester) async { + final TreeViewController controller = TreeViewController(); + TreeViewController? returnedController; + await tester.pumpWidget(MaterialApp( + home: TreeView( + tree: simpleNodeSet, + controller: controller, + treeNodeBuilder: ( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) { + returnedController ??= TreeViewController.of(context); + return TreeView.defaultTreeNodeBuilder( + context, + node, + toggleAnimationStyle, + ); + }, + ), + )); + expect(controller, returnedController); + }); + + testWidgets('Can get default controller on TreeView', + (WidgetTester tester) async { + TreeViewController? returnedController; + await tester.pumpWidget(MaterialApp( + home: TreeView( + tree: simpleNodeSet, + treeNodeBuilder: ( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) { + returnedController ??= TreeViewController.maybeOf(context); + return TreeView.defaultTreeNodeBuilder( + context, + node, + toggleAnimationStyle, + ); + }, + ), + )); + expect(returnedController, isNotNull); + }); + + testWidgets('Can get node for TreeViewNode.content', + (WidgetTester tester) async { + final TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: TreeView( + tree: simpleNodeSet, + controller: controller, + ), + )); + + expect(controller.getNodeFor('Root 0'), simpleNodeSet[0]); + }); + + testWidgets('Can get isExpanded for a node', (WidgetTester tester) async { + final TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: TreeView( + 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 TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: TreeView( + 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 TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: TreeView( + 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's expansions status also does not change until the + // animation completes. + 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 TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: TreeView( + 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 TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: TreeView( + 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); + }); + }); + + group('TreeView', () { + setUp(() { + // Reset node conditions for each test. + simpleNodeSet = >[ + TreeViewNode('Root 0'), + TreeViewNode( + 'Root 1', + expanded: true, + children: >[ + TreeViewNode('Child 1:0'), + TreeViewNode('Child 1:1'), + ], + ), + TreeViewNode( + 'Root 2', + children: >[ + TreeViewNode('Child 2:0'), + TreeViewNode('Child 2:1'), + ], + ), + TreeViewNode('Root 3'), + ]; + }); + test('asserts proper axis directions', () { + TreeView? treeView; + expect( + () { + treeView = TreeView( + tree: simpleNodeSet, + verticalDetails: const ScrollableDetails.vertical(reverse: true), + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('verticalDetails.direction == AxisDirection.down'), + ), + ), + ); + expect( + () { + treeView = TreeView( + tree: simpleNodeSet, + horizontalDetails: + const ScrollableDetails.horizontal(reverse: true), + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('horizontalDetails.direction == AxisDirection.right'), + ), + ), + ); + expect(treeView, isNull); + }); + + testWidgets('.toggleNodeWith, onNodeToggle', (WidgetTester tester) async { + final TreeViewController controller = TreeViewController(); + // The default node builder wraps the leading icon with toggleNodeWith. + bool toggled = false; + TreeViewNode? toggledNode; + await tester.pumpWidget(MaterialApp( + home: TreeView( + tree: simpleNodeSet, + controller: controller, + onNodeToggle: (TreeViewNode node) { + toggled = true; + toggledNode = node as TreeViewNode; + }, + ), + )); + 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: TreeView( + tree: simpleNodeSet, + controller: controller, + onNodeToggle: (TreeViewNode node) { + toggled = true; + toggledNode = node as TreeViewNode; + }, + treeNodeBuilder: ( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) { + final Duration animationDuration = toggleAnimationStyle.duration ?? + TreeView.defaultAnimationDuration; + final Curve animationCurve = + toggleAnimationStyle.curve ?? TreeView.defaultAnimationCurve; + // This makes the whole row trigger toggling. + return TreeView.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: TreeView( + tree: simpleNodeSet, + treeNodeBuilder: ( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) { + style ??= toggleAnimationStyle; + return Text(node.content.toString()); + }, + ), + )); + // Default + expect( + style, + AnimationStyle( + duration: TreeView.defaultAnimationDuration, + curve: TreeView.defaultAnimationCurve, + ), + ); + + await tester.pumpWidget(MaterialApp( + home: TreeView( + tree: simpleNodeSet, + toggleAnimationStyle: AnimationStyle.noAnimation, + treeNodeBuilder: ( + BuildContext context, + TreeViewNode 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: TreeView( + tree: simpleNodeSet, + toggleAnimationStyle: AnimationStyle( + curve: Curves.easeIn, + duration: const Duration(milliseconds: 200), + ), + treeNodeBuilder: ( + BuildContext context, + TreeViewNode 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 { + final TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: TreeView( + tree: simpleNodeSet, + controller: controller, + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + simpleNodeSet.add(TreeViewNode('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 { + final TreeViewController controller = TreeViewController(); + await tester.pumpWidget(MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: TreeView( + tree: simpleNodeSet, + controller: controller, + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + simpleNodeSet[1].children.add( + TreeViewNode('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); + }); + }); + + group('TreeViewport', () { + test('asserts proper axis directions', () { + TreeViewport? treeViewport; + expect( + () { + treeViewport = TreeViewport( + verticalOffset: TestOffset(), + verticalAxisDirection: AxisDirection.up, + horizontalOffset: TestOffset(), + horizontalAxisDirection: AxisDirection.right, + delegate: TreeRowBuilderDelegate( + rowCount: 0, + nodeBuilder: (_, __) => const SizedBox(), + rowBuilder: (_) => const TreeRow( + extent: FixedTreeRowExtent(40.0), + ), + ), + activeAnimations: const {}, + rowDepths: const {}, + indentation: 0.0, + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('verticalAxisDirection == AxisDirection.down'), + ), + ), + ); + expect( + () { + treeViewport = TreeViewport( + verticalOffset: TestOffset(), + verticalAxisDirection: AxisDirection.down, + horizontalOffset: TestOffset(), + horizontalAxisDirection: AxisDirection.left, + delegate: TreeRowBuilderDelegate( + rowCount: 0, + nodeBuilder: (_, __) => const SizedBox(), + rowBuilder: (_) => const TreeRow( + extent: FixedTreeRowExtent(40.0), + ), + ), + activeAnimations: const {}, + rowDepths: const {}, + indentation: 0.0, + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('horizontalAxisDirection == AxisDirection.right'), + ), + ), + ); + expect(treeViewport, isNull); + }); + }); +} + +class TestOffset extends ViewportOffset { + TestOffset(); + + @override + bool get allowImplicitScrolling => throw UnimplementedError(); + + @override + Future animateTo( + double to, { + required Duration duration, + required Curve curve, + }) { + throw UnimplementedError(); + } + + @override + bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { + throw UnimplementedError(); + } + + @override + bool applyViewportDimension(double viewportDimension) { + throw UnimplementedError(); + } + + @override + void correctBy(double correction) {} + + @override + bool get hasPixels => throw UnimplementedError(); + + @override + void jumpTo(double pixels) {} + + @override + double get pixels => throw UnimplementedError(); + + @override + ScrollDirection get userScrollDirection => throw UnimplementedError(); +}