Skip to content

Commit c79ffd3

Browse files
chrisbobbegnprice
authored andcommitted
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 ee303d0 commit c79ffd3

File tree

2 files changed

+111
-26
lines changed

2 files changed

+111
-26
lines changed

lib/widgets/action_sheet.dart

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

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

lib/widgets/read_receipts.dart

Lines changed: 15 additions & 26 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,26 @@ 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(
90-
message: zulipLocalizations.actionSheetReadReceiptsErrorReadCount),
86+
FetchStatus.loading => SliverToBoxAdapter(
87+
child: BottomSheetEmptyContentPlaceholder(loading: true)),
88+
FetchStatus.error => SliverToBoxAdapter(
89+
child: BottomSheetEmptyContentPlaceholder(
90+
message: zulipLocalizations.actionSheetReadReceiptsErrorReadCount)),
9191
FetchStatus.success => userIds.isEmpty
92-
? BottomSheetEmptyContentPlaceholder(
93-
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+
? SliverToBoxAdapter(
93+
child: BottomSheetEmptyContentPlaceholder(
94+
message: zulipLocalizations.actionSheetReadReceiptsZeroReadCount))
95+
: SliverList.builder(
96+
itemCount: receiptCount,
97+
itemBuilder: (_, index) => ReadReceiptsUserItem(userId: userIds[index])),
10298
};
10399

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-
]));
100+
return DraggableScrollableModalBottomSheet(
101+
header: _ReadReceiptsHeader(receiptCount: receiptCount, status: status),
102+
contentSliver: content);
114103
}
115104
}
116105

0 commit comments

Comments
 (0)