diff --git a/CHANGELOG.md b/CHANGELOG.md index e035216..5c0cea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ -## 0.7.4 +## 0.8.0 +- **BREAKING CHANGE**: Refactor `ShadResizablePanelGroup` in order to react to window resize correctly. The sizes have been normalized. You don't need to provide anymore a pixel size, but a value between 0 and 1 which indicates the percentage of the available space. - Add `onChanged` to `ShadTabs`. - Fix `maxWidth` missing in `ShadSelectForlField`. diff --git a/example/lib/pages/resizable.dart b/example/lib/pages/resizable.dart index a5fcad6..ce218ac 100644 --- a/example/lib/pages/resizable.dart +++ b/example/lib/pages/resizable.dart @@ -18,52 +18,56 @@ class _ResizablePageState extends State { return BaseScaffold( appBarTitle: 'Resizable', children: [ - ShadDecorator( - decoration: ShadDecoration( - merge: false, - border: ShadBorder.all( - color: theme.colorScheme.border, - radius: theme.radius, + SizedBox( + width: 300, + height: 200, + child: ShadDecorator( + decoration: ShadDecoration( + merge: false, + border: ShadBorder.all( + color: theme.colorScheme.border, + radius: theme.radius, + ), ), - ), - child: ClipRRect( - borderRadius: theme.radius, - child: ShadResizablePanelGroup( - mainAxisSize: MainAxisSize.min, - height: 200, - showHandle: true, - children: [ - ShadResizablePanel( - defaultSize: 150, - minSize: 50, - maxSize: 300, - child: Center( - child: Text('One', style: theme.textTheme.large), + child: ClipRRect( + borderRadius: theme.radius, + child: ShadResizablePanelGroup( + mainAxisSize: MainAxisSize.min, + showHandle: true, + children: [ + ShadResizablePanel( + defaultSize: .5, + minSize: 0.1, + maxSize: 0.8, + child: Center( + child: Text('One', style: theme.textTheme.large), + ), ), - ), - ShadResizablePanel( - defaultSize: 150, - child: ShadResizablePanelGroup( - axis: Axis.vertical, - showHandle: true, - children: [ - ShadResizablePanel( - defaultSize: 50, - child: Center( - child: Text('Two', style: theme.textTheme.large)), - ), - ShadResizablePanel( - defaultSize: 150, - child: Align( - child: Text('Three', style: theme.textTheme.large)), - ), - ], + ShadResizablePanel( + defaultSize: 0.5, + child: ShadResizablePanelGroup( + axis: Axis.vertical, + showHandle: true, + children: [ + ShadResizablePanel( + defaultSize: 0.4, + child: Center( + child: Text('Two', style: theme.textTheme.large)), + ), + ShadResizablePanel( + defaultSize: 0.6, + child: Align( + child: + Text('Three', style: theme.textTheme.large)), + ), + ], + ), ), - ), - ], + ], + ), ), ), - ), + ) ], ); } diff --git a/lib/src/components/resizable.dart b/lib/src/components/resizable.dart index 101d03b..38a9b4a 100644 --- a/lib/src/components/resizable.dart +++ b/lib/src/components/resizable.dart @@ -1,10 +1,10 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:shadcn_ui/src/components/image.dart'; import 'package:shadcn_ui/src/theme/components/decorator.dart'; import 'package:shadcn_ui/src/theme/theme.dart'; import 'package:shadcn_ui/src/utils/mouse_cursor_provider.dart'; - import 'package:shadcn_ui/src/utils/provider.dart'; /// The result of resizing a panel @@ -22,7 +22,16 @@ class ShadPanelInfo { double? maxSize, }) : _size = defaultSize, minSize = minSize ?? 0, - maxSize = maxSize ?? double.infinity; + maxSize = maxSize ?? 1.0, + assert( + minSize == null || minSize >= 0 && minSize <= 1.0, + 'minSize must be between 0 and 1', + ), + assert( + maxSize == null || maxSize >= 0 && maxSize <= 1.0, + 'maxSize must be between 0 and 1', + ), + assert(defaultSize >= 0 && defaultSize <= 1); double get size => _size; @@ -65,11 +74,12 @@ class ShadResizableController extends ChangeNotifier { /// - If the resize operation is unsuccessful, the panel info will not b /// updated and the result will be [ShadResizeResult.failedLeading or /// [ShadResizeResult.failedTrailing] depending on the resize direction - ShadResizeResult resize( - int leadingIndex, - int trailingIndex, - double offset, - ) { + ShadResizeResult resize({ + required int leadingIndex, + required int trailingIndex, + required double offset, + required double totalAvailableSpace, + }) { assert( (leadingIndex - trailingIndex).abs() == 1, 'The indexes resized must be adjacent', @@ -77,8 +87,12 @@ class ShadResizableController extends ChangeNotifier { final leadingPanelInfo = getPanelInfo(leadingIndex); final trailingPanelInfo = getPanelInfo(trailingIndex); - final newLeadingSize = leadingPanelInfo.size + offset; - final newTrailingSize = trailingPanelInfo.size - offset; + final newLeadingSize = + (leadingPanelInfo.size * totalAvailableSpace + offset) / + totalAvailableSpace; + final newTrailingSize = + (trailingPanelInfo.size * totalAvailableSpace - offset) / + totalAvailableSpace; if (newLeadingSize < leadingPanelInfo.minSize || newTrailingSize > trailingPanelInfo.maxSize) { @@ -120,7 +134,6 @@ class ShadResizablePanelGroup extends StatefulWidget { this.mainAxisSize, this.textDirection, this.verticalDirection, - this.height, this.controller, this.showHandle, this.handleIconSrc, @@ -133,10 +146,7 @@ class ShadResizablePanelGroup extends StatefulWidget { this.handleDecoration, this.handlePadding, this.handleSize, - }) : assert( - axis == Axis.vertical || height != null, - 'Height must be set for horizontal panels', - ); + }); final Axis axis; final List children; @@ -145,7 +155,6 @@ class ShadResizablePanelGroup extends StatefulWidget { final MainAxisSize? mainAxisSize; final TextDirection? textDirection; final VerticalDirection? verticalDirection; - final double? height; final ShadResizableController? controller; final bool? showHandle; final ShadImageSrc? handleIconSrc; @@ -172,6 +181,8 @@ class ShadResizablePanelGroupState extends State { final dragging = ValueNotifier(false); + BoxConstraints? currentConstraints; + bool get isHorizontal => widget.axis == Axis.horizontal; bool get isVertical => widget.axis == Axis.vertical; @@ -212,9 +223,12 @@ class ShadResizablePanelGroupState extends State { }) { final axisOffset = widget.axis == Axis.horizontal ? offset.dx : offset.dy; final result = controller.resize( - indexOfLeadingPanel, - indexOfTrailingPanel, - axisOffset, + leadingIndex: indexOfLeadingPanel, + trailingIndex: indexOfTrailingPanel, + offset: axisOffset, + totalAvailableSpace: widget.axis == Axis.horizontal + ? currentConstraints!.maxWidth + : currentConstraints!.maxHeight, ); switch (result) { case ShadResizeResult.success: @@ -323,126 +337,139 @@ class ShadResizablePanelGroupState extends State { ), }; - Widget child = Flex( - direction: widget.axis, - mainAxisAlignment: effectiveMainAxisAlignment, - crossAxisAlignment: effectiveCrossAxisAlignment, - mainAxisSize: effectiveMainAxisSize, - textDirection: effectiveTextDirection, - verticalDirection: effectiveVerticalDirection, - children: widget.children, - ); - - // lazy, will be initialized when the handle is needed - late final handle = widget.handleIcon ?? - ShadDecorator( - decoration: effectiveHandleDecoration, - child: Padding( - padding: effectiveHandlePadding, - child: ShadImage( - widget.handleIconSrc ?? - (isHorizontal - ? LucideIcons.gripVertical - : LucideIcons.gripHorizontal), - width: effectiveHandleSize.width, - height: effectiveHandleSize.height, - ), - ), + return LayoutBuilder( + builder: (context, constraints) { + currentConstraints = constraints; + Widget child = Flex( + direction: widget.axis, + mainAxisAlignment: effectiveMainAxisAlignment, + crossAxisAlignment: effectiveCrossAxisAlignment, + mainAxisSize: effectiveMainAxisSize, + textDirection: effectiveTextDirection, + verticalDirection: effectiveVerticalDirection, + children: widget.children.mapIndexed( + (i, e) { + final flex = (effectivesSizes[i] * 1000).toInt(); + return Expanded( + flex: flex, + child: Offstage( + offstage: flex == 0, + child: e, + ), + ); + }, + ).toList(), ); - final dividers = []; - for (var i = 0; i < dividersCount; i++) { - final leadingPosition = effectivesSizes.sublist(0, i + 1).fold( + // lazy, will be initialized when the handle is needed + late final handle = widget.handleIcon ?? + ShadDecorator( + decoration: effectiveHandleDecoration, + child: Padding( + padding: effectiveHandlePadding, + child: ShadImage( + widget.handleIconSrc ?? + (isHorizontal + ? LucideIcons.gripVertical + : LucideIcons.gripHorizontal), + width: effectiveHandleSize.width, + height: effectiveHandleSize.height, + ), + ), + ); + + final dividers = []; + for (var i = 0; i < dividersCount; i++) { + var leadingPosition = effectivesSizes.sublist(0, i + 1).fold( 0, (previousValue, element) => previousValue + element, - ) - - (effectiveDividerSize / 2 + effectiveDividerThickness / 2); - dividers.add( - Positioned( - top: isHorizontal ? 0 : leadingPosition, - left: isHorizontal ? leadingPosition : null, - bottom: isHorizontal ? 0 : null, - child: GestureDetector( - onDoubleTap: widget.onDividerDoubleTap ?? - () { - if (!effectiveResetOnDoubleTap) return; - resetDefaultSizes(i, i + 1); - }, - onHorizontalDragStart: - isHorizontal ? (_) => dragging.value = true : null, - onHorizontalDragEnd: (_) => - isHorizontal ? dragging.value = false : null, - onHorizontalDragCancel: () => - isHorizontal ? dragging.value = false : null, - onHorizontalDragUpdate: (details) => isHorizontal - ? onHandleDrag( - offset: details.delta, - indexOfLeadingPanel: i, - indexOfTrailingPanel: i + 1, - ) - : null, - onVerticalDragStart: - isVertical ? (_) => dragging.value = true : null, - onVerticalDragEnd: (_) => - isVertical ? dragging.value = false : null, - onVerticalDragCancel: () => - isVertical ? dragging.value = false : null, - onVerticalDragUpdate: (details) => isVertical - ? onHandleDrag( - offset: details.delta, - indexOfLeadingPanel: i, - indexOfTrailingPanel: i + 1, - ) - : null, - child: MouseRegion( - onEnter: (_) { - final cursor = switch (widget.axis) { - Axis.horizontal => SystemMouseCursors.resizeLeftRight, - Axis.vertical => SystemMouseCursors.resizeUpDown, - }; - - mouseCursorController.cursor = cursor; - }, - onExit: (details) async { - if (dragging.value) return; - mouseCursorController.cursor = MouseCursor.defer; - }, - child: effectiveShowHandle - ? Stack( - alignment: AlignmentDirectional.center, - children: [ - divider, - handle, - ], - ) - : divider, + ); + leadingPosition = isHorizontal + ? leadingPosition * constraints.maxWidth + : leadingPosition * constraints.maxHeight; + leadingPosition -= + effectiveDividerSize / 2 + effectiveDividerThickness / 2; + + dividers.add( + Positioned( + top: isHorizontal ? 0 : leadingPosition, + left: isHorizontal ? leadingPosition : null, + bottom: isHorizontal ? 0 : null, + child: GestureDetector( + onDoubleTap: widget.onDividerDoubleTap ?? + () { + if (!effectiveResetOnDoubleTap) return; + resetDefaultSizes(i, i + 1); + }, + onHorizontalDragStart: + isHorizontal ? (_) => dragging.value = true : null, + onHorizontalDragEnd: (_) => + isHorizontal ? dragging.value = false : null, + onHorizontalDragCancel: () => + isHorizontal ? dragging.value = false : null, + onHorizontalDragUpdate: (details) => isHorizontal + ? onHandleDrag( + offset: details.delta, + indexOfLeadingPanel: i, + indexOfTrailingPanel: i + 1, + ) + : null, + onVerticalDragStart: + isVertical ? (_) => dragging.value = true : null, + onVerticalDragEnd: (_) => + isVertical ? dragging.value = false : null, + onVerticalDragCancel: () => + isVertical ? dragging.value = false : null, + onVerticalDragUpdate: (details) => isVertical + ? onHandleDrag( + offset: details.delta, + indexOfLeadingPanel: i, + indexOfTrailingPanel: i + 1, + ) + : null, + child: MouseRegion( + onEnter: (_) { + final cursor = switch (widget.axis) { + Axis.horizontal => SystemMouseCursors.resizeLeftRight, + Axis.vertical => SystemMouseCursors.resizeUpDown, + }; + + mouseCursorController.cursor = cursor; + }, + onExit: (details) async { + if (dragging.value) return; + mouseCursorController.cursor = MouseCursor.defer; + }, + child: effectiveShowHandle + ? Stack( + alignment: AlignmentDirectional.center, + children: [ + divider, + handle, + ], + ) + : divider, + ), + ), ), - ), - ), - ); - } - - child = Stack( - alignment: AlignmentDirectional.center, - children: [ - child, - ...dividers, - ], - ); - - final totalSize = - effectivesSizes.reduce((value, element) => value + element); - - child = SizedBox( - height: isVertical ? totalSize : widget.height, - width: isHorizontal ? totalSize : null, - child: child, - ); + ); + } + + child = Stack( + fit: StackFit.expand, + alignment: AlignmentDirectional.center, + children: [ + child, + ...dividers, + ], + ); - return ShadProvider( - data: this, - notifyUpdate: (_) => true, - child: child, + return ShadProvider( + data: this, + notifyUpdate: (_) => true, + child: child, + ); + }, ); } } @@ -499,19 +526,9 @@ class _ShadResizablePanelState extends State { @override Widget build(BuildContext context) { - final inherited = context.watch(); - final axis = inherited.widget.axis; - - final panelSize = inherited.getPanelInfo(index).size; - final effectiveChild = SizedBox( - width: axis == Axis.horizontal ? panelSize : null, - height: axis == Axis.vertical ? panelSize : null, - child: widget.child, - ); - return ClipRRect( clipBehavior: Clip.hardEdge, - child: effectiveChild, + child: widget.child, ); } } diff --git a/playground/lib/pages/resizable.dart b/playground/lib/pages/resizable.dart index 5358c3d..322a422 100644 --- a/playground/lib/pages/resizable.dart +++ b/playground/lib/pages/resizable.dart @@ -42,45 +42,47 @@ class BasicResizable extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - return ShadDecorator( - decoration: ShadDecoration( - border: ShadBorder.all( - color: theme.colorScheme.border, - radius: theme.radius, + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ShadDecorator( + decoration: ShadDecoration( + border: ShadBorder.all( + color: theme.colorScheme.border, + radius: theme.radius, + ), ), - ), - child: ClipRRect( - borderRadius: theme.radius, - child: ShadResizablePanelGroup( - height: 200, - children: [ - ShadResizablePanel( - defaultSize: 200, - minSize: 50, - maxSize: 300, - child: Center( - child: Text('One', style: theme.textTheme.large), + child: ClipRRect( + borderRadius: theme.radius, + child: ShadResizablePanelGroup( + children: [ + ShadResizablePanel( + defaultSize: .5, + minSize: .2, + maxSize: .8, + child: Center( + child: Text('One', style: theme.textTheme.large), + ), ), - ), - ShadResizablePanel( - defaultSize: 200, - child: ShadResizablePanelGroup( - axis: Axis.vertical, - children: [ - ShadResizablePanel( - defaultSize: 50, - child: Center( - child: Text('Two', style: theme.textTheme.large)), - ), - ShadResizablePanel( - defaultSize: 150, - child: Align( - child: Text('Three', style: theme.textTheme.large)), - ), - ], + ShadResizablePanel( + defaultSize: .5, + child: ShadResizablePanelGroup( + axis: Axis.vertical, + children: [ + ShadResizablePanel( + defaultSize: .3, + child: Center( + child: Text('Two', style: theme.textTheme.large)), + ), + ShadResizablePanel( + defaultSize: .7, + child: Align( + child: Text('Three', style: theme.textTheme.large)), + ), + ], + ), ), - ), - ], + ], + ), ), ), ); @@ -93,33 +95,36 @@ class VerticalResizable extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - return ShadDecorator( - decoration: ShadDecoration( - border: ShadBorder.all( - color: theme.colorScheme.border, - radius: theme.radius, + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ShadDecorator( + decoration: ShadDecoration( + border: ShadBorder.all( + color: theme.colorScheme.border, + radius: theme.radius, + ), ), - ), - child: ClipRRect( - borderRadius: theme.radius, - child: ShadResizablePanelGroup( - axis: Axis.vertical, - children: [ - ShadResizablePanel( - defaultSize: 50, - minSize: 25, - child: Center( - child: Text('Header', style: theme.textTheme.large), + child: ClipRRect( + borderRadius: theme.radius, + child: ShadResizablePanelGroup( + axis: Axis.vertical, + children: [ + ShadResizablePanel( + defaultSize: 0.3, + minSize: 0.1, + child: Center( + child: Text('Header', style: theme.textTheme.large), + ), ), - ), - ShadResizablePanel( - defaultSize: 150, - minSize: 25, - child: Center( - child: Text('Footer', style: theme.textTheme.large), + ShadResizablePanel( + defaultSize: 0.7, + minSize: 0.1, + child: Center( + child: Text('Footer', style: theme.textTheme.large), + ), ), - ), - ], + ], + ), ), ), ); @@ -132,35 +137,37 @@ class HandleResizable extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - return ShadDecorator( - decoration: ShadDecoration( - border: ShadBorder.all( - width: 1, - color: theme.colorScheme.border, - radius: theme.radius, + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ShadDecorator( + decoration: ShadDecoration( + border: ShadBorder.all( + width: 1, + color: theme.colorScheme.border, + radius: theme.radius, + ), ), - ), - child: ClipRRect( - borderRadius: theme.radius, - child: ShadResizablePanelGroup( - height: 200, - showHandle: true, - children: [ - ShadResizablePanel( - defaultSize: 200, - minSize: 70, - child: Center( - child: Text('Sidebar', style: theme.textTheme.large), + child: ClipRRect( + borderRadius: theme.radius, + child: ShadResizablePanelGroup( + showHandle: true, + children: [ + ShadResizablePanel( + defaultSize: .5, + minSize: .2, + child: Center( + child: Text('Sidebar', style: theme.textTheme.large), + ), ), - ), - ShadResizablePanel( - defaultSize: 200, - minSize: 80, - child: Center( - child: Text('Content', style: theme.textTheme.large), + ShadResizablePanel( + defaultSize: .5, + minSize: .2, + child: Center( + child: Text('Content', style: theme.textTheme.large), + ), ), - ), - ], + ], + ), ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 577a28e..b8b7716 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: shadcn_ui description: shadcn-ui ported in Flutter. Awesome UI components for Flutter, fully customizable. -version: 0.7.4 +version: 0.8.0 homepage: https://mariuti.com/shadcn-ui repository: https://github.com/nank1ro/flutter-shadcn-ui documentation: https://mariuti.com/shadcn-ui @@ -12,6 +12,7 @@ environment: flutter: ">=1.17.0" dependencies: + collection: ^1.18.0 flutter: sdk: flutter flutter_animate: ^4.5.0