mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 18:10:29 +00:00
fix(ui): keep narrow file trees readable and scrollable
This commit is contained in:
1 parent
5bd57608d2
commit
539ed2bba7
5 files changed
+474
-81
No files matched your search
@@ -0,0 +1,66 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../theme/app_design_tokens.dart';
|
||||
|
||||
/// Scrolls the header and virtualized tree together without freezing columns.
|
||||
class FileTreeHorizontalViewport extends StatelessWidget {
|
||||
const FileTreeHorizontalViewport({
|
||||
super.key,
|
||||
required this.keyPrefix,
|
||||
required this.controller,
|
||||
required this.contentWidth,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String keyPrefix;
|
||||
final ScrollController controller;
|
||||
final double contentWidth;
|
||||
final Widget child;
|
||||
|
||||
static double? widthOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<_FileTreeHorizontalScope>()?.width;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = math.max(constraints.maxWidth, contentWidth);
|
||||
final canScroll = width > constraints.maxWidth + 0.5;
|
||||
return _FileTreeHorizontalScope(
|
||||
width: constraints.maxWidth,
|
||||
child: Scrollbar(
|
||||
key: ValueKey('$keyPrefix-horizontal-scrollbar'),
|
||||
controller: controller,
|
||||
thumbVisibility: canScroll,
|
||||
trackVisibility: canScroll,
|
||||
interactive: true,
|
||||
thickness: 6,
|
||||
scrollbarOrientation: ScrollbarOrientation.bottom,
|
||||
notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: canScroll ? AppDesignTokens.space12 : 0),
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey('$keyPrefix-horizontal-scroll'),
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(width: width, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileTreeHorizontalScope extends InheritedWidget {
|
||||
const _FileTreeHorizontalScope({required this.width, required super.child});
|
||||
|
||||
final double width;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_FileTreeHorizontalScope oldWidget) => width != oldWidget.width;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app_tooltip.dart';
|
||||
import 'file_tree_horizontal_viewport.dart';
|
||||
|
||||
/// Keeps the full name scrollable, with an ellipsis at its visible trailing edge.
|
||||
class FileTreeName extends StatelessWidget {
|
||||
const FileTreeName({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.style,
|
||||
required this.controller,
|
||||
required this.contentWidth,
|
||||
this.treeContentOffset,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final TextStyle style;
|
||||
final ScrollController controller;
|
||||
final double contentWidth;
|
||||
|
||||
/// The name's leading position on the canvas when the whole tree scrolls.
|
||||
/// Null means that only the name scrolls, with the trailing columns frozen.
|
||||
final double? treeContentOffset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final treeViewportWidth = FileTreeHorizontalViewport.widthOf(context);
|
||||
final direction = Directionality.of(context);
|
||||
return AppTooltip(
|
||||
message: name,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
final offset = controller.hasClients ? controller.offset : 0.0;
|
||||
final available = treeContentOffset == null
|
||||
? constraints.maxWidth + offset
|
||||
: (treeViewportWidth ?? constraints.maxWidth) + offset - treeContentOffset!;
|
||||
final width = math.max(
|
||||
0.0,
|
||||
math.min(treeContentOffset == null ? contentWidth : constraints.maxWidth, available),
|
||||
);
|
||||
final text = SizedBox(
|
||||
width: width,
|
||||
child: Text(name, style: style, maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
if (treeContentOffset != null) {
|
||||
return Align(alignment: AlignmentDirectional.centerStart, child: text);
|
||||
}
|
||||
return ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
minWidth: 0,
|
||||
maxWidth: double.infinity,
|
||||
child: Transform.translate(
|
||||
offset: Offset(direction == TextDirection.rtl ? offset : -offset, 0),
|
||||
child: text,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import 'package:flutter/gestures.dart' show Drag;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
|
||||
|
||||
import '../../core/utils/breakpoints.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../util/util.dart';
|
||||
import 'file_tree_horizontal_viewport.dart';
|
||||
import 'file_tree_name.dart';
|
||||
import 'virtual_tree_view.dart';
|
||||
|
||||
class FileTreeItem<T> {
|
||||
@@ -110,6 +113,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
late final Animation<double> _reveal;
|
||||
final _scrollController = ScrollController();
|
||||
final _horizontalScrollController = ScrollController();
|
||||
final _wholeTreeScrollController = ScrollController();
|
||||
Drag? _horizontalDrag;
|
||||
final Map<String, double> _labelWidthCache = {};
|
||||
TextStyle? _labelMeasurementStyle;
|
||||
@@ -163,6 +167,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
_transitionController.dispose();
|
||||
_scrollController.dispose();
|
||||
_horizontalScrollController.dispose();
|
||||
_wholeTreeScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -196,6 +201,8 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
|
||||
// Folder expansion must not switch between frozen and scrolling columns.
|
||||
final scrollWholeTree = treeWidth < Breakpoints.mobile;
|
||||
final horizontalOverflow = _horizontalOverflow(
|
||||
_nodes,
|
||||
baseNameViewport:
|
||||
@@ -204,57 +211,70 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
measureLabel: measureLabel,
|
||||
);
|
||||
|
||||
return OutlinedContainer(
|
||||
child: Column(
|
||||
children: [
|
||||
_FileTreeToolbar(
|
||||
keyPrefix: widget.keyPrefix,
|
||||
headerLeading: widget.headerLeading,
|
||||
allExpanded: _allFoldersExpanded,
|
||||
nameSortDirection: _sortColumn == _FileTreeSortColumn.name
|
||||
? _sortDirection
|
||||
: _FileTreeSortDirection.none,
|
||||
sizeSortDirection: _sortColumn == _FileTreeSortColumn.size
|
||||
? _sortDirection
|
||||
: _FileTreeSortDirection.none,
|
||||
showSizeColumn: widget.showSizeColumn,
|
||||
trailingHeader: widget.trailingHeader,
|
||||
trailingColumnWidth: trailingColumnWidth,
|
||||
onNameSort: () => _toggleSort(_FileTreeSortColumn.name),
|
||||
onSizeSort: () => _toggleSort(_FileTreeSortColumn.size),
|
||||
onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll,
|
||||
),
|
||||
Expanded(
|
||||
child: _nodes.isEmpty
|
||||
? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles)
|
||||
: VirtualTreeView<FileTreeNode<T>>(
|
||||
nodes: _nodes,
|
||||
controller: _scrollController,
|
||||
branchLine: BranchLine.path,
|
||||
rowHeight: widget.rowHeight,
|
||||
padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4),
|
||||
itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5),
|
||||
itemBuilder: (context, entry) => _buildTreeItem(
|
||||
context,
|
||||
entry,
|
||||
nameContentWidth: nameContentWidth,
|
||||
trailingColumnWidth: trailingColumnWidth,
|
||||
hasTrailingColumn: hasTrailingColumn,
|
||||
horizontalScrollEnabled: horizontalOverflow > 0.5,
|
||||
contentTextStyle: labelStyle,
|
||||
),
|
||||
transitionBuilder: _buildTreeTransition,
|
||||
final content = Column(
|
||||
children: [
|
||||
_FileTreeToolbar(
|
||||
keyPrefix: widget.keyPrefix,
|
||||
headerLeading: widget.headerLeading,
|
||||
allExpanded: _allFoldersExpanded,
|
||||
nameSortDirection: _sortColumn == _FileTreeSortColumn.name ? _sortDirection : _FileTreeSortDirection.none,
|
||||
sizeSortDirection: _sortColumn == _FileTreeSortColumn.size ? _sortDirection : _FileTreeSortDirection.none,
|
||||
showSizeColumn: widget.showSizeColumn,
|
||||
trailingHeader: widget.trailingHeader,
|
||||
trailingColumnWidth: trailingColumnWidth,
|
||||
onNameSort: () => _toggleSort(_FileTreeSortColumn.name),
|
||||
onSizeSort: () => _toggleSort(_FileTreeSortColumn.size),
|
||||
onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll,
|
||||
),
|
||||
Expanded(
|
||||
child: _nodes.isEmpty
|
||||
? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles)
|
||||
: VirtualTreeView<FileTreeNode<T>>(
|
||||
nodes: _nodes,
|
||||
controller: _scrollController,
|
||||
branchLine: BranchLine.path,
|
||||
rowHeight: widget.rowHeight,
|
||||
padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4),
|
||||
itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5),
|
||||
itemBuilder: (context, entry) => _buildTreeItem(
|
||||
context,
|
||||
entry,
|
||||
nameContentWidth: nameContentWidth,
|
||||
trailingColumnWidth: trailingColumnWidth,
|
||||
hasTrailingColumn: hasTrailingColumn,
|
||||
horizontalScrollEnabled: horizontalOverflow > 0.5,
|
||||
scrollWholeTree: scrollWholeTree,
|
||||
nameContentOffset:
|
||||
_treeContentOffset + (entry.depth.length - 1) * treeItemInset * 3 + fixedNameChrome,
|
||||
contentTextStyle: labelStyle,
|
||||
),
|
||||
transitionBuilder: _buildTreeTransition,
|
||||
),
|
||||
),
|
||||
if (!scrollWholeTree && _nodes.isNotEmpty && horizontalOverflow > 0.5)
|
||||
_FileNameHorizontalScrollbar(
|
||||
keyPrefix: widget.keyPrefix,
|
||||
controller: _horizontalScrollController,
|
||||
overflow: horizontalOverflow,
|
||||
trailingColumnWidth: trailingColumnWidth,
|
||||
),
|
||||
if (_nodes.isNotEmpty && horizontalOverflow > 0.5)
|
||||
_FileNameHorizontalScrollbar(
|
||||
],
|
||||
);
|
||||
|
||||
return OutlinedContainer(
|
||||
child: scrollWholeTree
|
||||
? FileTreeHorizontalViewport(
|
||||
keyPrefix: widget.keyPrefix,
|
||||
controller: _horizontalScrollController,
|
||||
overflow: horizontalOverflow,
|
||||
trailingColumnWidth: trailingColumnWidth,
|
||||
),
|
||||
],
|
||||
),
|
||||
controller: _wholeTreeScrollController,
|
||||
contentWidth:
|
||||
_treeHorizontalPadding +
|
||||
fixedNameChrome +
|
||||
_treeTrailingGap +
|
||||
trailingColumnWidth +
|
||||
_widestIndentedLabelWidth(_nodes, measureLabel, treeItemInset * 3),
|
||||
child: content,
|
||||
)
|
||||
: content,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -267,6 +287,8 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
required double trailingColumnWidth,
|
||||
required bool hasTrailingColumn,
|
||||
required bool horizontalScrollEnabled,
|
||||
required bool scrollWholeTree,
|
||||
required double nameContentOffset,
|
||||
required TextStyle contentTextStyle,
|
||||
}) {
|
||||
final palette = AppPalette.of(context);
|
||||
@@ -315,36 +337,22 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
child: GestureDetector(
|
||||
key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: horizontalScrollEnabled ? _startNameDrag : null,
|
||||
onHorizontalDragUpdate: horizontalScrollEnabled
|
||||
onHorizontalDragStart: !scrollWholeTree && horizontalScrollEnabled ? _startNameDrag : null,
|
||||
onHorizontalDragUpdate: !scrollWholeTree && horizontalScrollEnabled
|
||||
? (details) => _horizontalDrag?.update(details)
|
||||
: null,
|
||||
onHorizontalDragEnd: horizontalScrollEnabled ? (details) => _horizontalDrag?.end(details) : null,
|
||||
onHorizontalDragCancel: horizontalScrollEnabled ? () => _horizontalDrag?.cancel() : null,
|
||||
child: ClipRect(
|
||||
child: AnimatedBuilder(
|
||||
animation: _horizontalScrollController,
|
||||
builder: (context, child) {
|
||||
final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients
|
||||
? _horizontalScrollController.offset
|
||||
: 0.0;
|
||||
return Transform.translate(offset: Offset(-offset, 0), child: child);
|
||||
},
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: 0,
|
||||
maxWidth: double.infinity,
|
||||
child: SizedBox(
|
||||
width: nameContentWidth,
|
||||
child: Text(
|
||||
data.label,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: contentTextStyle.copyWith(color: palette.textPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onHorizontalDragEnd: !scrollWholeTree && horizontalScrollEnabled
|
||||
? (details) => _horizontalDrag?.end(details)
|
||||
: null,
|
||||
onHorizontalDragCancel: !scrollWholeTree && horizontalScrollEnabled
|
||||
? () => _horizontalDrag?.cancel()
|
||||
: null,
|
||||
child: FileTreeName(
|
||||
name: data.label,
|
||||
style: contentTextStyle.copyWith(color: palette.textPrimary),
|
||||
controller: scrollWholeTree ? _wholeTreeScrollController : _horizontalScrollController,
|
||||
contentWidth: nameContentWidth,
|
||||
treeContentOffset: scrollWholeTree ? nameContentOffset : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1076,6 +1084,22 @@ double _widestLabelWidth<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Functi
|
||||
return widest;
|
||||
}
|
||||
|
||||
double _widestIndentedLabelWidth<T>(
|
||||
List<TreeNode<FileTreeNode<T>>> nodes,
|
||||
double Function(String label) measureLabel,
|
||||
double depthIndent, [
|
||||
int depth = 0,
|
||||
]) {
|
||||
var widest = _treeMinimumNameContentWidth;
|
||||
for (final node in nodes) {
|
||||
final item = node as TreeItemNode<FileTreeNode<T>>;
|
||||
widest = math.max(widest, depth * depthIndent + measureLabel(item.data.label));
|
||||
// Include collapsed descendants so expanding folders keeps the canvas stable.
|
||||
widest = math.max(widest, _widestIndentedLabelWidth(item.children, measureLabel, depthIndent, depth + 1));
|
||||
}
|
||||
return widest;
|
||||
}
|
||||
|
||||
double _measureLabelWidth(
|
||||
String label, {
|
||||
required TextStyle style,
|
||||
|
||||
Reference in new issue
Block a user