From c093120e73514ced2e2a66e2f99b3b4efee9eaf3 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:21:52 +0700 Subject: [PATCH] fix(library): collapse selection actions before dismissing Keep selection active after the first downward swipe, and cancel only on a second swipe from the compact header. Support expansion by dragging or tapping, pin the header while actions scroll, and cover large text and accessibility behavior. --- lib/widgets/selection_bottom_bar.dart | 330 ++++++++++++++++------- test/selection_bottom_bar_drag_test.dart | 176 +++++++++++- 2 files changed, 408 insertions(+), 98 deletions(-) diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index b094f934..febdc78d 100644 --- a/lib/widgets/selection_bottom_bar.dart +++ b/lib/widgets/selection_bottom_bar.dart @@ -244,72 +244,62 @@ class SelectionBottomBar extends StatelessWidget { final tokens = context.tokens; final colorScheme = Theme.of(context).colorScheme; - final content = SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppSheetHandle(), + final header = LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + if (context.isMornye) + Tooltip( + message: MaterialLocalizations.of(context).closeButtonTooltip, + child: CupertinoButton( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.all(12), + onPressed: onClose, + child: Icon( + CupertinoIcons.xmark, + color: colorScheme.onSurface, + size: 24, + ), + ), + ) + else + IconButton.filledTonal( + onPressed: onClose, + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), - Row( + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (context.isMornye) - Tooltip( - message: MaterialLocalizations.of( - context, - ).closeButtonTooltip, - child: CupertinoButton( - color: MornyeTheme.controlFill(context), - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.all(12), - onPressed: onClose, - child: Icon( - CupertinoIcons.xmark, - color: colorScheme.onSurface, - size: 24, - ), - ), - ) - else - IconButton.filledTonal( - onPressed: onClose, - tooltip: MaterialLocalizations.of( - context, - ).closeButtonTooltip, - icon: const Icon(Icons.close), - style: IconButton.styleFrom( - backgroundColor: colorScheme.surfaceContainerHighest, - ), - ), - const SizedBox(width: 12), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.selectionSelected(selectedCount), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - Text( - allSelected - ? allSelectedLabel ?? - context.l10n.selectionAllSelected - : tapToSelectLabel ?? - context.l10n.downloadedAlbumTapToSelect, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], + Text( + context.l10n.selectionSelected(selectedCount), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), ), + Text( + allSelected + ? allSelectedLabel ?? context.l10n.selectionAllSelected + : tapToSelectLabel ?? + context.l10n.downloadedAlbumTapToSelect, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), - if (context.isMornye) - CupertinoButton( + ConstrainedBox( + constraints: BoxConstraints(maxWidth: constraints.maxWidth * 0.4), + child: context.isMornye + ? CupertinoButton( padding: const EdgeInsets.symmetric(horizontal: 8), onPressed: onToggleSelectAll, child: Text( @@ -321,8 +311,7 @@ class SelectionBottomBar extends StatelessWidget { ), ), ) - else - TextButton.icon( + : TextButton.icon( onPressed: onToggleSelectAll, icon: Icon( allSelected ? Icons.deselect : Icons.select_all, @@ -337,33 +326,18 @@ class SelectionBottomBar extends StatelessWidget { foregroundColor: colorScheme.primary, ), ), - ], - ), - - const SizedBox(height: 12), - - ...children, - ], - ), + ), + ], ), ); return _SelectionPanelDrag( onClose: onClose, - builder: (scrollController) { - final boundedContent = ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height * 0.72, - ), - child: SingleChildScrollView( - controller: scrollController, - physics: const AlwaysScrollableScrollPhysics( - parent: ClampingScrollPhysics(), - ), - child: content, - ), - ); + header: header, + bottomPadding: bottomPadding, + children: children, + surfaceBuilder: (content) { if (context.isMornye) { - return MornyeGlassPanel.overlay(child: boundedContent); + return MornyeGlassPanel.overlay(child: content); } return Container( decoration: BoxDecoration( @@ -379,45 +353,100 @@ class SelectionBottomBar extends StatelessWidget { ), ], ), - child: boundedContent, + child: content, ); }, ); } } -/// Shares the scrollable's gesture: downward motion at its top pulls the whole -/// surface instead of bouncing the header inside a stationary glass panel. +/// Keeps selection alive when the actions are tucked away. One downward +/// gesture collapses the actions; only a new gesture on the compact header +/// dismisses selection. Action scrolling never hides the header. class _SelectionPanelDrag extends StatefulWidget { - const _SelectionPanelDrag({required this.onClose, required this.builder}); + const _SelectionPanelDrag({ + required this.onClose, + required this.header, + required this.bottomPadding, + required this.children, + required this.surfaceBuilder, + }); final VoidCallback onClose; - final Widget Function(ScrollController) builder; + final Widget header; + final double bottomPadding; + final List children; + final Widget Function(Widget) surfaceBuilder; @override State<_SelectionPanelDrag> createState() => _SelectionPanelDragState(); } class _SelectionPanelDragState extends State<_SelectionPanelDrag> - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { late final AnimationController _offset = AnimationController.unbounded( vsync: this, ); late final _SelectionPanelScrollController _scroll = _SelectionPanelScrollController(_drag, _settle); + late final AnimationController _expansion = AnimationController( + vsync: this, + value: 1, + duration: const Duration(milliseconds: 220), + ); + final _actionsKey = GlobalKey(); + bool _expanded = true; + bool? _dragFromExpanded; + double _pull = 0; + double _actionHeight = 1; bool _closing = false; double _drag(double delta) { if (_closing) return 0; + if (_dragFromExpanded == null) { + if (_expanded && _expansion.value == 1 && delta <= 0) return delta; + _dragFromExpanded = _expanded; + _actionHeight = (_actionsKey.currentContext?.size?.height ?? 1).clamp( + 1.0, + double.infinity, + ); + _pull = _expanded + ? (1 - _expansion.value) * _actionHeight + : _offset.value - _expansion.value * _actionHeight; + } _offset.stop(); - final previous = _offset.value; - _offset.value = (previous + delta).clamp(0.0, double.infinity); - return delta - (_offset.value - previous); + _expansion.stop(); + if (_dragFromExpanded!) { + final previous = _pull; + _pull = (_pull + delta).clamp(0.0, _actionHeight); + _expansion.value = 1 - _pull / _actionHeight; + // A single pull can reach the compact state, but cannot cancel it. + return delta < 0 ? delta - (_pull - previous) : 0; + } + _pull = (_pull + delta).clamp(-_actionHeight, double.infinity); + _expansion.value = (-_pull / _actionHeight).clamp(0.0, 1.0); + _offset.value = _pull.clamp(0.0, double.infinity); + return 0; } bool _settle(double velocity) { if (_closing) return true; - if (_offset.value == 0) return false; + final fromExpanded = _dragFromExpanded; + if (fromExpanded == null) return false; + _dragFromExpanded = null; + final threshold = (_actionHeight * 0.25).clamp(64.0, 120.0); + if (fromExpanded) { + final collapse = + velocity > 700 || (velocity >= -700 && _pull >= threshold); + _setExpanded(!collapse); + return true; + } + if (_pull < 0) { + final expand = + velocity < -700 || (velocity <= 700 && -_pull >= threshold); + _setExpanded(expand); + return true; + } final height = context.size?.height ?? 0; _closing = velocity > 700 || @@ -426,6 +455,31 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag> return true; } + void _setExpanded(bool expanded) { + setState(() => _expanded = expanded); + if (!expanded && _scroll.hasClients) _scroll.jumpTo(0); + _expansion.animateTo( + expanded ? 1 : 0, + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + _animateToRest(0); + } + + void _toggleExpanded() { + if (_closing) return; + _dragFromExpanded = null; + _setExpanded(!_expanded); + } + + void _cancelDrag() { + final fromExpanded = _dragFromExpanded; + _dragFromExpanded = null; + if (fromExpanded != null) _setExpanded(fromExpanded); + } + Future _animateToRest(double target) async { try { await _offset @@ -445,6 +499,7 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag> void dispose() { _scroll.dispose(); _offset.dispose(); + _expansion.dispose(); super.dispose(); } @@ -453,7 +508,90 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag> animation: _offset, builder: (_, child) => Transform.translate(offset: Offset(0, _offset.value), child: child), - child: widget.builder(_scroll), + child: widget.surfaceBuilder( + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.72, + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 16, + 16, + widget.bottomPadding > 0 ? 8 : 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (details) => _drag(details.delta.dy), + onVerticalDragEnd: (details) => + _settle(details.primaryVelocity ?? 0), + onVerticalDragCancel: _cancelDrag, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + container: true, + button: true, + expanded: _expanded, + label: _expanded + ? MaterialLocalizations.of( + context, + ).expandedIconTapHint + : MaterialLocalizations.of( + context, + ).collapsedIconTapHint, + onTap: _toggleExpanded, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: _toggleExpanded, + child: const SizedBox( + height: 32, + child: AppSheetHandle(margin: EdgeInsets.zero), + ), + ), + ), + widget.header, + ], + ), + ), + Flexible( + child: ExcludeSemantics( + excluding: !_expanded, + child: IgnorePointer( + ignoring: !_expanded, + child: SizeTransition( + sizeFactor: _expansion, + alignment: Alignment.topCenter, + child: SingleChildScrollView( + key: _actionsKey, + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics( + parent: ClampingScrollPhysics(), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + ...widget.children, + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), ); } diff --git a/test/selection_bottom_bar_drag_test.dart b/test/selection_bottom_bar_drag_test.dart index f2e76456..46babddb 100644 --- a/test/selection_bottom_bar_drag_test.dart +++ b/test/selection_bottom_bar_drag_test.dart @@ -15,10 +15,18 @@ void main() { WidgetTester tester, VoidCallback onClose, { bool tall = false, + int selectedCount = 1, + double textScale = 1, }) async { await tester.pumpWidget( ProviderScope( child: MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), theme: mornye ? MornyeTheme.build(Brightness.light) : AppTheme.light(), @@ -31,7 +39,7 @@ void main() { Align( alignment: Alignment.bottomCenter, child: SelectionBottomBar( - selectedCount: 1, + selectedCount: selectedCount, allSelected: true, onClose: onClose, onToggleSelectAll: () {}, @@ -54,13 +62,16 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('$name: downward drag moves the panel and closes once', ( + testWidgets('$name: first swipe collapses and second swipe closes once', ( tester, ) async { var closed = 0; await showPanel(tester, () => closed++); final surface = find.byType(SingleChildScrollView); final initial = tester.getTopLeft(surface); + final expandedHeight = tester + .getSize(find.byType(SelectionBottomBar)) + .height; final handle = find.byType(AppSheetHandle); final gesture = await tester.startGesture(tester.getCenter(handle)); await gesture.moveBy(const Offset(0, 25)); @@ -76,12 +87,109 @@ void main() { expect(background.position.pixels, 0); await gesture.up(); await tester.pumpAndSettle(); + expect(closed, 0); + expect(find.text('1 selected'), findsOneWidget); + expect(find.text('Action').hitTestable(), findsNothing); + expect( + tester.getSize(find.byType(SelectionBottomBar)).height, + lessThan(expandedHeight - 100), + ); + await tester.drag(handle, const Offset(0, 150)); + await tester.pumpAndSettle(); expect(closed, 1); await tester.pumpWidget(const SizedBox()); expect(closed, 1); expect(tester.takeException(), isNull); }); + testWidgets('$name: a long first swipe cannot cancel selection', ( + tester, + ) async { + var closed = 0; + await showPanel(tester, () => closed++); + await tester.drag(find.byType(AppSheetHandle), const Offset(0, 600)); + await tester.pumpAndSettle(); + expect(closed, 0); + expect(find.text('Action').hitTestable(), findsNothing); + expect(find.text('1 selected'), findsOneWidget); + }); + + testWidgets('$name: collapsed panel stays compact as selection changes', ( + tester, + ) async { + var closed = 0; + await showPanel(tester, () => closed++); + await tester.drag(find.byType(AppSheetHandle), const Offset(0, 150)); + await tester.pumpAndSettle(); + final compactHeight = tester + .getSize(find.byType(SelectionBottomBar)) + .height; + + await showPanel(tester, () => closed++, selectedCount: 9); + + expect(find.text('9 selected'), findsOneWidget); + expect(closed, 0); + expect(find.text('Action').hitTestable(), findsNothing); + expect( + tester.getSize(find.byType(SelectionBottomBar)).height, + compactHeight, + ); + // The area occupied by the expanded panel must belong to Library again. + await tester.timedDragFrom( + tester.getTopLeft(find.byType(SelectionBottomBar)) - + const Offset(-100, 50), + const Offset(0, -100), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + final background = tester.state( + find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ), + ); + expect(background.position.pixels, greaterThan(80)); + expect(closed, 0); + }); + + testWidgets('$name: tap handle and upward swipe restore actions', ( + tester, + ) async { + var closed = 0; + await showPanel(tester, () => closed++); + final handle = find.byType(AppSheetHandle); + final initial = tester.getSize(find.byType(SelectionBottomBar)); + await tester.tap(handle); + await tester.pumpAndSettle(); + expect(find.text('Action').hitTestable(), findsNothing); + await tester.tap(handle); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(SelectionBottomBar)), initial); + expect(find.text('Action').hitTestable(), findsOneWidget); + + await tester.drag(handle, const Offset(0, 150)); + await tester.pumpAndSettle(); + await tester.timedDrag( + handle, + const Offset(0, -140), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(SelectionBottomBar)), initial); + expect(find.text('Action').hitTestable(), findsOneWidget); + expect(closed, 0); + }); + + testWidgets('$name: close button still cancels immediately', ( + tester, + ) async { + var closed = 0; + await showPanel(tester, () => closed++); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(closed, 1); + }); + testWidgets('$name: short drag returns without closing', (tester) async { var closed = 0; await showPanel(tester, () => closed++); @@ -123,6 +231,8 @@ void main() { ) async { var closed = 0; await showPanel(tester, () => closed++); + await tester.tap(find.byType(AppSheetHandle)); + await tester.pumpAndSettle(); await tester.drag(find.byType(AppSheetHandle), const Offset(0, 150)); await tester.pump(const Duration(milliseconds: 30)); await tester.pumpWidget(const SizedBox()); @@ -138,6 +248,7 @@ void main() { await showPanel(tester, () => closed++, tall: true); final surface = find.byType(SingleChildScrollView); final initial = tester.getTopLeft(surface); + final headerPosition = tester.getTopLeft(find.text('1 selected')); final scroll = tester.widget(surface).controller!; await tester.timedDrag( surface, @@ -147,6 +258,7 @@ void main() { await tester.pumpAndSettle(); expect(scroll.offset, greaterThan(180)); expect(tester.getTopLeft(surface), initial); + expect(tester.getTopLeft(find.text('1 selected')), headerPosition); final offset = scroll.offset; await tester.timedDrag( surface, @@ -157,6 +269,66 @@ void main() { expect(scroll.offset, lessThan(offset)); expect(tester.getTopLeft(surface), initial); expect(closed, 0); + // The fixed header can collapse actions even when they are scrolled. + await tester.drag(find.byType(AppSheetHandle), const Offset(0, 200)); + await tester.pumpAndSettle(); + expect(scroll.offset, 0); + expect(find.text('1 selected'), findsOneWidget); + expect( + tester.getSize(find.byType(SelectionBottomBar)).height, + lessThan(160), + ); + expect(closed, 0); + }); + + testWidgets('$name: pulling actions at the top also collapses the panel', ( + tester, + ) async { + var closed = 0; + await showPanel(tester, () => closed++); + await tester.timedDrag( + find.byType(SingleChildScrollView), + const Offset(0, 150), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + expect(closed, 0); + expect(find.text('Action').hitTestable(), findsNothing); + }); + + testWidgets('$name: large text leaves the compact header usable', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var closed = 0; + await showPanel(tester, () => closed++, tall: true, textScale: 2); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(AppSheetHandle)); + await tester.pumpAndSettle(); + expect(find.text('1 selected').hitTestable(), findsOneWidget); + expect(find.byTooltip('Close').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + expect(closed, 0); + }); + + testWidgets('$name: compact actions are hidden from accessibility', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await showPanel(tester, () {}); + expect(find.semantics.byLabel('Collapse'), findsOne); + await tester.tap(find.byType(AppSheetHandle)); + await tester.pumpAndSettle(); + expect(find.semantics.byLabel('Expand'), findsOne); + expect(find.semantics.byLabel('Action'), findsNothing); + expect(find.text('1 selected').hitTestable(), findsOneWidget); + } finally { + semantics.dispose(); + } }); } }