blob: b0cc09207ebc5c00a0dbb28949f358362c061bf9 [file] [log] [blame]
// Copyright 2019 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
part of 'table.dart';
enum _TableRowType { data, columnHeader, columnGroupHeader, filler }
enum _TableRowPartDisplayType { column, columnSpacer, columnGroupSpacer }
/// 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({
super.key,
required this.linkedScrollControllerGroup,
required this.node,
required this.columns,
required this.columnWidths,
required this.onPressed,
this.columnGroups,
this.backgroundColor,
this.expandableColumn,
this.isExpanded = false,
this.isExpandable = false,
this.isSelected = false,
this.isShown = true,
this.enableHoverHandling = false,
this.displayTreeGuidelines = false,
this.searchMatchesNotifier,
this.activeSearchMatchNotifier,
}) : sortColumn = null,
sortDirection = null,
secondarySortColumn = null,
onSortChanged = null,
_rowType = _TableRowType.data,
tall = false;
/// Constructs a [TableRow] that is empty.
const TableRow.filler({
super.key,
required this.linkedScrollControllerGroup,
required this.columns,
required this.columnWidths,
this.columnGroups,
this.backgroundColor,
}) : node = null,
isExpanded = false,
isExpandable = false,
isSelected = false,
onPressed = null,
expandableColumn = null,
isShown = true,
sortColumn = null,
sortDirection = null,
secondarySortColumn = null,
onSortChanged = null,
searchMatchesNotifier = null,
activeSearchMatchNotifier = null,
tall = false,
enableHoverHandling = false,
displayTreeGuidelines = false,
_rowType = _TableRowType.filler;
/// Constructs a [TableRow] that presents the column titles instead
/// of any [node].
const TableRow.tableColumnHeader({
super.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,
this.tall = false,
this.backgroundColor,
}) : node = null,
isExpanded = false,
isExpandable = false,
isSelected = false,
expandableColumn = null,
isShown = true,
searchMatchesNotifier = null,
activeSearchMatchNotifier = null,
displayTreeGuidelines = false,
enableHoverHandling = false,
_rowType = _TableRowType.columnHeader;
/// Constructs a [TableRow] that presents column group titles instead of any
/// [node].
const TableRow.tableColumnGroupHeader({
super.key,
required this.linkedScrollControllerGroup,
required this.columnGroups,
required this.columnWidths,
required this.sortColumn,
required this.sortDirection,
required this.onSortChanged,
this.secondarySortColumn,
this.onPressed,
this.tall = false,
this.backgroundColor,
}) : node = null,
isExpanded = false,
isExpandable = false,
isSelected = false,
expandableColumn = null,
columns = const [],
isShown = true,
searchMatchesNotifier = null,
activeSearchMatchNotifier = null,
displayTreeGuidelines = false,
enableHoverHandling = false,
_rowType = _TableRowType.columnGroupHeader;
final LinkedScrollControllerGroup linkedScrollControllerGroup;
final T? node;
final List<ColumnData<T>> columns;
final List<ColumnGroup>? columnGroups;
final ItemSelectedCallback<T>? onPressed;
final List<double> columnWidths;
final bool isSelected;
final _TableRowType _rowType;
final bool tall;
final bool enableHoverHandling;
/// 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 appear or disappear.
final bool isShown;
/// 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;
final bool displayTreeGuidelines;
@override
State<TableRow<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;
bool isHovering = false;
late FixedExtentDelegate rowExtentDelegate;
late List<_TableRowPartDisplayType> _rowDisplayParts;
@override
void initState() {
super.initState();
contentKey = ValueKey(this);
scrollController = widget.linkedScrollControllerGroup.addAndGet();
_initSearchListeners();
_rowDisplayParts = _rowDisplayPartsHelper();
rowExtentDelegate = FixedExtentDelegate(
computeExtent: (index) {
final columnIndexMap = _columnIndexMapHelper(_rowDisplayParts);
switch (_rowDisplayParts[index]) {
case _TableRowPartDisplayType.column:
final columnIndex = columnIndexMap[index]!;
return widget.columnWidths[columnIndex];
case _TableRowPartDisplayType.columnSpacer:
return columnSpacing;
case _TableRowPartDisplayType.columnGroupSpacer:
return columnGroupSpacingWithPadding;
}
},
computeLength: () => _rowDisplayParts.length,
);
}
@override
void didUpdateWidget(TableRow<T> oldWidget) {
super.didUpdateWidget(oldWidget);
setExpanded(widget.isExpanded);
if (oldWidget.linkedScrollControllerGroup !=
widget.linkedScrollControllerGroup) {
scrollController.dispose();
scrollController = widget.linkedScrollControllerGroup.addAndGet();
}
_rowDisplayParts = _rowDisplayPartsHelper();
rowExtentDelegate.recompute();
cancelListeners();
_initSearchListeners();
}
@override
void dispose() {
super.dispose();
scrollController.dispose();
}
@override
Widget build(BuildContext context) {
final node = widget.node;
final widgetOnPressed = widget.onPressed;
void 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
: defaultHeaderHeight +
(widget.tall ? scaleByFontFactor(densePadding) : 0.0),
child: Material(
color: _searchAwareBackgroundColor(),
child:
onPressed != null
? InkWell(
canRequestFocus: false,
key: contentKey,
onTap: onPressed,
child: row,
)
: row,
),
);
return box;
}
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 colorScheme = Theme.of(context).colorScheme;
final backgroundColor = widget.backgroundColor ?? colorScheme.surface;
if (widget.isSelected) {
return colorScheme.selectedRowBackgroundColor;
}
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:
return Alignment.centerLeft;
}
}
List<_TableRowPartDisplayType> _rowDisplayPartsHelper() {
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,
(_) => _TableRowPartDisplayType.column,
).joinWith(_TableRowPartDisplayType.columnSpacer);
rowDisplayParts.addAll(parts);
}
return rowDisplayParts;
}
Map<int, int> _columnIndexMapHelper(List<_TableRowPartDisplayType> parts) {
// Maps the indices from [rowDisplayParts] to the corresponding index of
// each column in [widget.columns].
final columnIndexMap = <int, int>{};
// Add scope to guarantee [columnIndexTracker] is not used outside of this
// block.
{
var columnIndexTracker = 0;
for (int i = 0; i < parts.length; i++) {
final type = parts[i];
if (type == _TableRowPartDisplayType.column) {
columnIndexMap[i] = columnIndexTracker;
columnIndexTracker++;
}
}
}
return columnIndexMap;
}
/// Presents the content of this row.
Widget tableRowFor(BuildContext context, {VoidCallback? onPressed}) {
Widget columnFor(ColumnData<T> column, double columnWidth) {
Widget? content;
final theme = Theme.of(context);
final node = widget.node;
if (widget._rowType == _TableRowType.filler) {
content = const SizedBox.shrink();
} else if (widget._rowType == _TableRowType.columnHeader) {
Widget defaultHeaderRenderer() => _ColumnHeader(
column: column,
isSortColumn: column == widget.sortColumn,
secondarySortColumn: widget.secondarySortColumn,
sortDirection: widget.sortDirection!,
onSortChanged: widget.onSortChanged,
);
// ignore: avoid-unrelated-type-assertions, false positive.
if (column is ColumnHeaderRenderer) {
content = (column as ColumnHeaderRenderer).buildHeader(
context,
defaultHeaderRenderer,
);
}
// If ColumnHeaderRenderer.build returns null, fall back to the default
// rendering.
content ??= defaultHeaderRenderer();
} 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);
// ignore: avoid-unrelated-type-assertions, false positive.
if (column is ColumnRenderer) {
content = (column as ColumnRenderer).build(
context,
node,
isRowSelected: widget.isSelected,
isRowHovered: isHovering,
onPressed: onPressed,
);
}
// If ColumnRenderer.build returns null, fall back to the default
// rendering.
content ??= Text.rich(
TextSpan(
text: column.getDisplayValue(node),
children: [
if (column.getCaption(node) != null)
TextSpan(
text: ' ${column.getCaption(node)}',
style: const TextStyle(fontStyle: FontStyle.italic),
),
],
style: column.contentTextStyle(
context,
node,
isSelected: widget.isSelected,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: column.contentTextAlignment,
);
final tooltip = column.getTooltip(node);
final richTooltip = column.getRichTooltip(node, context);
if (tooltip.isNotEmpty || richTooltip != null) {
content = DevToolsTooltip(
message: richTooltip == null ? tooltip : null,
richMessage: richTooltip,
waitDuration: tooltipWaitLong,
child: content,
);
}
if (column == widget.expandableColumn) {
final expandIndicator =
widget.isExpandable
? ValueListenableBuilder(
valueListenable: expandController,
builder: (context, _, _) {
return RotationTransition(
turns: expandArrowAnimation,
child: Icon(
Icons.expand_more,
color: theme.colorScheme.onSurface,
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),
);
if (widget.displayTreeGuidelines &&
node != null &&
node is TreeNode &&
column is TreeColumnData) {
content = CustomPaint(
painter: _RowGuidelinePainter(node.level, theme.colorScheme),
child: content,
);
}
return DefaultTextStyle(style: theme.regularTextStyle, child: content);
}
if (widget._rowType == _TableRowType.columnGroupHeader) {
final groups = widget.columnGroups!;
return _ColumnGroupHeaderRow(
groups: groups,
columnWidths: widget.columnWidths,
scrollController: scrollController,
);
}
Widget rowContent = Padding(
padding: const EdgeInsets.symmetric(horizontal: defaultSpacing),
child: ExtentDelegateListView(
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
controller: scrollController,
extentDelegate: rowExtentDelegate,
childrenDelegate: SliverChildBuilderDelegate((context, int i) {
final columnIndexMap = _columnIndexMapHelper(_rowDisplayParts);
final displayTypeForIndex = _rowDisplayParts[i];
switch (displayTypeForIndex) {
case _TableRowPartDisplayType.column:
final index = columnIndexMap[i]!;
return columnFor(
widget.columns[index],
widget.columnWidths[index],
);
case _TableRowPartDisplayType.columnSpacer:
return const SizedBox(
width: columnSpacing,
child: VerticalDivider(width: columnSpacing),
);
case _TableRowPartDisplayType.columnGroupSpacer:
return const _ColumnGroupSpacer();
}
}, childCount: _rowDisplayParts.length),
),
);
if (widget.enableHoverHandling) {
rowContent = MouseRegion(
onEnter: (_) => setState(() => isHovering = true),
onExit: (_) => setState(() => isHovering = false),
child: rowContent,
);
}
if (widget._rowType == _TableRowType.columnHeader) {
return OutlineDecoration.onlyBottom(child: rowContent);
}
return rowContent;
}
@override
bool get isExpanded => widget.isExpanded;
@override
void onExpandChanged(bool expanded) {}
@override
bool shouldShow() => widget.isShown;
}