fix(download): make picker drag dismissal natural

This commit is contained in:
zarzet
2026-07-29 19:59:09 +07:00
parent 6f94aae0cc
commit 43a8d08851
3 changed files with 104 additions and 6 deletions
+33
View File
@@ -40,6 +40,39 @@ class AppSheetHandle extends StatelessWidget {
}
}
/// A modal-sheet surface whose height and inner scroll position share one drag.
///
/// The child must attach the supplied [ScrollController] to its primary
/// vertical scroll view. Pulling down at the top then moves the whole surface;
/// releasing either restores it or dismisses it at the minimum extent.
class AppDraggableSheet extends StatelessWidget {
const AppDraggableSheet({
super.key,
required this.builder,
this.initialChildSize = 0.88,
this.minChildSize = 0.25,
this.maxChildSize = 0.88,
});
final ScrollableWidgetBuilder builder;
final double initialChildSize;
final double minChildSize;
final double maxChildSize;
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
snap: true,
shouldCloseOnMinExtent: true,
initialChildSize: initialChildSize,
minChildSize: minChildSize,
maxChildSize: maxChildSize,
builder: builder,
);
}
}
/// Standard chrome for modal sheet content: drag handle, optional title block,
/// height cap, keyboard inset and bottom safe area, so each sheet only supplies
/// its own body.
+13 -6
View File
@@ -13,6 +13,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
final String? coverUrl;
final void Function(String quality, String service) onSelect;
final String? recommendedService;
final ScrollController? scrollController;
const DownloadServicePicker({
super.key,
@@ -21,6 +22,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
this.coverUrl,
required this.onSelect,
this.recommendedService,
this.scrollController,
});
@override
@@ -42,12 +44,16 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
builder: (context) => DownloadServicePicker(
trackName: trackName,
artistName: artistName,
coverUrl: coverUrl,
onSelect: onSelect,
recommendedService: recommendedService,
enableDrag: false,
builder: (context) => AppDraggableSheet(
builder: (context, scrollController) => DownloadServicePicker(
trackName: trackName,
artistName: artistName,
coverUrl: coverUrl,
onSelect: onSelect,
recommendedService: recommendedService,
scrollController: scrollController,
),
),
);
}
@@ -120,6 +126,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return SafeArea(
child: SingleChildScrollView(
controller: widget.scrollController,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
+58
View File
@@ -259,6 +259,64 @@ void main() {
expect(find.text('body'), findsOneWidget);
});
testWidgets('draggable content moves as one surface and dismisses', (
tester,
) async {
const sheetKey = ValueKey<String>('scrollable-sheet');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
// Disable the route recognizer so the draggable surface,
// rather than the modal's fallback gesture, is under test.
enableDrag: false,
builder: (_) => AppDraggableSheet(
builder: (_, scrollController) => Material(
key: sheetKey,
child: ListView(
controller: scrollController,
children: const [
SizedBox(height: 800, child: Text('sheet body')),
],
),
),
),
),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.byKey(sheetKey), findsOneWidget);
final initialTop = tester.getTopLeft(find.byKey(sheetKey)).dy;
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(sheetKey)),
);
await gesture.moveBy(const Offset(0, 160));
await tester.pump();
expect(
tester.getTopLeft(find.byKey(sheetKey)).dy,
greaterThan(initialTop + 100),
);
await gesture.moveBy(const Offset(0, 260));
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(sheetKey), findsNothing);
});
testWidgets('sheet shape comes from the token scale', (tester) async {
final shape =
AppTheme.light().bottomSheetTheme.shape! as RoundedRectangleBorder;