Skip to content

Commit 1d723e3

Browse files
committed
NOMERGE read_receipts: Improve UX by making the sheet draggable-scrollable
The Figma asks that the sheet be expandable to fill the screen: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11367-21131&m=dev and that's implemented here. Compare f8ddff2, where we removed an earlier implementation. I hadn't tried to bring that back yet because I wanted to support triggering resize from a drag handle at the top, and I couldn't figure out how to do that. IIRC I could only get the drag handle to respond to drag-down gestures (via `enableDrag: true`) by shifting the sheet's position downward. That worked as a way to dismiss the sheet, but it was frustratingly different from the gesture handling on the scrollable list: - The slide-to-dismiss looked different from the shrink-and-dismiss, an awkward inconsistency - The list would respond to upward drags, too (by growing and showing more content) I've managed it here, modulo with a header instead of a drag handle, by making sure the scrollable area extends through the top of the sheet. (Done with a CustomScrollView, pinning the header to the viewport top.)
1 parent 07002f8 commit 1d723e3

File tree

2 files changed

+127
-24
lines changed

2 files changed

+127
-24
lines changed

lib/widgets/action_sheet.dart

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,123 @@ class BottomSheetEmptyContentPlaceholder extends StatelessWidget {
225225
}
226226
}
227227

228+
/// Like [BottomSheetEmptyContentPlaceholder], but as a sliver.
229+
///
230+
/// Simply a [BottomSheetEmptyContentPlaceholder] wrapped in
231+
/// [SliverToBoxAdapter].
232+
class SliverBottomSheetEmptyContentPlaceholder extends StatelessWidget {
233+
const SliverBottomSheetEmptyContentPlaceholder({
234+
super.key,
235+
this.message,
236+
this.loading = false,
237+
}) : assert((message != null) ^ loading);
238+
239+
final String? message;
240+
final bool loading;
241+
242+
@override
243+
Widget build(BuildContext context) {
244+
return SliverToBoxAdapter(
245+
child: BottomSheetEmptyContentPlaceholder(message: message, loading: loading));
246+
}
247+
}
248+
249+
/// A bottom sheet that resizes, scrolls, and dismisses in response to dragging.
250+
///
251+
/// [header] is assumed to occupy the full width its parent allows.
252+
/// (This is important for the clipping/shadow effect when [contentSliver]
253+
/// scrolls under the header.)
254+
///
255+
/// The sheet's initial height and minimum height before dismissing
256+
/// are set proportionally to the screen's height.
257+
/// The screen's height is read from the parent's max-height constraint,
258+
/// so the caller should not introduce widgets that interfere with that.
259+
/// (Non-layout wrapper widgets such as [InheritedWidget]s are OK.)
260+
///
261+
/// The sheet's dismissal works like this:
262+
/// - A "Close" button is offered.
263+
/// - A drag-down or fling on the header or the [contentSliver]
264+
/// causes those areas to shrink past a threshold at which the sheet
265+
/// decides to dismiss.
266+
/// - The [enableDrag] param of upstream's [showModalBottomSheet]
267+
/// only seems to affect gesture handling on the Close button and its padding
268+
/// (which are not part of the resizable/scrollable area):
269+
/// - When true, the Close button responds to a downward fling by
270+
/// sliding the sheet downward and dismissing it
271+
/// (i.e. not by the usual behavior where the header- and-content height
272+
/// shrinks past a threshold, causing dismissal).
273+
/// - When false, the Close button doesn't respond to a downward fling.
274+
class DraggableScrollableModalBottomSheet extends StatelessWidget {
275+
const DraggableScrollableModalBottomSheet({
276+
super.key,
277+
required this.header,
278+
required this.contentSliver,
279+
});
280+
281+
final Widget header;
282+
final Widget contentSliver;
283+
284+
@override
285+
Widget build(BuildContext context) {
286+
return DraggableScrollableSheet(
287+
expand: false,
288+
builder: (context, controller) {
289+
final backgroundColor = Theme.of(context).bottomSheetTheme.backgroundColor!;
290+
291+
// The "inset shadow" effect in Figma is a bit awkwardly
292+
// implemented and there might be a better factoring:
293+
// 1. This effect leans on the abstraction that [contentSliver]
294+
// is simply a scrollable area in its own viewport.
295+
// We'd normally just wrap that viewport in [InsetShadowBox].
296+
// 2. Really, though, the scrollable includes the header,
297+
// pinned to the viewport top. We do this to support resizing
298+
// (and dismiss-on-min-height) on gestures in the header, too,
299+
// uniformly with the content.
300+
// 3. So for the top shadow, we tack a shadow gradient onto the header,
301+
// exploiting the header's pinning behavior to keep it fixed.
302+
// 3. For the bottom, I haven't found a nice sliver-based implementation
303+
// that supports pinning a shadow overlay at the viewport bottom.
304+
// So for the bottom we use [InsetShadowBox] around the viewport,
305+
// with just `bottom:` and no `top:`.
306+
307+
final headerWithShadow = Column(
308+
mainAxisSize: MainAxisSize.min,
309+
children: [
310+
ColoredBox(
311+
color: backgroundColor,
312+
child: header),
313+
SizedBox(height: 8, width: double.infinity,
314+
child: DecoratedBox(decoration: fadeToTransparencyDecoration(
315+
FadeToTransparencyDirection.down, backgroundColor))),
316+
]);
317+
318+
return Column(
319+
mainAxisSize: MainAxisSize.min,
320+
children: [
321+
Flexible(
322+
child: InsetShadowBox(
323+
bottom: 8,
324+
color: backgroundColor,
325+
child: CustomScrollView(
326+
// The iOS default "bouncing" effect would look uncoordinated
327+
// in the common case where overscroll co-occurs with
328+
// shrinking the sheet past the threshold where it dismisses.
329+
physics: ClampingScrollPhysics(),
330+
controller: controller,
331+
slivers: [
332+
PinnedHeaderSliver(child: headerWithShadow),
333+
SliverPadding(
334+
padding: EdgeInsets.only(bottom: 8),
335+
sliver: contentSliver),
336+
]))),
337+
Padding(
338+
padding: const EdgeInsets.symmetric(horizontal: 16),
339+
child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close))
340+
]);
341+
});
342+
}
343+
}
344+
228345
/// A button in an action sheet.
229346
///
230347
/// When built from server data, the action sheet ignores changes in that data;

lib/widgets/read_receipts.dart

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import '../generated/l10n/zulip_localizations.dart';
66
import 'action_sheet.dart';
77
import 'actions.dart';
88
import 'color.dart';
9-
import 'inset_shadow.dart';
109
import 'profile.dart';
1110
import 'store.dart';
1211
import 'text.dart';
@@ -81,36 +80,23 @@ class _ReadReceiptsState extends State<ReadReceipts> with PerAccountStoreAwareSt
8180
@override
8281
Widget build(BuildContext context) {
8382
final zulipLocalizations = ZulipLocalizations.of(context);
84-
// TODO could pull out this layout/appearance code,
85-
// focusing this widget only on state management
83+
final receiptCount = userIds.length;
8684

8785
final content = switch (status) {
88-
FetchStatus.loading => BottomSheetEmptyContentPlaceholder(loading: true),
89-
FetchStatus.error => BottomSheetEmptyContentPlaceholder(
86+
FetchStatus.loading => SliverBottomSheetEmptyContentPlaceholder(loading: true),
87+
FetchStatus.error => SliverBottomSheetEmptyContentPlaceholder(
9088
message: zulipLocalizations.actionSheetReadReceiptsErrorReadCount),
9189
FetchStatus.success => userIds.isEmpty
92-
? BottomSheetEmptyContentPlaceholder(
90+
? SliverBottomSheetEmptyContentPlaceholder(
9391
message: zulipLocalizations.actionSheetReadReceiptsZeroReadCount)
94-
: InsetShadowBox(
95-
top: 8, bottom: 8,
96-
color: DesignVariables.of(context).bgContextMenu,
97-
child: ListView.builder(
98-
padding: EdgeInsets.symmetric(vertical: 8),
99-
itemCount: userIds.length,
100-
itemBuilder: (context, index) =>
101-
ReadReceiptsUserItem(userId: userIds[index])))
92+
: SliverList.builder(
93+
itemCount: receiptCount,
94+
itemBuilder: (_, index) => ReadReceiptsUserItem(userId: userIds[index])),
10295
};
10396

104-
return SizedBox(
105-
height: 500, // TODO(design) tune
106-
child: Column(
107-
children: [
108-
_ReadReceiptsHeader(receiptCount: userIds.length, status: status),
109-
Expanded(child: content),
110-
Padding(
111-
padding: const EdgeInsets.symmetric(horizontal: 16),
112-
child: const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.close))
113-
]));
97+
return DraggableScrollableModalBottomSheet(
98+
header: _ReadReceiptsHeader(receiptCount: receiptCount, status: status),
99+
contentSliver: content);
114100
}
115101
}
116102

0 commit comments

Comments
 (0)