Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions examples/api/lib/cupertino/sheet/cupertino_sheet.3.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/cupertino.dart';

/// Flutter code sample for [CupertinoSheetRoute].

void main() {
runApp(const CupertinoSheetApp());
}

class CupertinoSheetApp extends StatelessWidget {
const CupertinoSheetApp({super.key});

@override
Widget build(BuildContext context) {
return const CupertinoApp(
title: 'Scrollable Cupertino Sheet',
home: HomePage(),
);
}
}

class HomePage extends StatelessWidget {
const HomePage({super.key});

@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar(
middle: Text('Scrollable Cupertino Sheet Example'),
automaticBackgroundVisibility: false,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CupertinoButton.filled(
onPressed: () {
Navigator.of(context).push(
CupertinoSheetRoute<void>.scrollable(
scrollableBuilder:
(BuildContext context, ScrollController controller) =>
_ScrollableSheetBody(scrollController: controller),
),
);
},
child: const Text('Open Sheet'),
),
],
),
),
);
}
}

class _ScrollableSheetBody extends StatelessWidget {
const _ScrollableSheetBody({required this.scrollController});

final ScrollController scrollController;

@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoSheetNavbar(
child: CupertinoNavigationBar(
backgroundColor: CupertinoColors.systemGrey3,
middle: const Text('Scrollable Sheet'),
automaticBackgroundVisibility: false,
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('Close'),
onPressed: () {
CupertinoSheetRoute.popSheet(context);
},
),
),
),
child: CustomScrollView(
controller: scrollController,
primary: false,
slivers: <Widget>[
SliverList(
delegate: SliverChildBuilderDelegate((
BuildContext context,
int index,
) {
return Container(
alignment: Alignment.center,
height: 100,
child: const Text('Scroll Me'),
);
}, childCount: 20),
),
],
),
);
}
}

class CupertinoSheetNavbar extends StatelessWidget
implements ObstructingPreferredSizeWidget {
const CupertinoSheetNavbar({super.key, required this.child});

final CupertinoNavigationBar child;

@override
bool shouldFullyObstruct(BuildContext context) {
return child.shouldFullyObstruct(context);
}

@override
Size get preferredSize {
return child.preferredSize;
}

@override
Widget build(BuildContext context) {
return CupertinoSheetDragArea(child: child);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be automatically built into CupertinoNavigationBar?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible, and it would be convenient as this would theoretically be a common use case. I'd just worry about this behavior being discoverable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does feel a bit kludgy from the developer experience side.

What native behavior are we trying to emulate by exposing this drag wrapper for non-scrolling parts? If I had a header that I wanted to have drag the sheet, why not put it in a pinned sliver in a CustomScrollView as part of the scrollable? Would that achieve the same effect without adding more API for the developer to have to wire up?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What native behavior are we trying to emulate by exposing this drag wrapper for non-scrolling parts?

We want to be able to trigger the drag-to-dismiss behavior when a drag originates outside of the scroll area, and only the drag-to-dismiss, not a scroll. For example, if you start a drag on the header of a native sheet, it will not scroll the content below it, no matter what direction you drag. This is also the only way to trigger the stretch upwards drag effect on a sheet. If you start the drag from within the scrolling area then the draggable/scrollable behavior would happen.

If I had a header that I wanted to have drag the sheet, why not put it in a pinned sliver in a CustomScrollView as part of the scrollable? Would that achieve the same effect without adding more API for the developer to have to wire up?

I believe in that case then a scroll would be triggered on a drag upwards originating in the pinned sliver, instead of the stretch animation.

From native: a drag starting within the navbar will not scroll and will stretch upwards. A drag starting below the navbar will scroll depending on the direction, and will not scroll upwards.
ScreenRecording2025-10-21at12 39 56PM-ezgif com-video-to-gif-converter

Basically outside of the scrolling area we want the sheet to work as if it's a non-scrolling sheet. This wrapper lets a developer wrap the original vertical drag gesture recognizer only around those areas.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be easier for the developer if, in the case of a scrollable sheet, we provide a header parameter and we can manage the gesture resolution ourselves internally?
What is the native experience like for developers building the UI you described above?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe in that case then a scroll would be triggered on a drag upwards originating in the pinned sliver, instead of the stretch animation.

Did you try it?

We have control over how the scrolling offset is applied here with the custom scroll position class in the scrollable sheet, could we divert it in this case?

}
}
62 changes: 62 additions & 0 deletions examples/api/test/cupertino/sheet/cupertino_sheet.3_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/rendering.dart';
import 'package:flutter_api_samples/cupertino/sheet/cupertino_sheet.3.dart'
as example;
import 'package:flutter_test/flutter_test.dart';

void main() {
testWidgets('Tap on button displays cupertino sheet', (
WidgetTester tester,
) async {
await tester.pumpWidget(const example.CupertinoSheetApp());

final Finder dialogTitle = find.text('Scrollable Sheet');
expect(dialogTitle, findsNothing);

await tester.tap(find.text('Open Sheet'));
await tester.pumpAndSettle();
expect(dialogTitle, findsOneWidget);

await tester.tap(find.text('Close'));
await tester.pumpAndSettle();
expect(dialogTitle, findsNothing);
});

testWidgets('Drag on nav bar triggers drag only', (
WidgetTester tester,
) async {
await tester.pumpWidget(const example.CupertinoSheetApp());

final Finder dialogTitle = find.text('Scrollable Sheet');
expect(dialogTitle, findsNothing);

await tester.tap(find.text('Open Sheet'));
await tester.pumpAndSettle();
expect(dialogTitle, findsOneWidget);

final RenderBox box =
tester.renderObject(find.text('Scrollable Sheet')) as RenderBox;
final Offset navbarOffset = box.localToGlobal(Offset.zero);
final double initialSheetHeight = navbarOffset.dy;

final TestGesture gesture = await tester.startGesture(navbarOffset);
await gesture.moveBy(const Offset(0, -50));
await tester.pump();

// Upwards drag triggers stretch, and not scroll.
final double currentSheetHeight = box.localToGlobal(Offset.zero).dy;
expect(currentSheetHeight, lessThan(initialSheetHeight));

await gesture.moveBy(const Offset(0, 200));
await tester.pump();

final double finalSheetHeight = box.localToGlobal(Offset.zero).dy;
expect(finalSheetHeight, greaterThan(initialSheetHeight));

await gesture.up();
await tester.pumpAndSettle();
});
}
Loading