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.
This commit is contained in:
zarzet committed 2026-10-10 23:21:52 +07:00
1 parent 58fb7750e5
commit c093120e73
2 files changed
+408 -98

No files matched your search

+234 -96
View File
@@ -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<Widget> 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<void> _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,
],
),
),
),
),
),
),
],
),
),
),
),
),
);
}
+174 -2
View File
@@ -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<ScrollableState>(
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<SingleChildScrollView>(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();
}
});
}
}