diff --git a/lib/src/constants.dart b/lib/src/constants.dart index 310c8866f..16b7c8c88 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -3,3 +3,4 @@ const kYaruDialogTitlePadding = 0.0; const kYaruPageWidth = 500.0; const kYaruContainerRadius = 8.0; const kYaruButtonRadius = 6.0; +const kYaruMasterDetailBreakpoint = 620.0; diff --git a/lib/src/pages/layouts/yaru_landscape_layout.dart b/lib/src/pages/layouts/yaru_landscape_layout.dart index abed05d1a..8b79669b7 100644 --- a/lib/src/pages/layouts/yaru_landscape_layout.dart +++ b/lib/src/pages/layouts/yaru_landscape_layout.dart @@ -15,6 +15,10 @@ class YaruLandscapeLayout extends StatefulWidget { required this.pageBuilder, required this.onSelected, required this.leftPaneWidth, + required this.allowLeftPaneResize, + required this.leftPaneMinWidth, + required this.pageMinWidth, + this.onLeftPaneWidthChange, this.appBar, }); @@ -36,9 +40,21 @@ class YaruLandscapeLayout extends StatefulWidget { /// Callback that returns an index when the page changes. final ValueChanged onSelected; - /// Specifies the width of left pane. + /// Specifies the initial width of left pane. final double leftPaneWidth; + /// If true, allow the left pane to be resized. + final bool allowLeftPaneResize; + + /// If [allowLeftPaneResize], specifies the min-width of the left pane. + final double leftPaneMinWidth; + + /// If [allowLeftPaneResize], callback called when the left pane is resizing. + final Function(double)? onLeftPaneWidthChange; + + /// If [allowLeftPaneResize], specifies the min-width of the page. + final double pageMinWidth; + /// An optional [PreferredSizeWidget] used as the left [AppBar] /// If provided, a second [AppBar] will be created right to it. final PreferredSizeWidget? appBar; @@ -47,13 +63,24 @@ class YaruLandscapeLayout extends StatefulWidget { State createState() => _YaruLandscapeLayoutState(); } +const _kLeftPaneResizingRegionWidth = 4.0; +const _kLeftPaneResizingRegionAnimationDuration = Duration(milliseconds: 250); + class _YaruLandscapeLayoutState extends State { late int _selectedIndex; + late double _leftPaneWidth; + + double _initialPaneWidth = 0.0; + double _paneWidthMove = 0.0; + + bool _isDragging = false; + bool _isHovering = false; @override void initState() { - _selectedIndex = widget.selectedIndex; super.initState(); + _selectedIndex = widget.selectedIndex; + _leftPaneWidth = widget.leftPaneWidth; } void _onTap(int index) { @@ -63,51 +90,150 @@ class _YaruLandscapeLayoutState extends State { @override Widget build(BuildContext context) { - final theme = YaruMasterDetailTheme.of(context); - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - width: widget.leftPaneWidth, - decoration: BoxDecoration( - border: Border( - right: BorderSide( - width: 1, - color: Colors.black.withOpacity(0.1), + return _maybeBuildGlobalMouseRegion( + LayoutBuilder( + builder: (context, boxConstraints) { + // Avoid left pane to overflow when resizing the window + if (widget.allowLeftPaneResize && + _leftPaneWidth >= boxConstraints.maxWidth - widget.pageMinWidth) { + _leftPaneWidth = boxConstraints.maxWidth - widget.pageMinWidth; + widget.onLeftPaneWidthChange?.call(_leftPaneWidth); + } + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildLeftPane(), + Expanded( + child: widget.allowLeftPaneResize + ? Stack( + children: [ + _buildPage(context), + _buildLeftPaneResizer(context, boxConstraints), + ], + ) + : _buildPage(context), ), - ), - ), - child: Scaffold( - appBar: widget.appBar, - body: YaruMasterListView( - length: widget.length, - selectedIndex: _selectedIndex, - onTap: _onTap, - builder: widget.tileBuilder, - ), + ], + ); + }, + ), + ); + } + + Color get _separatorColor => Colors.black.withOpacity(0.1); + + Widget _maybeBuildGlobalMouseRegion(Widget child) { + if (widget.allowLeftPaneResize) { + return MouseRegion( + cursor: _isHovering || _isDragging + ? SystemMouseCursors.resizeColumn + : MouseCursor.defer, + child: child, + ); + } + + return child; + } + + Widget _buildLeftPane() { + return Container( + width: _leftPaneWidth, + decoration: BoxDecoration( + border: Border( + right: BorderSide( + width: 1, + color: _separatorColor, ), ), - Expanded( - child: Theme( - data: Theme.of(context).copyWith( - pageTransitionsTheme: theme.landscapeTransitions, - ), - child: Navigator( - pages: [ - MaterialPage( - key: ValueKey(_selectedIndex), - child: widget.length > _selectedIndex - ? widget.pageBuilder(context, _selectedIndex) - : widget.pageBuilder(context, 0), - ), - ], - onPopPage: (route, result) => route.didPop(result), - observers: [HeroController()], - ), + ), + child: Scaffold( + appBar: widget.appBar, + body: YaruMasterListView( + length: widget.length, + selectedIndex: _selectedIndex, + onTap: _onTap, + builder: widget.tileBuilder, + ), + ), + ); + } + + Widget _buildPage(BuildContext context) { + final theme = YaruMasterDetailTheme.of(context); + + return Theme( + data: Theme.of(context).copyWith( + pageTransitionsTheme: theme.landscapeTransitions, + ), + child: Navigator( + pages: [ + MaterialPage( + key: ValueKey(_selectedIndex), + child: widget.length > _selectedIndex + ? widget.pageBuilder(context, _selectedIndex) + : widget.pageBuilder(context, 0), + ), + ], + onPopPage: (route, result) => route.didPop(result), + observers: [HeroController()], + ), + ); + } + + Widget _buildLeftPaneResizer( + BuildContext context, + BoxConstraints boxConstraints, + ) { + return Positioned( + child: AnimatedContainer( + duration: _kLeftPaneResizingRegionAnimationDuration, + color: + _isHovering || _isDragging ? _separatorColor : 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 = _leftPaneWidth; + }), + onPanUpdate: (details) => setState(() { + _paneWidthMove += details.delta.dx; + final width = _initialPaneWidth + _paneWidthMove; + final maxWidth = boxConstraints.maxWidth - widget.pageMinWidth; + + final previousPaneWidth = _leftPaneWidth; + + if (width >= maxWidth) { + _leftPaneWidth = maxWidth; + } else if (width < widget.leftPaneMinWidth) { + _leftPaneWidth = widget.leftPaneMinWidth; + } else { + _leftPaneWidth = width; + } + + if (previousPaneWidth != _leftPaneWidth) { + widget.onLeftPaneWidthChange?.call(width); + } + }), + onPanEnd: (details) => setState(() { + _isDragging = false; + _paneWidthMove = 0.0; + }), ), ), - ], + ), + width: _kLeftPaneResizingRegionWidth, + top: 0, + bottom: 0, + left: 0, ); } } diff --git a/lib/src/pages/layouts/yaru_master_detail_page.dart b/lib/src/pages/layouts/yaru_master_detail_page.dart index 501128483..940b17af2 100644 --- a/lib/src/pages/layouts/yaru_master_detail_page.dart +++ b/lib/src/pages/layouts/yaru_master_detail_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import '../../constants.dart'; +import 'yaru_detail_page.dart'; import 'yaru_landscape_layout.dart'; import 'yaru_master_detail_theme.dart'; +import 'yaru_master_tile.dart'; import 'yaru_portrait_layout.dart'; typedef YaruMasterDetailBuilder = Widget Function( @@ -13,7 +16,7 @@ class YaruMasterDetailPage extends StatefulWidget { /// Creates a basic responsive layout with yaru theme, /// renders layout based on [width] constrain. /// - /// * if [constraints.maxWidth] < 620 the widget will render [YaruPotraitLayout] + /// * if [constraints.maxWidth] < 620 the widget will render [YaruPortraitLayout] /// * if [constraints.maxWidth] > 620 widget will render [YaruLandscapeLayout] /// /// for example: @@ -32,6 +35,9 @@ class YaruMasterDetailPage extends StatefulWidget { required this.titleBuilder, required this.pageBuilder, required this.leftPaneWidth, + this.allowLeftPaneResize = true, + this.leftPaneMinWidth = 175.0, + this.pageMinWidth = kYaruMasterDetailBreakpoint / 2, this.appBar, this.initialIndex, this.onSelected, @@ -55,9 +61,20 @@ class YaruMasterDetailPage extends StatefulWidget { /// * [YaruDetailPage] final IndexedWidgetBuilder pageBuilder; - /// Specifies the width of left pane. + /// Specifies the initial width of left pane. final double leftPaneWidth; + /// If true, allow the left pane to be resized in landscape layout. + final bool allowLeftPaneResize; + + /// If [allowLeftPaneResize], specifies the min-width of the left pane. + /// Defaults to 175 + final double leftPaneMinWidth; + + /// If [allowLeftPaneResize], specifies the min-width of the page. + /// Defaults to 310 + final double pageMinWidth; + /// An optional custom AppBar for the left pane. final PreferredSizeWidget? appBar; @@ -75,6 +92,8 @@ class _YaruMasterDetailPageState extends State { var _index = -1; var _previousIndex = 0; + late double _leftPaneWidth; + void _setIndex(int index) { _previousIndex = _index; _index = index; @@ -85,6 +104,7 @@ class _YaruMasterDetailPageState extends State { void initState() { super.initState(); _index = widget.initialIndex ?? -1; + _leftPaneWidth = widget.leftPaneWidth; } @override @@ -119,7 +139,11 @@ class _YaruMasterDetailPageState extends State { titleBuilder: widget.titleBuilder, pageBuilder: widget.pageBuilder, onSelected: _setIndex, - leftPaneWidth: widget.leftPaneWidth, + leftPaneWidth: _leftPaneWidth, + allowLeftPaneResize: widget.allowLeftPaneResize, + leftPaneMinWidth: widget.leftPaneMinWidth, + pageMinWidth: widget.pageMinWidth, + onLeftPaneWidthChange: (panWidth) => _leftPaneWidth = panWidth, appBar: widget.appBar, ); } diff --git a/lib/src/pages/layouts/yaru_master_detail_theme.dart b/lib/src/pages/layouts/yaru_master_detail_theme.dart index 4b5c71116..f0ac4092b 100644 --- a/lib/src/pages/layouts/yaru_master_detail_theme.dart +++ b/lib/src/pages/layouts/yaru_master_detail_theme.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; +import '../../constants.dart'; + @immutable class YaruMasterDetailThemeData with Diagnosticable { /// Creates a theme that can be used with [YaruMasterDetailPage]. @@ -15,7 +17,7 @@ class YaruMasterDetailThemeData with Diagnosticable { factory YaruMasterDetailThemeData.fallback() { return const YaruMasterDetailThemeData( - breakpoint: 620, + breakpoint: kYaruMasterDetailBreakpoint, tileSpacing: 6, listPadding: EdgeInsets.symmetric(vertical: 8), portraitTransitions: YaruPageTransitionsTheme.horizontal,