blob: 7568b9caf0a5e6c405196c093dc0deadb92d1b9b [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide TableRow;
import 'package:flutter/services.dart';
import '../primitives/auto_dispose_mixin.dart';
import '../primitives/flutter_widgets/linked_scroll_controller.dart';
import '../primitives/listenable.dart';
import '../primitives/trees.dart';
import '../primitives/utils.dart';
import '../ui/colors.dart';
import '../ui/search.dart';
import '../ui/utils.dart';
import 'collapsible_mixin.dart';
import 'common_widgets.dart';
import 'table_data.dart';
import 'theme.dart';
import 'tree.dart';
import 'utils.dart';
// TODO(devoncarew): We need to render the selected row with a different
// background color.
/// The maximum height to allow for rows in the table.
///
/// When rows in the table expand or collapse, they will animate between a
/// height of 0 and a height of [defaultRowHeight].
double get defaultRowHeight => scaleByFontFactor(32.0);
const _columnGroupSpacing = 4.0;
const _columnGroupSpacingWithPadding = _columnGroupSpacing + 2 * defaultSpacing;
const _columnSpacing = defaultSpacing;
typedef IndexedScrollableWidgetBuilder = Widget Function(
BuildContext,
LinkedScrollControllerGroup linkedScrollControllerGroup,
int index,
List<double> columnWidths,
);
typedef TableKeyEventHandler = KeyEventResult Function(
RawKeyEvent event,
ScrollController scrollController,
BoxConstraints constraints,
);
enum ScrollKind { up, down, parent }
/// A table that displays in a collection of [data], based on a collection of
/// [ColumnData].
///
/// The [ColumnData] gives this table information about how to size its columns,
/// and how to present each row of `data`.
class FlatTable<T> extends StatefulWidget {
const FlatTable({
Key? key,
required this.columns,
this.columnGroups,
required this.data,
this.autoScrollContent = false,
required this.keyFactory,
required this.onItemSelected,
required this.sortColumn,
required this.sortDirection,
this.secondarySortColumn,
this.onSortChanged,
this.searchMatchesNotifier,
this.activeSearchMatchNotifier,
this.selectionNotifier,
}) : super(key: key);
final List<ColumnData<T>> columns;
final List<ColumnGroup>? columnGroups;
final List<T> data;
/// Auto-scrolling the table to keep new content visible.
final bool autoScrollContent;
/// Factory that creates keys for each row in this table.
final Key Function(T data) keyFactory;
final ItemCallback<T> onItemSelected;
final ColumnData<T> sortColumn;
final SortDirection sortDirection;
final ColumnData<T>? secondarySortColumn;
final Function(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
})? onSortChanged;
final ValueListenable<List<T>>? searchMatchesNotifier;
final ValueListenable<T?>? activeSearchMatchNotifier;
final ValueListenable<T?>? selectionNotifier;
@override
FlatTableState<T> createState() => FlatTableState<T>();
}
class FlatTableState<T> extends State<FlatTable<T>>
implements SortableTable<T> {
late List<T> data;
@override
void initState() {
super.initState();
_initData();
}
@override
void didUpdateWidget(FlatTable<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.sortColumn != oldWidget.sortColumn ||
widget.sortDirection != oldWidget.sortDirection ||
!collectionEquals(widget.data, oldWidget.data)) {
_initData();
}
}
void _initData() {
data = List.from(widget.data);
sortData(
widget.sortColumn,
widget.sortDirection,
secondarySortColumn: widget.secondarySortColumn,
);
}
@visibleForTesting
List<double> computeColumnWidths(double maxWidth) {
// Subtract width from outer padding around table.
maxWidth -= 2 * defaultSpacing;
final numColumnGroupSpacers = widget.columnGroups?.numSpacers ?? 0;
final numColumnSpacers = widget.columns.numSpacers - numColumnGroupSpacers;
maxWidth -= numColumnSpacers * _columnSpacing;
maxWidth -= numColumnGroupSpacers * _columnGroupSpacingWithPadding;
maxWidth = max(0, maxWidth);
double available = maxWidth;
// Columns sorted by increasing minWidth.
final List<ColumnData<T>> sortedColumns = widget.columns.toList()
..sort((a, b) {
if (a.minWidthPx != null && b.minWidthPx != null) {
return a.minWidthPx!.compareTo(b.minWidthPx!);
}
if (a.minWidthPx != null) return -1;
if (b.minWidthPx != null) return 1;
return 0;
});
for (var col in widget.columns) {
if (col.fixedWidthPx != null) {
available -= col.fixedWidthPx!;
} else if (col.minWidthPx != null) {
available -= col.minWidthPx!;
}
}
available = max(available, 0);
int unconstrainedCount = 0;
for (var column in sortedColumns) {
if (column.fixedWidthPx == null && column.minWidthPx == null) {
unconstrainedCount++;
}
}
if (available > 0) {
// We need to find how many columns with minWidth constraints can actually
// be treated as unconstrained as their minWidth constraint is satisfied
// by the width given to all unconstrained columns.
// We use a greedy algorithm to accurately compute this by iterating
// through the columns from the smallest minWidth to largest minWidth
// incrementally adding columns where the minWidth constraint can be
// satisfied using the width given to unconstrained columns.
for (var column in sortedColumns) {
if (column.fixedWidthPx == null && column.minWidthPx != null) {
// Width of this column if it was not clamped to its min width.
// We add column.minWidthPx to the available width because
// available is currently not considering the space reserved for this
// column's min width as available.
final widthIfUnconstrainedByMinWidth =
(available + column.minWidthPx!) / (unconstrainedCount + 1);
if (widthIfUnconstrainedByMinWidth < column.minWidthPx!) {
// We have found the first column that will have to be clamped to
// its min width.
break;
}
// As this column's width in the final layout is greater than its
// min width, we can treat it as unconstrained and give its min width
// back to the available pool.
unconstrainedCount++;
available += column.minWidthPx!;
}
}
}
final unconstrainedWidth =
unconstrainedCount > 0 ? available / unconstrainedCount : available;
int unconstrainedFound = 0;
final widths = <double>[];
for (ColumnData<T> column in widget.columns) {
double? width = column.fixedWidthPx;
if (width == null) {
if (column.minWidthPx != null &&
column.minWidthPx! > unconstrainedWidth) {
width = column.minWidthPx!;
} else {
width = unconstrainedWidth;
unconstrainedFound++;
}
}
widths.add(width);
}
assert(unconstrainedCount == unconstrainedFound);
return widths;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final columnWidths = computeColumnWidths(constraints.maxWidth);
return _Table<T>(
data: data,
columns: widget.columns,
columnGroups: widget.columnGroups,
columnWidths: columnWidths,
autoScrollContent: widget.autoScrollContent,
rowBuilder: _buildRow,
sortColumn: widget.sortColumn,
sortDirection: widget.sortDirection,
secondarySortColumn: widget.secondarySortColumn,
onSortChanged: _sortDataAndUpdate,
activeSearchMatchNotifier: widget.activeSearchMatchNotifier,
rowItemExtent: defaultRowHeight,
);
},
);
}
Widget _buildRow(
BuildContext context,
LinkedScrollControllerGroup linkedScrollControllerGroup,
int index,
List<double> columnWidths,
) {
final node = data[index];
final selectionNotifier =
widget.selectionNotifier ?? FixedValueListenable<T?>(null);
return ValueListenableBuilder<T?>(
valueListenable: selectionNotifier,
builder: (context, selected, _) {
return TableRow<T>(
key: widget.keyFactory(node),
linkedScrollControllerGroup: linkedScrollControllerGroup,
node: node,
onPressed: widget.onItemSelected,
columns: widget.columns,
columnGroups: widget.columnGroups,
columnWidths: columnWidths,
backgroundColor: alternatingColorForIndex(
index,
Theme.of(context).colorScheme,
),
isSelected: node != null && node == selected,
searchMatchesNotifier: widget.searchMatchesNotifier,
activeSearchMatchNotifier: widget.activeSearchMatchNotifier,
);
},
);
}
void _sortDataAndUpdate(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
}) {
setState(() {
sortData(column, direction, secondarySortColumn: secondarySortColumn);
if (widget.onSortChanged != null) {
widget.onSortChanged!(
column,
direction,
secondarySortColumn: secondarySortColumn,
);
}
});
}
@override
void sortData(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
}) {
data.sort(
(T a, T b) => _compareData<T>(
a,
b,
column,
direction,
secondarySortColumn: secondarySortColumn,
),
);
}
}
class Selection<T> {
Selection({
this.node,
this.nodeIndex,
this.nodeIndexCalculator,
this.scrollIntoView = false,
}) : assert(nodeIndex == null || nodeIndexCalculator == null);
Selection.empty()
: node = null,
nodeIndex = null,
nodeIndexCalculator = null,
scrollIntoView = true;
final T? node;
// TODO (carolynqu): get rid of nodeIndex and only use nodeIndexCalculator, https://github.com/flutter/devtools/issues/4266
final int? nodeIndex;
final int Function(T)? nodeIndexCalculator;
final bool scrollIntoView;
}
// TODO(https://github.com/flutter/devtools/issues/1657)
/// A table that shows [TreeNode]s.
///
/// This takes in a collection of [dataRoots], and a collection of [ColumnData].
/// It takes at most one [TreeColumnData].
///
/// The [ColumnData] gives this table information about how to size its
/// columns, how to present each row of `data`, and which rows to show.
///
/// To lay the contents out, this table's [TreeTableState] creates a flattened
/// list of the tree hierarchy. It then uses the nesting level of the
/// deepest row in [dataRoots] to determine how wide to make the [treeColumn].
///
/// If [dataRoots.length] > 1, there are multiple trees in this tree table. In
/// this case, tree table operations (expand all, collapse all, sort, etc.) will
/// be applied to every tree.
class TreeTable<T extends TreeNode<T>> extends StatefulWidget {
TreeTable({
Key? key,
required this.columns,
required this.treeColumn,
required this.dataRoots,
required this.keyFactory,
required this.sortColumn,
required this.sortDirection,
this.secondarySortColumn,
this.selectionNotifier,
this.autoExpandRoots = false,
}) : assert(columns.contains(treeColumn)),
assert(columns.contains(sortColumn)),
super(key: key);
/// The columns to show in this table.
final List<ColumnData<T>> columns;
/// The column of the table to treat as expandable.
final TreeColumnData<T> treeColumn;
/// The tree structures of rows to show in this table.
final List<T> dataRoots;
/// Factory that creates keys for each row in this table.
final Key Function(T) keyFactory;
final ColumnData<T> sortColumn;
final SortDirection sortDirection;
final ColumnData<T>? secondarySortColumn;
final ValueNotifier<Selection<T>>? selectionNotifier;
final bool autoExpandRoots;
@override
TreeTableState<T> createState() => TreeTableState<T>();
}
class TreeTableState<T extends TreeNode<T>> extends State<TreeTable<T>>
with TickerProviderStateMixin, TreeMixin<T>, AutoDisposeMixin
implements SortableTable<T> {
/// The number of items to show when animating out the tree table.
static const itemsToShowWhenAnimating = 50;
List<T> animatingChildren = [];
Set<T> animatingChildrenSet = {};
T? animatingNode;
late List<double> columnWidths;
late List<bool> rootsExpanded;
late FocusNode _focusNode;
late ValueNotifier<Selection<T>> selectionNotifier;
FocusNode? get focusNode => _focusNode;
@override
void initState() {
super.initState();
_initData();
addAutoDisposeListener(selectionNotifier, () {
setState(() {
final node = selectionNotifier.value.node;
if (node != null) {
expandParents(node.parent);
}
});
});
rootsExpanded =
List.generate(dataRoots.length, (index) => dataRoots[index].isExpanded);
_updateItems();
_focusNode = FocusNode(debugLabel: 'table');
autoDisposeFocusNode(_focusNode);
}
void expandParents(T? parent) {
if (parent == null) return;
if (parent.parent?.index != -1) {
expandParents(parent.parent);
}
if (!parent.isExpanded) {
_toggleNode(parent);
}
}
@override
void didUpdateWidget(TreeTable<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget == oldWidget) return;
if (widget.sortColumn.title != oldWidget.sortColumn.title ||
widget.sortDirection != oldWidget.sortDirection ||
!collectionEquals(widget.dataRoots, oldWidget.dataRoots)) {
_initData();
}
rootsExpanded =
List.generate(dataRoots.length, (index) => dataRoots[index].isExpanded);
_updateItems();
}
void _initData() {
dataRoots = List.generate(widget.dataRoots.length, (index) {
final root = widget.dataRoots[index];
if (widget.autoExpandRoots && !root.isExpanded) {
root.expand();
}
return root;
});
dataRoots = List.from(widget.dataRoots);
sortData(
widget.sortColumn,
widget.sortDirection,
secondarySortColumn: widget.secondarySortColumn,
);
selectionNotifier = widget.selectionNotifier ??
ValueNotifier<Selection<T>>(Selection.empty());
}
void _updateItems() {
setState(() {
items = buildFlatList(dataRoots);
// Leave enough space for the animating children during the animation.
columnWidths = _computeColumnWidths([...items, ...animatingChildren]);
});
}
void _onItemsAnimated() {
setState(() {
animatingChildren = [];
animatingChildrenSet = {};
// Remove the animating children from the column width computations.
columnWidths = _computeColumnWidths(items);
});
}
void _onItemPressed(T node, int nodeIndex) {
// Rebuilds the table whenever the tree structure has been updated.
selectionNotifier.value = Selection(
node: node,
nodeIndex: nodeIndex,
);
_toggleNode(node);
}
void _toggleNode(T node) {
if (!node.isExpandable) {
node.leaf();
_updateItems();
return;
}
setState(() {
if (!node.isExpandable) return;
animatingNode = node;
List<T> nodeChildren;
if (node.isExpanded) {
// Compute the children of the expanded node before collapsing.
nodeChildren = buildFlatList([node]);
node.collapse();
} else {
node.expand();
// Compute the children of the collapsed node after expanding it.
nodeChildren = buildFlatList([node]);
}
// The first item will be node itself. We will take the next few items
// to generate a convincing expansion animation without creating
// potentially thousands of widgets.
animatingChildren =
nodeChildren.skip(1).take(itemsToShowWhenAnimating).toList();
animatingChildrenSet = Set.of(animatingChildren);
// Update the tracked expansion of the root node if needed.
if (dataRoots.contains(node)) {
final rootIndex = dataRoots.indexOf(node);
rootsExpanded[rootIndex] = node.isExpanded;
}
});
_updateItems();
}
List<double> _computeColumnWidths(List<T> flattenedList) {
final firstRoot = dataRoots.first;
var deepest = firstRoot;
// This will use the width of all rows in the table, even the rows
// that are hidden by nesting.
// We may want to change this to using a flattened list of only
// the list items that should show right now.
for (var node in flattenedList) {
if (node.level > deepest.level) {
deepest = node;
}
}
final widths = <double>[];
for (var column in widget.columns) {
var width = column.getNodeIndentPx(deepest);
assert(width >= 0.0);
if (column.fixedWidthPx != null) {
width += column.fixedWidthPx!;
} else {
// TODO(djshuckerow): measure the text of the longest content
// to get an idea for how wide this column should be.
// Text measurement is a somewhat expensive algorithm, so we don't
// want to do it for every row in the table, only the row
// we are confident is the widest. A reasonable heuristic is the row
// with the longest text, but because of variable-width fonts, this is
// not always true.
width += _Table.defaultColumnWidth;
}
widths.add(width);
}
return widths;
}
@override
Widget build(BuildContext context) {
return _Table<T>(
data: items,
columns: widget.columns,
columnWidths: columnWidths,
rowBuilder: _buildRow,
sortColumn: widget.sortColumn,
secondarySortColumn: widget.secondarySortColumn,
sortDirection: widget.sortDirection,
onSortChanged: _sortDataAndUpdate,
focusNode: _focusNode,
handleKeyEvent: _handleKeyEvent,
selectionNotifier: selectionNotifier,
);
}
Widget _buildRow(
BuildContext context,
LinkedScrollControllerGroup linkedScrollControllerGroup,
int index,
List<double> columnWidths,
) {
Widget rowForNode(T node) {
node.index = index;
return TableRow<T>(
key: widget.keyFactory(node),
linkedScrollControllerGroup: linkedScrollControllerGroup,
node: node,
onPressed: (item) => _onItemPressed(item, index),
backgroundColor: alternatingColorForIndex(
index,
Theme.of(context).colorScheme,
),
columns: widget.columns,
columnWidths: columnWidths,
expandableColumn: widget.treeColumn,
isSelected: selectionNotifier.value.node == node,
isExpanded: node.isExpanded,
isExpandable: node.isExpandable,
isShown: node.shouldShow(),
expansionChildren:
node != animatingNode || animatingChildrenSet.contains(node)
? null
: [for (var child in animatingChildren) rowForNode(child)],
onExpansionCompleted: _onItemsAnimated,
);
}
final node = items[index];
if (animatingChildrenSet.contains(node)) return const SizedBox();
return rowForNode(node);
}
@override
void sortData(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
}) {
final sortFunction = (T a, T b) => _compareData<T>(
a,
b,
column,
direction,
secondarySortColumn: secondarySortColumn,
);
void _sort(T dataObject) {
dataObject.children
..sort(sortFunction)
..forEach(_sort);
}
dataRoots
..sort(sortFunction)
..forEach(_sort);
}
KeyEventResult _handleKeyEvent(
RawKeyEvent event,
ScrollController scrollController,
BoxConstraints constraints,
) {
if (event is! RawKeyDownEvent) return KeyEventResult.ignored;
// Exit early if we aren't handling the key
if (![
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight
].contains(event.logicalKey)) return KeyEventResult.ignored;
// If there is no selected node, choose the first one.
if (selectionNotifier.value.node == null) {
selectionNotifier.value = Selection(
node: items[0],
nodeIndex: 0,
);
}
assert(
selectionNotifier.value.node == items[selectionNotifier.value.nodeIndex!],
);
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_moveSelection(ScrollKind.down, scrollController, constraints.maxHeight);
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
_moveSelection(ScrollKind.up, scrollController, constraints.maxHeight);
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
// On left arrow collapse the row if it is expanded. If it is not, move
// selection to its parent.
if (selectionNotifier.value.node!.isExpanded) {
_toggleNode(selectionNotifier.value.node!);
} else {
_moveSelection(
ScrollKind.parent,
scrollController,
constraints.maxHeight,
);
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
// On right arrow expand the row if possible, otherwise move selection down.
if (selectionNotifier.value.node!.isExpandable &&
!selectionNotifier.value.node!.isExpanded) {
_toggleNode(selectionNotifier.value.node!);
} else {
_moveSelection(
ScrollKind.down,
scrollController,
constraints.maxHeight,
);
}
}
return KeyEventResult.handled;
}
void _moveSelection(
ScrollKind scrollKind,
ScrollController scrollController,
double viewportHeight,
) {
// get the index of the first item fully visible in the viewport
final firstItemIndex = (scrollController.offset / defaultRowHeight).ceil();
// '-2' in the following calculations is needed because the header row
// occupies space in the viewport so we must subtract 1 for that, and we
// subtract 1 to account for the fact that a partial row could be displayed
// at the top and bottom of the view.
final minCompleteItemsInView =
max((viewportHeight / defaultRowHeight).floor() - 2, 0);
final lastItemIndex = firstItemIndex + minCompleteItemsInView - 1;
late int newSelectedNodeIndex;
final selectionValue = selectionNotifier.value;
final selectedNodeIndex = selectionValue.nodeIndex;
switch (scrollKind) {
case ScrollKind.down:
newSelectedNodeIndex = min(selectedNodeIndex! + 1, items.length - 1);
break;
case ScrollKind.up:
newSelectedNodeIndex = max(selectedNodeIndex! - 1, 0);
break;
case ScrollKind.parent:
newSelectedNodeIndex = selectionValue.node!.parent != null
? max(items.indexOf(selectionValue.node!.parent!), 0)
: 0;
break;
}
final newSelectedNode = items[newSelectedNodeIndex];
final isBelowViewport = newSelectedNodeIndex > lastItemIndex;
final isAboveViewport = newSelectedNodeIndex < firstItemIndex;
if (isBelowViewport) {
// Scroll so selected row is the last full item displayed in the viewport.
// To do this we need to be showing the (minCompleteItemsInView + 1)
// previous item at the top.
scrollController.animateTo(
(newSelectedNodeIndex - minCompleteItemsInView + 1) * defaultRowHeight,
duration: defaultDuration,
curve: defaultCurve,
);
} else if (isAboveViewport) {
scrollController.animateTo(
newSelectedNodeIndex * defaultRowHeight,
duration: defaultDuration,
curve: defaultCurve,
);
}
setState(() {
// We do not need to scroll into view here because we have manually
// managed the scrolling in the above checks for `isBelowViewport` and
// `isAboveViewport`.
selectionNotifier.value = Selection(
node: newSelectedNode,
nodeIndex: newSelectedNodeIndex,
);
});
}
void _sortDataAndUpdate(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
}) {
sortData(column, direction, secondarySortColumn: secondarySortColumn);
_updateItems();
}
}
// TODO(kenz): https://github.com/flutter/devtools/issues/1522. The table code
// needs to be refactored to support flexible column widths.
class _Table<T> extends StatefulWidget {
const _Table({
Key? key,
required this.data,
required this.columns,
required this.columnWidths,
required this.rowBuilder,
required this.sortColumn,
required this.sortDirection,
required this.onSortChanged,
this.columnGroups,
this.secondarySortColumn,
this.focusNode,
this.handleKeyEvent,
this.autoScrollContent = false,
this.selectionNotifier,
this.activeSearchMatchNotifier,
this.rowItemExtent,
}) : super(key: key);
final List<T> data;
final bool autoScrollContent;
final List<ColumnData<T>> columns;
final List<ColumnGroup>? columnGroups;
final List<double> columnWidths;
final IndexedScrollableWidgetBuilder rowBuilder;
final ColumnData<T> sortColumn;
final SortDirection sortDirection;
final ColumnData<T>? secondarySortColumn;
final double? rowItemExtent;
final Function(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
}) onSortChanged;
final FocusNode? focusNode;
final TableKeyEventHandler? handleKeyEvent;
final ValueNotifier<Selection<T>>? selectionNotifier;
final ValueListenable<T?>? activeSearchMatchNotifier;
/// The width to assume for columns that don't specify a width.
static const defaultColumnWidth = 500.0;
@override
_TableState<T> createState() => _TableState<T>();
}
class _TableState<T> extends State<_Table<T>> with AutoDisposeMixin {
late LinkedScrollControllerGroup _linkedHorizontalScrollControllerGroup;
late ColumnData<T> sortColumn;
late SortDirection sortDirection;
late ScrollController scrollController;
@override
void initState() {
super.initState();
_linkedHorizontalScrollControllerGroup = LinkedScrollControllerGroup();
sortColumn = widget.sortColumn;
sortDirection = widget.sortDirection;
scrollController = ScrollController();
_addScrollListener(widget.selectionNotifier);
_initSearchListener();
}
void _addScrollListener(ValueNotifier<Selection<T>>? selectionNotifier) {
if (selectionNotifier != null) {
addAutoDisposeListener(selectionNotifier, () {
WidgetsBinding.instance.addPostFrameCallback((_) {
final Selection<T> selection = selectionNotifier.value;
final T? node = selection.node;
final int Function(T)? nodeIndexCalculator =
selection.nodeIndexCalculator;
final int? nodeIndex = selection.nodeIndex;
if (selection.scrollIntoView && node != null) {
final int selectedDisplayRow = nodeIndexCalculator != null
? nodeIndexCalculator(node)
: nodeIndex!;
final newPos = selectedDisplayRow * defaultRowHeight;
maybeScrollToPosition(scrollController, newPos);
}
});
});
}
}
void _initSearchListener() {
if (widget.activeSearchMatchNotifier != null) {
addAutoDisposeListener(
widget.activeSearchMatchNotifier,
_onActiveSearchChange,
);
}
}
void _onActiveSearchChange() async {
final activeSearch = widget.activeSearchMatchNotifier!.value;
if (activeSearch == null) return;
final index = widget.data.indexOf(activeSearch);
if (index == -1) return;
final y = index * defaultRowHeight;
final indexInView = y > scrollController.offset &&
y < scrollController.offset + scrollController.position.extentInside;
if (!indexInView) {
await scrollController.animateTo(
index * defaultRowHeight,
duration: defaultDuration,
curve: defaultCurve,
);
}
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
/// The width of all columns in the table, with additional padding.
double get tableWidth {
var tableWidth = 2 * defaultSpacing;
final numColumnGroupSpacers = widget.columnGroups?.numSpacers ?? 0;
final numColumnSpacers = widget.columns.numSpacers - numColumnGroupSpacers;
tableWidth += numColumnSpacers * _columnSpacing;
tableWidth += numColumnGroupSpacers * _columnGroupSpacingWithPadding;
for (var columnWidth in widget.columnWidths) {
tableWidth += columnWidth;
}
return tableWidth;
}
Widget _buildItem(BuildContext context, int index) {
return widget.rowBuilder(
context,
_linkedHorizontalScrollControllerGroup,
index,
widget.columnWidths,
);
}
@override
Widget build(BuildContext context) {
// If we're at the end already, scroll to expose the new content.
if (widget.autoScrollContent) {
if (scrollController.hasClients && scrollController.atScrollBottom) {
scrollController.autoScrollToBottom();
}
}
final columnGroups = widget.columnGroups;
return LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: max(
constraints.widthConstraints().maxWidth,
tableWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (columnGroups != null && columnGroups.isNotEmpty)
TableRow<T>.tableColumnGroupHeader(
linkedScrollControllerGroup:
_linkedHorizontalScrollControllerGroup,
columnGroups: columnGroups,
columnWidths: widget.columnWidths,
sortColumn: sortColumn,
sortDirection: sortDirection,
secondarySortColumn: widget.secondarySortColumn,
onSortChanged: _sortData,
),
TableRow<T>.tableColumnHeader(
key: const Key('Table header'),
linkedScrollControllerGroup:
_linkedHorizontalScrollControllerGroup,
columns: widget.columns,
columnGroups: columnGroups,
columnWidths: widget.columnWidths,
sortColumn: sortColumn,
sortDirection: sortDirection,
secondarySortColumn: widget.secondarySortColumn,
onSortChanged: _sortData,
),
Expanded(
child: Scrollbar(
thumbVisibility: true,
controller: scrollController,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (a) => widget.focusNode?.requestFocus(),
child: Focus(
autofocus: true,
onKey: (_, event) => widget.handleKeyEvent != null
? widget.handleKeyEvent!(
event,
scrollController,
constraints,
)
: KeyEventResult.ignored,
focusNode: widget.focusNode,
child: ListView.builder(
controller: scrollController,
itemCount: widget.data.length,
itemExtent: widget.rowItemExtent,
itemBuilder: _buildItem,
),
),
),
),
),
],
),
);
},
);
}
void _sortData(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
}) {
sortDirection = direction;
sortColumn = column;
widget.onSortChanged(
column,
direction,
secondarySortColumn: secondarySortColumn,
);
}
}
/// If a [ColumnData] implements this interface, it can override how that cell
/// is rendered.
abstract class ColumnRenderer<T> {
/// Render the given [data] to a [Widget].
///
/// This method can return `null` to indicate that the default rendering
/// should be used instead.
Widget build(
BuildContext context,
T data, {
bool isRowSelected = false,
VoidCallback? onPressed,
});
}
/// Callback for when a specific item in a table is selected.
typedef ItemCallback<T> = void Function(T item);
/// Presents a [node] as a row in a table.
///
/// When the given [node] is null, this widget will instead present
/// column headings.
@visibleForTesting
class TableRow<T> extends StatefulWidget {
/// Constructs a [TableRow] that presents the column values for
/// [node].
const TableRow({
Key? key,
required this.linkedScrollControllerGroup,
required this.node,
required this.columns,
required this.columnWidths,
required this.onPressed,
this.columnGroups,
this.backgroundColor,
this.expandableColumn,
this.expansionChildren,
this.onExpansionCompleted,
this.isExpanded = false,
this.isExpandable = false,
this.isSelected = false,
this.isShown = true,
this.searchMatchesNotifier,
this.activeSearchMatchNotifier,
}) : sortColumn = null,
sortDirection = null,
secondarySortColumn = null,
onSortChanged = null,
rowType = _TableRowType.data,
super(key: key);
/// Constructs a [TableRow] that presents the column titles instead
/// of any [node].
const TableRow.tableColumnHeader({
Key? key,
required this.linkedScrollControllerGroup,
required this.columns,
required this.columnWidths,
required this.columnGroups,
required this.sortColumn,
required this.sortDirection,
required this.onSortChanged,
this.secondarySortColumn,
this.onPressed,
}) : node = null,
isExpanded = false,
isExpandable = false,
isSelected = false,
expandableColumn = null,
isShown = true,
backgroundColor = null,
expansionChildren = null,
onExpansionCompleted = null,
searchMatchesNotifier = null,
activeSearchMatchNotifier = null,
rowType = _TableRowType.columnHeader,
super(key: key);
/// Constructs a [TableRow] that presents column group titles instead of any
/// [node].
const TableRow.tableColumnGroupHeader({
Key? key,
required this.linkedScrollControllerGroup,
required this.columnGroups,
required this.columnWidths,
required this.sortColumn,
required this.sortDirection,
required this.onSortChanged,
this.secondarySortColumn,
this.onPressed,
}) : node = null,
isExpanded = false,
isExpandable = false,
isSelected = false,
expandableColumn = null,
columns = const [],
isShown = true,
backgroundColor = null,
expansionChildren = null,
onExpansionCompleted = null,
searchMatchesNotifier = null,
activeSearchMatchNotifier = null,
rowType = _TableRowType.columnGroupHeader,
super(key: key);
final LinkedScrollControllerGroup linkedScrollControllerGroup;
final T? node;
final List<ColumnData<T>> columns;
final List<ColumnGroup>? columnGroups;
final ItemCallback<T>? onPressed;
final List<double> columnWidths;
final bool isSelected;
final _TableRowType rowType;
/// Which column, if any, should show expansion affordances
/// and nested rows.
final ColumnData<T>? expandableColumn;
/// Whether or not this row is expanded.
///
/// This dictates the orientation of the expansion arrow
/// that is drawn in the [expandableColumn].
///
/// Only meaningful if [isExpanded] is true.
final bool isExpanded;
/// Whether or not this row can be expanded.
///
/// This dictates whether an expansion arrow is
/// drawn in the [expandableColumn].
final bool isExpandable;
/// Whether or not this row is shown.
///
/// When the value is toggled, this row will animate in or out.
final bool isShown;
/// The children to show when the expand animation of this widget is running.
final List<Widget>? expansionChildren;
final VoidCallback? onExpansionCompleted;
/// The background color of the row.
///
/// If null, defaults to `Theme.of(context).canvasColor`.
final Color? backgroundColor;
final ColumnData<T>? sortColumn;
final SortDirection? sortDirection;
final ColumnData<T>? secondarySortColumn;
final Function(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
})? onSortChanged;
final ValueListenable<List<T>>? searchMatchesNotifier;
final ValueListenable<T?>? activeSearchMatchNotifier;
@override
_TableRowState<T> createState() => _TableRowState<T>();
}
class _TableRowState<T> extends State<TableRow<T>>
with
TickerProviderStateMixin,
CollapsibleAnimationMixin,
AutoDisposeMixin,
SearchableMixin {
Key? contentKey;
late ScrollController scrollController;
bool isSearchMatch = false;
bool isActiveSearchMatch = false;
@override
void initState() {
super.initState();
contentKey = ValueKey(this);
scrollController = widget.linkedScrollControllerGroup.addAndGet();
expandController.addStatusListener((status) {
setState(() {});
if ([AnimationStatus.completed, AnimationStatus.dismissed]
.contains(status) &&
widget.onExpansionCompleted != null) {
widget.onExpansionCompleted!();
}
});
_initSearchListeners();
}
@override
void didUpdateWidget(TableRow<T> oldWidget) {
super.didUpdateWidget(oldWidget);
setExpanded(widget.isExpanded);
if (oldWidget.linkedScrollControllerGroup !=
widget.linkedScrollControllerGroup) {
scrollController.dispose();
scrollController = widget.linkedScrollControllerGroup.addAndGet();
}
cancelListeners();
_initSearchListeners();
}
@override
void dispose() {
super.dispose();
scrollController.dispose();
}
@override
Widget build(BuildContext context) {
final node = widget.node;
final widgetOnPressed = widget.onPressed;
Function()? onPressed;
if (node != null && widgetOnPressed != null) {
onPressed = () => widgetOnPressed(node);
}
final row = tableRowFor(
context,
onPressed: onPressed,
);
final box = SizedBox(
height: widget.rowType == _TableRowType.data
? defaultRowHeight
: areaPaneHeaderHeight,
child: Material(
color: _searchAwareBackgroundColor(),
child: onPressed != null
? InkWell(
canRequestFocus: false,
key: contentKey,
onTap: onPressed,
child: row,
)
: row,
),
);
if (widget.expansionChildren == null) return box;
return AnimatedBuilder(
animation: expandCurve,
builder: (context, child) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
box,
for (var c in widget.expansionChildren!)
SizedBox(
height: defaultRowHeight * expandCurve.value,
child: OverflowBox(
minHeight: 0.0,
maxHeight: defaultRowHeight,
alignment: Alignment.topCenter,
child: c,
),
),
],
);
},
);
}
void _initSearchListeners() {
if (widget.searchMatchesNotifier != null) {
searchMatches = widget.searchMatchesNotifier!.value;
isSearchMatch = searchMatches.contains(widget.node);
addAutoDisposeListener(widget.searchMatchesNotifier, () {
final isPreviousMatch = searchMatches.contains(widget.node);
searchMatches = widget.searchMatchesNotifier!.value;
final isNewMatch = searchMatches.contains(widget.node);
// We only want to rebuild the row if it the match status has changed.
if (isPreviousMatch != isNewMatch) {
setState(() {
isSearchMatch = isNewMatch;
});
}
});
}
if (widget.activeSearchMatchNotifier != null) {
activeSearchMatch = widget.activeSearchMatchNotifier!.value;
isActiveSearchMatch = activeSearchMatch == widget.node;
addAutoDisposeListener(widget.activeSearchMatchNotifier, () {
final isPreviousActiveSearchMatch = activeSearchMatch == widget.node;
activeSearchMatch = widget.activeSearchMatchNotifier!.value;
final isNewActiveSearchMatch = activeSearchMatch == widget.node;
// We only want to rebuild the row if it the match status has changed.
if (isPreviousActiveSearchMatch != isNewActiveSearchMatch) {
setState(() {
isActiveSearchMatch = isNewActiveSearchMatch;
});
}
});
}
}
Color _searchAwareBackgroundColor() {
final backgroundColor =
widget.backgroundColor ?? Theme.of(context).titleSolidBackgroundColor;
if (widget.isSelected) {
return Theme.of(context).selectedRowColor;
}
final searchAwareBackgroundColor = isSearchMatch
? Color.alphaBlend(
isActiveSearchMatch
? activeSearchMatchColorOpaque
: searchMatchColorOpaque,
backgroundColor,
)
: backgroundColor;
return searchAwareBackgroundColor;
}
Alignment _alignmentFor(ColumnData<T> column) {
switch (column.alignment) {
case ColumnAlignment.center:
return Alignment.center;
case ColumnAlignment.right:
return Alignment.centerRight;
case ColumnAlignment.left:
default:
return Alignment.centerLeft;
}
}
TextAlign _textAlignmentFor(ColumnData<T> column) {
switch (column.alignment) {
case ColumnAlignment.center:
return TextAlign.center;
case ColumnAlignment.right:
return TextAlign.right;
case ColumnAlignment.left:
default:
return TextAlign.left;
}
}
MainAxisAlignment _mainAxisAlignmentFor(ColumnData<T> column) {
switch (column.alignment) {
case ColumnAlignment.center:
return MainAxisAlignment.center;
case ColumnAlignment.right:
return MainAxisAlignment.end;
case ColumnAlignment.left:
default:
return MainAxisAlignment.start;
}
}
/// Presents the content of this row.
Widget tableRowFor(BuildContext context, {VoidCallback? onPressed}) {
Widget columnFor(ColumnData<T> column, double columnWidth) {
late Widget content;
final node = widget.node;
if (widget.rowType == _TableRowType.columnHeader) {
// TODO(kenz): clean up and pull all this code into _ColumnHeaderRow
// widget class.
final isSortColumn = column == widget.sortColumn;
final title = Text(
column.title,
overflow: TextOverflow.ellipsis,
);
final headerContent = Row(
mainAxisAlignment: _mainAxisAlignmentFor(column),
children: [
if (isSortColumn && column.supportsSorting) ...[
Icon(
widget.sortDirection == SortDirection.ascending
? Icons.expand_less
: Icons.expand_more,
size: defaultIconSize,
),
const SizedBox(width: densePadding),
],
// TODO: This Flexible wrapper was added to get the
// network_profiler_test.dart tests to pass.
Flexible(
child: column.titleTooltip != null
? DevToolsTooltip(
message: column.titleTooltip,
padding: const EdgeInsets.all(denseSpacing),
child: title,
)
: title,
),
],
);
content = column.includeHeader
? InkWell(
canRequestFocus: false,
onTap: column.supportsSorting
? () => _handleSortChange(
column,
secondarySortColumn: widget.secondarySortColumn,
)
: null,
child: headerContent,
)
: headerContent;
} else if (node != null) {
// TODO(kenz): clean up and pull all this code into _ColumnDataRow
// widget class.
final padding = column.getNodeIndentPx(node);
assert(padding >= 0);
if (column is ColumnRenderer) {
content = (column as ColumnRenderer).build(
context,
node,
isRowSelected: widget.isSelected,
onPressed: onPressed,
);
} else {
content = RichText(
text: TextSpan(
text: column.getDisplayValue(node),
children: [
if (column.getCaption(node) != null)
TextSpan(
text: ' ${column.getCaption(node)}',
style: const TextStyle(
fontStyle: FontStyle.italic,
),
)
],
style: contentTextStyle(column),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: _textAlignmentFor(column),
);
}
final tooltip = column.getTooltip(node);
if (tooltip.isNotEmpty) {
content = DevToolsTooltip(
message: tooltip,
waitDuration: tooltipWaitLong,
child: content,
);
}
if (column == widget.expandableColumn) {
final expandIndicator = widget.isExpandable
? RotationTransition(
turns: expandArrowAnimation,
child: Icon(
Icons.expand_more,
color: widget.isSelected
? defaultSelectionForegroundColor
: null,
size: defaultIconSize,
),
)
: SizedBox(width: defaultIconSize, height: defaultIconSize);
content = Row(
mainAxisSize: MainAxisSize.min,
children: [
expandIndicator,
Expanded(child: content),
],
);
}
content = Padding(
padding: EdgeInsets.only(left: padding),
child: ClipRect(
child: content,
),
);
} else {
throw Exception(
'Expected a non-null node for this table column, but node == null.',
);
}
content = SizedBox(
width: columnWidth,
child: Align(
alignment: _alignmentFor(column),
child: content,
),
);
return content;
}
if (widget.rowType == _TableRowType.columnGroupHeader) {
final groups = widget.columnGroups!;
return _ColumnGroupHeaderRow(
groups: groups,
columnWidths: widget.columnWidths,
scrollController: scrollController,
);
}
final rowDisplayParts = <_TableRowPartDisplayType>[];
final groups = widget.columnGroups;
if (groups != null && groups.isNotEmpty) {
for (int i = 0; i < groups.length; i++) {
final groupParts = List.generate(
groups[i].range.size as int,
(index) => _TableRowPartDisplayType.column,
).joinWith(_TableRowPartDisplayType.columnSpacer);
rowDisplayParts.addAll(groupParts);
if (i < groups.length - 1) {
rowDisplayParts.add(_TableRowPartDisplayType.columnGroupSpacer);
}
}
} else {
final parts = List.generate(
widget.columns.length,
(index) => _TableRowPartDisplayType.column,
).joinWith(_TableRowPartDisplayType.columnSpacer);
rowDisplayParts.addAll(parts);
}
var columnIndexTracker = 0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: defaultSpacing),
child: ListView.builder(
scrollDirection: Axis.horizontal,
controller: scrollController,
itemCount: widget.columns.length + widget.columns.numSpacers,
itemBuilder: (context, int i) {
final displayTypeForIndex = rowDisplayParts[i];
switch (displayTypeForIndex) {
case _TableRowPartDisplayType.column:
final current = columnIndexTracker;
columnIndexTracker++;
return columnFor(
widget.columns[current],
widget.columnWidths[current],
);
case _TableRowPartDisplayType.columnSpacer:
return const SizedBox(
width: _columnSpacing,
child: VerticalDivider(width: _columnSpacing),
);
case _TableRowPartDisplayType.columnGroupSpacer:
return const _ColumnGroupSpacer();
}
},
),
);
}
TextStyle contentTextStyle(ColumnData<T> column) {
final textColor = widget.isSelected
? defaultSelectionForegroundColor
: column.getTextColor(widget.node!);
final fontStyle = Theme.of(context).fixedFontStyle;
return textColor == null ? fontStyle : fontStyle.copyWith(color: textColor);
}
@override
bool get isExpanded => widget.isExpanded;
@override
void onExpandChanged(bool expanded) {}
@override
bool shouldShow() => widget.isShown;
void _handleSortChange(
ColumnData<T> columnData, {
ColumnData<T>? secondarySortColumn,
}) {
SortDirection direction;
if (columnData.title == widget.sortColumn!.title) {
direction = widget.sortDirection!.reverse();
} else if (columnData.numeric) {
direction = SortDirection.descending;
} else {
direction = SortDirection.ascending;
}
widget.onSortChanged!(
columnData,
direction,
secondarySortColumn: secondarySortColumn,
);
}
}
class _ColumnGroupHeaderRow extends StatelessWidget {
const _ColumnGroupHeaderRow({
required this.groups,
required this.columnWidths,
required this.scrollController,
Key? key,
}) : super(key: key);
final List<ColumnGroup> groups;
final List<double> columnWidths;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: defaultSpacing),
decoration: BoxDecoration(
border: Border(
bottom: defaultBorderSide(Theme.of(context)),
),
),
child: ListView.builder(
scrollDirection: Axis.horizontal,
controller: scrollController,
itemCount: groups.length + groups.numSpacers,
itemBuilder: (context, int i) {
if (i % 2 == 1) {
return const _ColumnGroupSpacer();
}
final group = groups[i ~/ 2];
final groupRange = group.range;
double groupWidth = 0.0;
for (int j = groupRange.begin as int; j < groupRange.end; j++) {
final columnWidth = columnWidths[j];
groupWidth += columnWidth;
if (j < groupRange.end - 1) {
groupWidth += _columnSpacing;
}
}
return Container(
alignment: Alignment.center,
width: groupWidth,
child: Text(group.title),
);
},
),
);
}
}
class _ColumnGroupSpacer extends StatelessWidget {
const _ColumnGroupSpacer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: (_columnGroupSpacingWithPadding - _columnGroupSpacing) / 2,
),
child: Container(
width: _columnGroupSpacing,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black,
Theme.of(context).focusColor,
Colors.black,
],
),
),
),
);
}
}
enum _TableRowType {
data,
columnHeader,
columnGroupHeader,
}
enum _TableRowPartDisplayType {
column,
columnSpacer,
columnGroupSpacer,
}
extension _TableListExtension<T> on List<T> {
int get numSpacers => max(0, length - 1);
}
abstract class SortableTable<T> {
void sortData(ColumnData<T> column, SortDirection direction);
}
int _compareFactor(SortDirection direction) =>
direction == SortDirection.ascending ? 1 : -1;
int _compareData<T>(
T a,
T b,
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
}) {
final compare = column.compare(a, b) * _compareFactor(direction);
if (compare != 0 || secondarySortColumn == null) return compare;
return secondarySortColumn.compare(a, b) * _compareFactor(direction);
}