From f006d8ef14c5e5fea6a09b656d29b287324aaaa5 Mon Sep 17 00:00:00 2001 From: Paul Kepinski Date: Wed, 15 May 2024 17:30:07 +0200 Subject: [PATCH] feat!: decouple paned view from master detail (#897) --- example/devtools_options.yaml | 1 + example/lib/example.dart | 8 +- example/lib/example_page_items.dart | 9 + example/lib/pages/paned_view.dart | 65 +++ .../master_detail/yaru_landscape_layout.dart | 214 ++------- .../yaru_master_detail_layout_delegate.dart | 81 ---- .../yaru_master_detail_page.dart | 20 +- lib/src/widgets/yaru_paned_view.dart | 228 ++++++++++ .../yaru_paned_view_layout_delegate.dart | 121 ++++++ lib/widgets.dart | 3 +- .../widgets/yaru_master_detail_page_test.dart | 8 +- test/widgets/yaru_paned_view_test.dart | 410 ++++++++++++++++++ 12 files changed, 899 insertions(+), 269 deletions(-) create mode 100644 example/devtools_options.yaml create mode 100644 example/lib/pages/paned_view.dart delete mode 100644 lib/src/widgets/master_detail/yaru_master_detail_layout_delegate.dart create mode 100644 lib/src/widgets/yaru_paned_view.dart create mode 100644 lib/src/widgets/yaru_paned_view_layout_delegate.dart create mode 100644 test/widgets/yaru_paned_view_test.dart diff --git a/example/devtools_options.yaml b/example/devtools_options.yaml new file mode 100644 index 000000000..7e7e7f67d --- /dev/null +++ b/example/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/example/lib/example.dart b/example/lib/example.dart index d091085b9..a66f87d9e 100644 --- a/example/lib/example.dart +++ b/example/lib/example.dart @@ -63,10 +63,10 @@ class _MasterDetailPage extends StatelessWidget { @override Widget build(BuildContext context) { return YaruMasterDetailPage( - layoutDelegate: const YaruMasterResizablePaneDelegate( - initialPaneWidth: 280, - minPageWidth: kYaruMasterDetailBreakpoint / 2, - minPaneWidth: 175, + paneLayoutDelegate: const YaruResizablePaneDelegate( + initialPaneSize: 280, + minPageSize: kYaruMasterDetailBreakpoint / 2, + minPaneSize: 175, ), length: pageItems.length, tileBuilder: (context, index, selected, availableWidth) => YaruMasterTile( diff --git a/example/lib/example_page_items.dart b/example/lib/example_page_items.dart index 6490ce3ba..00a71e3ae 100644 --- a/example/lib/example_page_items.dart +++ b/example/lib/example_page_items.dart @@ -22,6 +22,7 @@ import 'pages/info_page.dart'; import 'pages/navigation_page.dart'; import 'pages/option_button_page.dart'; import 'pages/page_indicator.dart'; +import 'pages/paned_view.dart'; import 'pages/popup_page.dart'; import 'pages/progress_indicator_page.dart'; import 'pages/radio_page.dart'; @@ -194,6 +195,14 @@ final examplePageItems = [ const Icon(YaruIcons.view_more_horizontal), pageBuilder: (_) => const PageIndicatorPage(), ), + PageItem( + title: 'YaruPanedView', + floatingActionButtonBuilder: null, + iconBuilder: (context, selected) => selected + ? const Icon(YaruIcons.sidebar_filled) + : const Icon(YaruIcons.sidebar), + pageBuilder: (_) => const PanedPage(), + ), PageItem( title: 'YaruPopupMenuButton', floatingActionButtonBuilder: (_) => const CodeSnippedButton( diff --git a/example/lib/pages/paned_view.dart b/example/lib/pages/paned_view.dart new file mode 100644 index 000000000..6239af378 --- /dev/null +++ b/example/lib/pages/paned_view.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +class PanedPage extends StatelessWidget { + const PanedPage({super.key}); + + @override + Widget build(BuildContext context) { + final pane = Container( + color: Theme.of(context).colorScheme.onBackground.withOpacity(.025), + child: const Center( + child: Text('pane'), + ), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Divider(), + Flexible( + child: YaruPanedView( + pane: pane, + page: YaruPanedView( + pane: pane, + page: YaruPanedView( + pane: pane, + page: YaruPanedView( + pane: pane, + page: const Center( + child: Text('YaruPanedView Inception'), + ), + layoutDelegate: const YaruResizablePaneDelegate( + initialPaneSize: 200, + minPaneSize: 25, + minPageSize: 25, + paneSide: YaruPaneSide.bottom, + ), + ), + layoutDelegate: const YaruResizablePaneDelegate( + initialPaneSize: 200, + minPaneSize: 25, + minPageSize: 25, + paneSide: YaruPaneSide.end, + ), + ), + layoutDelegate: const YaruResizablePaneDelegate( + initialPaneSize: 200, + minPaneSize: 25, + minPageSize: 50, + paneSide: YaruPaneSide.top, + ), + ), + layoutDelegate: const YaruResizablePaneDelegate( + initialPaneSize: 200, + minPaneSize: 25, + minPageSize: 50, + paneSide: YaruPaneSide.start, + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/widgets/master_detail/yaru_landscape_layout.dart b/lib/src/widgets/master_detail/yaru_landscape_layout.dart index 42f49b01e..5cb7d3fba 100644 --- a/lib/src/widgets/master_detail/yaru_landscape_layout.dart +++ b/lib/src/widgets/master_detail/yaru_landscape_layout.dart @@ -1,14 +1,8 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:yaru/constants.dart'; -import 'package:yaru/foundation.dart' show YaruPageController; -import 'package:yaru/widgets.dart' - show YaruTitleBarTheme, YaruTitleBarThemeData, YaruTitleBarStyle; +import 'package:yaru/yaru.dart'; -import 'yaru_master_detail_layout_delegate.dart'; -import 'yaru_master_detail_page.dart'; -import 'yaru_master_detail_theme.dart'; import 'yaru_master_list_view.dart'; class YaruLandscapeLayout extends StatefulWidget { @@ -22,9 +16,7 @@ class YaruLandscapeLayout extends StatefulWidget { required this.tileBuilder, required this.pageBuilder, this.onSelected, - required this.layoutDelegate, - this.previousPaneWidth, - this.onLeftPaneWidthChange, + required this.paneLayoutDelegate, this.appBar, this.bottomBar, required this.controller, @@ -38,9 +30,7 @@ class YaruLandscapeLayout extends StatefulWidget { final YaruMasterTileBuilder tileBuilder; final IndexedWidgetBuilder pageBuilder; final ValueChanged? onSelected; - final YaruMasterDetailPaneLayoutDelegate layoutDelegate; - final double? previousPaneWidth; - final ValueChanged? onLeftPaneWidthChange; + final YaruPanedViewLayoutDelegate paneLayoutDelegate; final Widget? appBar; final Widget? bottomBar; final YaruPageController controller; @@ -49,23 +39,14 @@ class YaruLandscapeLayout extends StatefulWidget { State createState() => _YaruLandscapeLayoutState(); } -const _kLeftPaneResizingRegionWidth = 4.0; -const _kLeftPaneResizingRegionAnimationDuration = Duration(milliseconds: 250); - class _YaruLandscapeLayoutState extends State { late int _selectedIndex; - double? _paneWidth; - double? _initialPaneWidth; - double _paneWidthMove = 0.0; - - bool _isDragging = false; - bool _isHovering = false; @override void initState() { super.initState(); - _paneWidth = widget.previousPaneWidth; + assert(widget.paneLayoutDelegate.paneSide.isHorizontal); widget.controller.addListener(_controllerCallback); _selectedIndex = max(widget.controller.index, 0); } @@ -98,113 +79,55 @@ class _YaruLandscapeLayoutState extends State { widget.navigatorKey.currentState?.popUntil((route) => route.isFirst); } - void updatePaneWidth({ - required double availableWidth, - required double? candidatePaneWidth, - }) { - final oldPaneWidth = _paneWidth; - - _paneWidth = widget.layoutDelegate.calculatePaneWidth( - availableWidth: availableWidth, - candidatePaneWidth: candidatePaneWidth, - ); - - if (_paneWidth != oldPaneWidth) { - widget.onLeftPaneWidthChange?.call(_paneWidth!); - } - } - @override Widget build(BuildContext context) { final theme = YaruMasterDetailTheme.of(context); - return _maybeBuildGlobalMouseRegion( - LayoutBuilder( - builder: (context, boxConstraints) { - // Avoid left pane to overflow when resizing the window - updatePaneWidth( - availableWidth: boxConstraints.maxWidth, - candidatePaneWidth: _paneWidth, - ); - - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildLeftPane(theme.sideBarColor), - if (theme.includeSeparator != false) _buildVerticalSeparator(), - Expanded( - child: widget.layoutDelegate.allowPaneResizing - ? Stack( - children: [ - _buildPage(context), - _buildLeftPaneResizer(context, boxConstraints), - ], - ) - : _buildPage(context), - ), - ], - ); - }, - ), + return YaruPanedView( + pane: _buildLeftPane(theme), + page: _buildPage(context), + layoutDelegate: widget.paneLayoutDelegate, + includeSeparator: theme.includeSeparator ?? true, + onPaneSizeChange: (size) => _paneWidth = size, ); } - Widget _maybeBuildGlobalMouseRegion(Widget child) { - if (widget.layoutDelegate.allowPaneResizing) { - return MouseRegion( - cursor: _isHovering || _isDragging - ? SystemMouseCursors.resizeColumn - : MouseCursor.defer, - child: child, - ); - } - - return child; - } - - Widget _buildLeftPane(Color? color) { - return SizedBox( - width: _paneWidth, - child: YaruTitleBarTheme( - data: const YaruTitleBarThemeData( - style: YaruTitleBarStyle.undecorated, - ), - child: Column( - children: [ - if (widget.appBar != null) - SizedBox( - height: kYaruTitleBarHeight, - child: widget.appBar!, - ), - Expanded( - child: Container( - color: color, - child: YaruMasterListView( - length: widget.controller.length, - selectedIndex: _selectedIndex, - onTap: _onTap, - builder: widget.tileBuilder, - availableWidth: _paneWidth!, - startUndershoot: widget.appBar != null, - endUndershoot: widget.bottomBar != null, + Widget _buildLeftPane(YaruMasterDetailThemeData theme) { + return Builder( + builder: (context) { + return YaruTitleBarTheme( + data: const YaruTitleBarThemeData( + style: YaruTitleBarStyle.undecorated, + ), + child: Column( + children: [ + if (widget.appBar != null) + SizedBox( + height: kYaruTitleBarHeight, + child: widget.appBar!, + ), + Expanded( + child: Container( + color: theme.sideBarColor, + child: YaruMasterListView( + length: widget.controller.length, + selectedIndex: _selectedIndex, + onTap: _onTap, + builder: widget.tileBuilder, + availableWidth: _paneWidth!, + startUndershoot: widget.appBar != null, + endUndershoot: widget.bottomBar != null, + ), ), ), - ), - if (widget.bottomBar != null) - Material( - color: color, - child: widget.bottomBar, - ), - ], - ), - ), - ); - } - - Widget _buildVerticalSeparator() { - return const VerticalDivider( - thickness: 1, - width: 1, + if (widget.bottomBar != null) + Material( + color: theme.sideBarColor, + child: widget.bottomBar, + ), + ], + ), + ); + }, ); } @@ -237,51 +160,4 @@ class _YaruLandscapeLayoutState extends State { ), ); } - - Widget _buildLeftPaneResizer( - BuildContext context, - BoxConstraints boxConstraints, - ) { - final isRtl = Directionality.of(context) == TextDirection.rtl; - - return Positioned( - width: _kLeftPaneResizingRegionWidth, - top: 0, - bottom: 0, - left: isRtl ? null : 0, - right: isRtl ? 0 : null, - child: AnimatedContainer( - duration: _kLeftPaneResizingRegionAnimationDuration, - color: _isHovering || _isDragging - ? Theme.of(context).dividerColor - : Colors.transparent, - child: MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - onEnter: (event) => setState(() { - _isHovering = true; - }), - onExit: (event) => setState(() { - _isHovering = false; - }), - child: GestureDetector( - onPanStart: (details) => setState(() { - _isDragging = true; - _initialPaneWidth = _paneWidth; - }), - onPanUpdate: (details) => setState(() { - _paneWidthMove += isRtl ? -details.delta.dx : details.delta.dx; - updatePaneWidth( - availableWidth: boxConstraints.maxWidth, - candidatePaneWidth: _initialPaneWidth! + _paneWidthMove, - ); - }), - onPanEnd: (details) => setState(() { - _isDragging = false; - _paneWidthMove = 0.0; - }), - ), - ), - ), - ); - } } diff --git a/lib/src/widgets/master_detail/yaru_master_detail_layout_delegate.dart b/lib/src/widgets/master_detail/yaru_master_detail_layout_delegate.dart deleted file mode 100644 index 7ad0317a3..000000000 --- a/lib/src/widgets/master_detail/yaru_master_detail_layout_delegate.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'yaru_master_detail_page.dart'; - -/// Layout delegate interface which controls a [YaruMasterDetailPage] pane width and resizing capacity. -abstract class YaruMasterDetailPaneLayoutDelegate { - const YaruMasterDetailPaneLayoutDelegate(); - - bool get allowPaneResizing; - - double calculatePaneWidth({ - required double availableWidth, - required double? candidatePaneWidth, - }); -} - -/// Controls a [YaruMasterDetailPage] pane with a fixed width. -class YaruMasterFixedPaneDelegate - implements YaruMasterDetailPaneLayoutDelegate { - /// Controls a [YaruMasterDetailPage] pane with a fixed width. - const YaruMasterFixedPaneDelegate({required this.paneWidth}); - - /// Fixed width of the pane. - final double paneWidth; - - @override - bool get allowPaneResizing => false; - - @override - double calculatePaneWidth({ - required double availableWidth, - required double? candidatePaneWidth, - }) { - // Security in case of a very large [paneWidth]. - if (paneWidth > availableWidth / 2) { - return availableWidth / 2; - } - - return paneWidth; - } -} - -/// Controls a [YaruMasterDetailPage] pane with a resizable width. -class YaruMasterResizablePaneDelegate - implements YaruMasterDetailPaneLayoutDelegate { - /// Controls a [YaruMasterDetailPage] pane with a resizable width. - const YaruMasterResizablePaneDelegate({ - required this.initialPaneWidth, - required this.minPaneWidth, - required this.minPageWidth, - }); - - /// Initial width of a [YaruMasterDetailPage] pane. - final double initialPaneWidth; - - /// Min width of a [YaruMasterDetailPage] pane. - /// [minPaneWidth] has priority on this value. - final double minPaneWidth; - - /// Min width of a [YaruMasterDetailPage] page. - /// This value has priority on [minPaneWidth]. - final double minPageWidth; - - @override - bool get allowPaneResizing => true; - - @override - double calculatePaneWidth({ - required double availableWidth, - required double? candidatePaneWidth, - }) { - final maxWidth = availableWidth - minPageWidth; - candidatePaneWidth = candidatePaneWidth ?? initialPaneWidth; - - if (candidatePaneWidth >= maxWidth) { - return maxWidth; - } else if (candidatePaneWidth < minPaneWidth) { - return minPaneWidth; - } else { - return candidatePaneWidth; - } - } -} diff --git a/lib/src/widgets/master_detail/yaru_master_detail_page.dart b/lib/src/widgets/master_detail/yaru_master_detail_page.dart index 53d6a475d..bfba9abc6 100644 --- a/lib/src/widgets/master_detail/yaru_master_detail_page.dart +++ b/lib/src/widgets/master_detail/yaru_master_detail_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:yaru/foundation.dart' show YaruPageController; +import 'package:yaru/src/widgets/yaru_paned_view_layout_delegate.dart'; import 'yaru_detail_page.dart'; import 'yaru_landscape_layout.dart'; -import 'yaru_master_detail_layout_delegate.dart'; import 'yaru_master_detail_theme.dart'; import 'yaru_master_tile.dart'; import 'yaru_portrait_layout.dart'; @@ -22,7 +22,7 @@ typedef YaruAppBarBuilder = PreferredSizeWidget? Function(BuildContext context); /// A responsive master-detail page. /// /// [YaruMasterDetailPage] automatically switches between portrait and landscape -/// mode depending on [layoutDelegate]. +/// mode depending on [breakpoint]. /// /// ```dart /// YaruMasterDetailPage( @@ -56,8 +56,10 @@ class YaruMasterDetailPage extends StatefulWidget { required this.tileBuilder, required this.pageBuilder, this.emptyBuilder, - this.layoutDelegate = - const YaruMasterFixedPaneDelegate(paneWidth: _kDefaultPaneWidth), + this.paneLayoutDelegate = const YaruFixedPaneDelegate( + paneSize: _kDefaultPaneWidth, + paneSide: YaruPaneSide.start, + ), this.breakpoint, this.appBar, this.appBarBuilder, @@ -91,8 +93,9 @@ class YaruMasterDetailPage extends StatefulWidget { /// A builder that is called if there are no pages to display. final WidgetBuilder? emptyBuilder; - /// Controls the width and resizing capacity of the left pane. - final YaruMasterDetailPaneLayoutDelegate layoutDelegate; + /// Controls the width, side and resizing capacity of the pane. + /// [YaruPanedViewLayoutDelegate.paneSide] need to be horizontal (see: [YaruPaneSide.isHorizontal]). + final YaruPanedViewLayoutDelegate paneLayoutDelegate; /// The breakpoint at which `YaruMasterDetailPage` switches between portrait /// and landscape layouts. @@ -176,7 +179,6 @@ class YaruMasterDetailPage extends StatefulWidget { } class _YaruMasterDetailPageState extends State { - double? _previousPaneWidth; late YaruPageController _controller; late final GlobalKey _navigatorKey; @@ -241,9 +243,7 @@ class _YaruMasterDetailPageState extends State { tileBuilder: widget.tileBuilder, pageBuilder: widget.pageBuilder, onSelected: widget.onSelected, - layoutDelegate: widget.layoutDelegate, - previousPaneWidth: _previousPaneWidth, - onLeftPaneWidthChange: (width) => _previousPaneWidth = width, + paneLayoutDelegate: widget.paneLayoutDelegate, appBar: widget.appBar ?? widget.appBarBuilder?.call(context), bottomBar: widget.bottomBar, controller: _controller, diff --git a/lib/src/widgets/yaru_paned_view.dart b/lib/src/widgets/yaru_paned_view.dart new file mode 100644 index 000000000..99afffb61 --- /dev/null +++ b/lib/src/widgets/yaru_paned_view.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/src/widgets/yaru_paned_view_layout_delegate.dart'; + +class YaruPanedView extends StatefulWidget { + const YaruPanedView({ + super.key, + required this.pane, + required this.page, + required this.layoutDelegate, + this.onPaneSizeChange, + this.includeSeparator = true, + }); + + /// Pane widget child. + final Widget pane; + + /// Page widget child. + final Widget page; + + /// Controls the size, side and resizing capacity of the pane. + final YaruPanedViewLayoutDelegate layoutDelegate; + + /// Called each time the pane size change. + final ValueChanged? onPaneSizeChange; + + /// If true, a [Divider] will be added between the [pane] and the [page]. + final bool includeSeparator; + + @override + State createState() => _YaruPanedViewState(); +} + +const _kLeftPaneResizingRegionSize = 4.0; +const _kLeftPaneResizingRegionAnimationDuration = Duration(milliseconds: 250); + +class _YaruPanedViewState extends State { + double? _paneSize; + + double? _oldPaneSize; + double _paneSizeMove = 0.0; + + bool _isDragging = false; + bool _isHovering = false; + + void updatePaneSize({ + required BoxConstraints constraints, + required double? candidatePaneSize, + }) { + final oldPaneSize = _paneSize; + + _paneSize = widget.layoutDelegate.calculatePaneSize( + availableSpace: widget.layoutDelegate.paneSide.isVertical + ? constraints.maxHeight + : constraints.maxWidth, + candidatePaneSize: candidatePaneSize, + ); + + if (_paneSize != oldPaneSize) { + widget.onPaneSizeChange?.call(_paneSize!); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return _maybeBuildGlobalMouseRegion( + LayoutBuilder( + builder: (context, constraints) { + // Avoid left pane to overflow when resizing the window + updatePaneSize( + constraints: constraints, + candidatePaneSize: _paneSize, + ); + + return _buildFlexContainer([ + _buildPane(), + if (widget.includeSeparator != false) _buildVerticalSeparator(), + Expanded( + child: widget.layoutDelegate.allowPaneResizing + ? Stack( + children: [ + widget.page, + _buildLeftPaneResizer(context, constraints, theme), + ], + ) + : widget.page, + ), + ]); + }, + ), + ); + } + + Widget _maybeBuildGlobalMouseRegion(Widget child) { + if (widget.layoutDelegate.allowPaneResizing) { + return MouseRegion( + cursor: _isHovering || _isDragging + ? widget.layoutDelegate.paneSide.isHorizontal + ? SystemMouseCursors.resizeColumn + : SystemMouseCursors.resizeRow + : MouseCursor.defer, + child: child, + ); + } + + return child; + } + + Widget _buildFlexContainer(List children) { + final isRtl = Directionality.of(context) == TextDirection.rtl; + final top = widget.layoutDelegate.paneSide == YaruPaneSide.top; + final left = widget.layoutDelegate.paneSide == YaruPaneSide.left || + (!isRtl && widget.layoutDelegate.paneSide == YaruPaneSide.start || + isRtl && widget.layoutDelegate.paneSide == YaruPaneSide.end); + + return widget.layoutDelegate.paneSide.isHorizontal + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + textDirection: left ? TextDirection.ltr : TextDirection.rtl, + children: children, + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + verticalDirection: + top ? VerticalDirection.down : VerticalDirection.up, + children: children, + ); + } + + Widget _buildPane() { + return SizedBox( + width: widget.layoutDelegate.paneSide.isHorizontal ? _paneSize : null, + height: widget.layoutDelegate.paneSide.isVertical ? _paneSize : null, + child: widget.pane, + ); + } + + Widget _buildVerticalSeparator() { + return widget.layoutDelegate.paneSide.isVertical + ? const Divider( + thickness: 1, + height: 1, + ) + : const VerticalDivider( + thickness: 1, + width: 1, + ); + } + + Widget _buildLeftPaneResizer( + BuildContext context, + BoxConstraints constraints, + ThemeData theme, + ) { + final isRtl = Directionality.of(context) == TextDirection.rtl; + final top = widget.layoutDelegate.paneSide == YaruPaneSide.top; + final left = widget.layoutDelegate.paneSide == YaruPaneSide.left || + (!isRtl && widget.layoutDelegate.paneSide == YaruPaneSide.start || + isRtl && widget.layoutDelegate.paneSide == YaruPaneSide.end); + final isHorizontal = widget.layoutDelegate.paneSide.isHorizontal; + final isVertical = widget.layoutDelegate.paneSide.isVertical; + + return Positioned( + width: isHorizontal ? _kLeftPaneResizingRegionSize : null, + height: isVertical ? _kLeftPaneResizingRegionSize : null, + top: isVertical + ? top + ? 0 + : null + : 0, + bottom: isVertical + ? top + ? null + : 0 + : 0, + left: isHorizontal + ? left + ? 0 + : null + : 0, + right: isHorizontal + ? left + ? null + : 0 + : 0, + child: AnimatedContainer( + duration: _kLeftPaneResizingRegionAnimationDuration, + color: _isHovering || _isDragging + ? theme.dividerColor + : theme.dividerColor.withOpacity(0), + child: MouseRegion( + onEnter: (event) => setState(() { + _isHovering = true; + }), + onExit: (event) => setState(() { + _isHovering = false; + }), + child: GestureDetector( + onPanStart: (details) => setState(() { + _isDragging = true; + _oldPaneSize = _paneSize; + }), + onPanUpdate: (details) => setState(() { + _paneSizeMove += isHorizontal + ? left + ? details.delta.dx + : -details.delta.dx + : top + ? details.delta.dy + : -details.delta.dy; + updatePaneSize( + constraints: constraints, + candidatePaneSize: _oldPaneSize! + _paneSizeMove, + ); + }), + onPanEnd: (details) => setState(() { + _isDragging = false; + _paneSizeMove = 0.0; + }), + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/yaru_paned_view_layout_delegate.dart b/lib/src/widgets/yaru_paned_view_layout_delegate.dart new file mode 100644 index 000000000..314a52403 --- /dev/null +++ b/lib/src/widgets/yaru_paned_view_layout_delegate.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/src/widgets/yaru_paned_view.dart'; + +/// Define the side placement of a [YaruPanedView] pane. +enum YaruPaneSide { + /// Place pane at top side. + top, + + /// Place pane at bottom side. + bottom, + + /// Place pane at left side. + left, + + /// Place pane at right side. + right, + + /// [TextDirection] aware pane placement. + /// Left side in [TextDirection.ltr] contexts, + /// and right side in [TextDirection.rtl] contexts. + start, + + /// [TextDirection] aware pane placement. + /// Right side in [TextDirection.ltr] contexts, + /// and left side in [TextDirection.rtl] contexts. + end; + + bool get isVertical => this == top || this == bottom; + bool get isHorizontal => + this == left || this == right || this == start || this == end; +} + +/// Layout delegate interface which controls a [YaruPanedView] pane size, side and resizing capacity. +abstract class YaruPanedViewLayoutDelegate { + const YaruPanedViewLayoutDelegate(); + + bool get allowPaneResizing; + YaruPaneSide get paneSide; + + double calculatePaneSize({ + required double availableSpace, + required double? candidatePaneSize, + }); +} + +/// Controls a [YaruPanedView] pane with a fixed size. +class YaruFixedPaneDelegate implements YaruPanedViewLayoutDelegate { + /// Controls a [YaruPanedView] pane with a fixed size. + const YaruFixedPaneDelegate({ + required this.paneSize, + this.paneSide = YaruPaneSide.start, + }); + + /// Fixed size of the pane. + final double paneSize; + + // Side placement of the pane. + @override + final YaruPaneSide paneSide; + + @override + bool get allowPaneResizing => false; + + @override + double calculatePaneSize({ + required double availableSpace, + required double? candidatePaneSize, + }) { + // Security in case of a very large [paneSize]. + if (paneSize > availableSpace / 2) { + return availableSpace / 2; + } + + return paneSize; + } +} + +/// Controls a [YaruPanedView] pane with a resizable size. +class YaruResizablePaneDelegate implements YaruPanedViewLayoutDelegate { + /// Controls a [YaruPanedView] pane with a resizable size. + const YaruResizablePaneDelegate({ + required this.initialPaneSize, + required this.minPaneSize, + required this.minPageSize, + this.paneSide = YaruPaneSide.start, + }); + + final double initialPaneSize; + + /// Min size of a [YaruPanedView] pane. + /// [minPaneSize] has priority on this value. + final double minPaneSize; + + /// Min size of a [YaruPanedView] page. + /// This value has priority on [minPaneSize]. + final double minPageSize; + + // Side placement of the pane. + @override + final YaruPaneSide paneSide; + + @override + bool get allowPaneResizing => true; + + @override + double calculatePaneSize({ + required double availableSpace, + required double? candidatePaneSize, + }) { + final maxSize = availableSpace - minPageSize; + candidatePaneSize = candidatePaneSize ?? initialPaneSize; + + if (candidatePaneSize >= maxSize) { + return maxSize; + } else if (candidatePaneSize < minPaneSize) { + return minPaneSize; + } else { + return candidatePaneSize; + } + } +} diff --git a/lib/widgets.dart b/lib/widgets.dart index 212e71d82..3a7144244 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -1,5 +1,4 @@ export 'src/widgets/master_detail/yaru_detail_page.dart'; -export 'src/widgets/master_detail/yaru_master_detail_layout_delegate.dart'; export 'src/widgets/master_detail/yaru_master_detail_page.dart'; export 'src/widgets/master_detail/yaru_master_detail_theme.dart'; export 'src/widgets/master_detail/yaru_master_tile.dart'; @@ -31,6 +30,8 @@ export 'src/widgets/yaru_option_button.dart'; export 'src/widgets/yaru_page_indicator.dart'; export 'src/widgets/yaru_page_indicator_layout_delegate.dart'; export 'src/widgets/yaru_page_indicator_theme.dart'; +export 'src/widgets/yaru_paned_view.dart'; +export 'src/widgets/yaru_paned_view_layout_delegate.dart'; export 'src/widgets/yaru_popup_menu_button.dart'; export 'src/widgets/yaru_radio.dart'; export 'src/widgets/yaru_radio_button.dart'; diff --git a/test/widgets/yaru_master_detail_page_test.dart b/test/widgets/yaru_master_detail_page_test.dart index 45a920e1b..3a4e6f6b1 100644 --- a/test/widgets/yaru_master_detail_page_test.dart +++ b/test/widgets/yaru_master_detail_page_test.dart @@ -14,8 +14,8 @@ void main() { await tester.pumpScaffold( YaruMasterDetailPage( - layoutDelegate: const YaruMasterFixedPaneDelegate( - paneWidth: kYaruMasterDetailBreakpoint / 3, + paneLayoutDelegate: const YaruFixedPaneDelegate( + paneSize: kYaruMasterDetailBreakpoint / 3, ), length: withSpacer ? 3 : 8, appBar: AppBar(title: const Text('Master')), @@ -67,8 +67,8 @@ void main() { length: length, initialIndex: initialIndex, controller: controller, - layoutDelegate: const YaruMasterFixedPaneDelegate( - paneWidth: kYaruMasterDetailBreakpoint / 3, + paneLayoutDelegate: const YaruFixedPaneDelegate( + paneSize: kYaruMasterDetailBreakpoint / 3, ), appBar: AppBar(title: const Text('Master')), tileBuilder: (context, index, selected, maxWidth) => YaruMasterTile( diff --git a/test/widgets/yaru_paned_view_test.dart b/test/widgets/yaru_paned_view_test.dart new file mode 100644 index 000000000..d438e0eed --- /dev/null +++ b/test/widgets/yaru_paned_view_test.dart @@ -0,0 +1,410 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + testWidgets( + 'Fixed paned view', + (tester) async { + final variant = fixedPaneTestVariant.currentValue!; + + const pane = Key('pane'); + const page = Key('page'); + + await tester.pumpPanedView( + SizedBox( + width: 500, + height: 500, + child: YaruPanedView( + pane: const SizedBox.expand(key: pane), + page: const SizedBox.expand(key: page), + layoutDelegate: variant.layoutDelegate, + ), + ), + textDirection: variant.textDirection, + ); + + final panedViewRect = tester.getRect(find.byType(YaruPanedView)); + final paneRect = tester.getRect(find.byKey(pane)); + final pageRect = tester.getRect(find.byKey(page)); + + expect( + find.byType( + variant.layoutDelegate.paneSide.isHorizontal + ? VerticalDivider + : Divider, + ), + findsOneWidget, + ); + expect(find.byType(GestureDetector), findsNothing); + switch (variant.expectedSide) { + case Side.left: + expect(pageRect.left, greaterThan(paneRect.right)); + break; + case Side.right: + expect(paneRect.left, greaterThan(pageRect.right)); + break; + case Side.top: + expect(pageRect.top, greaterThan(paneRect.bottom)); + break; + case Side.bottom: + expect(paneRect.top, greaterThan(pageRect.bottom)); + break; + } + expect(panedViewRect.width, 500); + expect(paneRect.width, variant.expectedPaneSize.width); + expect(pageRect.width, variant.expectedPageSize.width); + expect(panedViewRect.height, 500); + expect(paneRect.height, variant.expectedPaneSize.height); + expect(pageRect.height, variant.expectedPageSize.height); + }, + variant: fixedPaneTestVariant, + ); + + testWidgets( + 'Resizable pane view', + (tester) async { + final variant = resizablePaneTestVariant.currentValue!; + + const pane = Key('pane'); + const page = Key('page'); + double? onPaneSizeChangeValue; + + await tester.pumpPanedView( + SizedBox( + width: 500, + height: 500, + child: YaruPanedView( + pane: const SizedBox.expand(key: pane), + page: const SizedBox.expand(key: page), + layoutDelegate: variant.layoutDelegate, + onPaneSizeChange: (value) => onPaneSizeChangeValue = value, + ), + ), + textDirection: variant.textDirection, + ); + await tester.pump(); + + var panedViewRect = tester.getRect(find.byType(YaruPanedView)); + var paneRect = tester.getRect(find.byKey(pane)); + var pageRect = tester.getRect(find.byKey(page)); + + expect( + find.byType( + variant.layoutDelegate.paneSide.isHorizontal + ? VerticalDivider + : Divider, + ), + findsOneWidget, + ); + switch (variant.expectedSide) { + case Side.left: + expect(pageRect.left, greaterThan(paneRect.right)); + break; + case Side.right: + expect(paneRect.left, greaterThan(pageRect.right)); + break; + case Side.top: + expect(pageRect.top, greaterThan(paneRect.bottom)); + break; + case Side.bottom: + expect(paneRect.top, greaterThan(pageRect.bottom)); + break; + } + expect(panedViewRect.width, 500); + expect(paneRect.width, variant.expectedInitialPaneSize.width); + expect(pageRect.width, variant.expectedInitialPageSize.width); + expect(panedViewRect.height, 500); + expect(paneRect.height, variant.expectedInitialPaneSize.height); + expect(pageRect.height, variant.expectedInitialPageSize.height); + + await tester.drag(find.byType(GestureDetector), variant.offset); + await tester.pump(); + + panedViewRect = tester.getRect(find.byType(YaruPanedView)); + paneRect = tester.getRect(find.byKey(pane)); + pageRect = tester.getRect(find.byKey(page)); + + expect(paneRect.width, variant.expectedResizedPaneSize.width); + expect(pageRect.width, variant.expectedResizedPageSize.width); + expect(paneRect.height, variant.expectedResizedPaneSize.height); + expect(pageRect.height, variant.expectedResizedPageSize.height); + expect( + onPaneSizeChangeValue, + variant.layoutDelegate.paneSide.isHorizontal + ? variant.expectedResizedPaneSize.width + : variant.expectedResizedPaneSize.height, + ); + }, + variant: resizablePaneTestVariant, + ); + + testWidgets( + 'Resizing', + (tester) async { + const pane = Key('pane'); + const page = Key('page'); + + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + await binding.setSurfaceSize(const Size(500, 500)); + + await tester.pumpPanedView( + const YaruPanedView( + pane: SizedBox.expand(key: pane), + page: SizedBox.expand(key: page), + layoutDelegate: YaruFixedPaneDelegate(paneSize: 200), + ), + ); + + var panedViewRect = tester.getRect(find.byType(YaruPanedView)); + var paneRect = tester.getRect(find.byKey(pane)); + var pageRect = tester.getRect(find.byKey(page)); + + expect(panedViewRect.width, 500); + expect(paneRect.width, 200); + expect(pageRect.width, 299); + + await binding.setSurfaceSize(const Size(200, 500)); + await tester.pump(); + + panedViewRect = tester.getRect(find.byType(YaruPanedView)); + paneRect = tester.getRect(find.byKey(pane)); + pageRect = tester.getRect(find.byKey(page)); + + expect(panedViewRect.width, 200); + expect(paneRect.width, 100); + expect(pageRect.width, 99); + }, + ); +} + +enum Side { + top, + right, + bottom, + left; +} + +class FixedPaneTestVariant { + FixedPaneTestVariant({ + required this.layoutDelegate, + required this.textDirection, + required this.expectedSide, + required this.expectedPaneSize, + required this.expectedPageSize, + }); + + FixedPaneTestVariant.horizontal({ + required YaruPaneSide paneSide, + required this.textDirection, + required this.expectedSide, + }) : layoutDelegate = YaruFixedPaneDelegate( + paneSize: 200, + paneSide: paneSide, + ), + expectedPaneSize = const Size(200, 500), + expectedPageSize = const Size(299, 500); + + FixedPaneTestVariant.vertical({ + required YaruPaneSide paneSide, + required this.textDirection, + required this.expectedSide, + }) : layoutDelegate = YaruFixedPaneDelegate( + paneSize: 200, + paneSide: paneSide, + ), + expectedPaneSize = const Size(500, 200), + expectedPageSize = const Size(500, 299); + + final YaruFixedPaneDelegate layoutDelegate; + final TextDirection textDirection; + final Side expectedSide; + final Size expectedPaneSize; + final Size expectedPageSize; + + @override + String toString() => + 'Fixed, Side: ${layoutDelegate.paneSide}, Text direction: $textDirection, Expected side: $expectedSide'; +} + +class ResizablePaneTestVariant { + ResizablePaneTestVariant({ + required this.layoutDelegate, + required this.textDirection, + required this.expectedSide, + required this.expectedInitialPaneSize, + required this.expectedInitialPageSize, + required this.offset, + required this.expectedResizedPaneSize, + required this.expectedResizedPageSize, + }); + + ResizablePaneTestVariant.horizontal({ + required YaruPaneSide paneSide, + required this.textDirection, + required this.expectedSide, + required double offset, + }) : layoutDelegate = YaruResizablePaneDelegate( + initialPaneSize: 200, + minPaneSize: 20, + minPageSize: 20, + paneSide: paneSide, + ), + expectedInitialPaneSize = const Size(200, 500), + expectedInitialPageSize = const Size(299, 500), + offset = Offset(offset, 0), + expectedResizedPaneSize = + Size(200 + (expectedSide == Side.left ? offset : -offset), 500), + expectedResizedPageSize = + Size(299 - (expectedSide == Side.left ? offset : -offset), 500); + + ResizablePaneTestVariant.vertical({ + required YaruPaneSide paneSide, + required this.textDirection, + required this.expectedSide, + required double offset, + }) : layoutDelegate = YaruResizablePaneDelegate( + initialPaneSize: 200, + minPaneSize: 20, + minPageSize: 20, + paneSide: paneSide, + ), + expectedInitialPaneSize = const Size(500, 200), + expectedInitialPageSize = const Size(500, 299), + offset = Offset(0, offset), + expectedResizedPaneSize = + Size(500, 200 + (expectedSide == Side.top ? offset : -offset)), + expectedResizedPageSize = + Size(500, 299 - (expectedSide == Side.top ? offset : -offset)); + + final YaruResizablePaneDelegate layoutDelegate; + final TextDirection textDirection; + final Side expectedSide; + final Size expectedInitialPaneSize; + final Size expectedInitialPageSize; + final Offset offset; + final Size expectedResizedPaneSize; + final Size expectedResizedPageSize; + + @override + String toString() => + 'Resizable, Side: ${layoutDelegate.paneSide}, Text direction: $textDirection, Expected side: $expectedSide'; +} + +final fixedPaneTestVariant = ValueVariant({ + FixedPaneTestVariant.horizontal( + paneSide: YaruPaneSide.start, + textDirection: TextDirection.ltr, + expectedSide: Side.left, + ), + FixedPaneTestVariant.horizontal( + paneSide: YaruPaneSide.end, + textDirection: TextDirection.ltr, + expectedSide: Side.right, + ), + FixedPaneTestVariant.horizontal( + paneSide: YaruPaneSide.start, + textDirection: TextDirection.rtl, + expectedSide: Side.right, + ), + FixedPaneTestVariant.horizontal( + paneSide: YaruPaneSide.end, + textDirection: TextDirection.rtl, + expectedSide: Side.left, + ), + for (final textDirection in TextDirection.values) ...[ + FixedPaneTestVariant.horizontal( + paneSide: YaruPaneSide.left, + textDirection: textDirection, + expectedSide: Side.left, + ), + FixedPaneTestVariant.horizontal( + paneSide: YaruPaneSide.right, + textDirection: textDirection, + expectedSide: Side.right, + ), + FixedPaneTestVariant.vertical( + paneSide: YaruPaneSide.top, + textDirection: textDirection, + expectedSide: Side.top, + ), + FixedPaneTestVariant.vertical( + paneSide: YaruPaneSide.bottom, + textDirection: textDirection, + expectedSide: Side.bottom, + ), + ], +}); + +final resizablePaneTestVariant = ValueVariant({ + ResizablePaneTestVariant.horizontal( + paneSide: YaruPaneSide.start, + textDirection: TextDirection.ltr, + expectedSide: Side.left, + offset: 20, + ), + ResizablePaneTestVariant.horizontal( + paneSide: YaruPaneSide.end, + textDirection: TextDirection.ltr, + expectedSide: Side.right, + offset: -20, + ), + ResizablePaneTestVariant.horizontal( + paneSide: YaruPaneSide.start, + textDirection: TextDirection.rtl, + expectedSide: Side.right, + offset: -20, + ), + ResizablePaneTestVariant.horizontal( + paneSide: YaruPaneSide.end, + textDirection: TextDirection.rtl, + expectedSide: Side.left, + offset: 20, + ), + for (final textDirection in TextDirection.values) ...[ + ResizablePaneTestVariant.horizontal( + paneSide: YaruPaneSide.left, + textDirection: textDirection, + expectedSide: Side.left, + offset: 20, + ), + ResizablePaneTestVariant.horizontal( + paneSide: YaruPaneSide.right, + textDirection: textDirection, + expectedSide: Side.right, + offset: -20, + ), + ResizablePaneTestVariant.vertical( + paneSide: YaruPaneSide.top, + textDirection: textDirection, + expectedSide: Side.top, + offset: 20, + ), + ResizablePaneTestVariant.vertical( + paneSide: YaruPaneSide.bottom, + textDirection: textDirection, + expectedSide: Side.bottom, + offset: -20, + ), + ], +}); + +extension YaruPanedViewTester on WidgetTester { + Future pumpPanedView( + Widget widget, { + TextDirection textDirection = TextDirection.ltr, + }) { + return pumpWidget( + MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: widget, + ), + ), + ), + ), + ); + } +}