|
| 1 | +import 'package:flutter/material.dart'; |
| 2 | + |
| 3 | +class _DraggableScrollableLayer extends StatelessWidget { |
| 4 | + const _DraggableScrollableLayer({required this.builder}); |
| 5 | + |
| 6 | + final WidgetBuilder builder; |
| 7 | + |
| 8 | + @override |
| 9 | + Widget build(BuildContext context) { |
| 10 | + return DraggableScrollableSheet( |
| 11 | + // Match `initial…` to `min…` so that a slight drag downward dismisses |
| 12 | + // the sheet instead of just resizing it. Making them equal gives a |
| 13 | + // buggy experience for some reason |
| 14 | + // ( https://github.com/zulip/zulip-flutter/pull/12#discussion_r1116423455 ) |
| 15 | + // so we work around by make `initial…` a bit bigger. |
| 16 | + minChildSize: 0.25, |
| 17 | + initialChildSize: 0.26, |
| 18 | + |
| 19 | + // With `expand: true`, the bottom sheet would then start out occupying |
| 20 | + // the whole screen, as if `initialChildSize` was 1.0. That doesn't seem |
| 21 | + // like what the docs call for. Maybe a bug. Or maybe it's somehow |
| 22 | + // related to the `Stack`? |
| 23 | + expand: false, |
| 24 | + |
| 25 | + builder: (BuildContext context, ScrollController scrollController) { |
| 26 | + return SingleChildScrollView( |
| 27 | + // Prevent overscroll animation on swipe down; it looks |
| 28 | + // sloppy when you're swiping to dismiss the sheet. |
| 29 | + physics: const ClampingScrollPhysics(), |
| 30 | + |
| 31 | + controller: scrollController, |
| 32 | + |
| 33 | + child: Padding( |
| 34 | + // Avoid the drag handle. See comment on |
| 35 | + // _DragHandleLayer's SizedBox.height. |
| 36 | + padding: const EdgeInsets.only(top: kMinInteractiveDimension), |
| 37 | + |
| 38 | + // Extend DraggableScrollableSheet to full width so the whole |
| 39 | + // sheet responds to drag/scroll uniformly. |
| 40 | + child: FractionallySizedBox( |
| 41 | + widthFactor: 1.0, |
| 42 | + child: Builder(builder: builder), |
| 43 | + ), |
| 44 | + ), |
| 45 | + ); |
| 46 | + }); |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +class _DragHandleLayer extends StatelessWidget { |
| 51 | + @override |
| 52 | + Widget build(BuildContext context) { |
| 53 | + ColorScheme colorScheme = Theme.of(context).colorScheme; |
| 54 | + return SizedBox( |
| 55 | + // In the spec, this is expressed as 22 logical pixels of top/bottom |
| 56 | + // padding on the drag handle: |
| 57 | + // https://m3.material.io/components/bottom-sheets/specs#e69f3dfb-e443-46ba-b4a8-aabc718cf335 |
| 58 | + // The drag handle is specified with height 4 logical pixels, so we can |
| 59 | + // get the same result by vertically centering the handle in a box with |
| 60 | + // height 22 + 4 + 22 = 48. We have another way to say 48 -- |
| 61 | + // kMinInteractiveDimension -- which is actually not a bad way to |
| 62 | + // express it, since the feature was announced as "an optional drag |
| 63 | + // handle with an accessible 48dp hit target": |
| 64 | + // https://m3.material.io/components/bottom-sheets/overview#2cce5bae-eb83-40b0-8e52-5d0cfaa9b795 |
| 65 | + // As a bonus, that constant is easy to use at the other layer in the |
| 66 | + // Stack where we set the starting position of the sheet's content to |
| 67 | + // avoid the drag handle. |
| 68 | + height: kMinInteractiveDimension, |
| 69 | + |
| 70 | + child: Center( |
| 71 | + child: ClipRRect( |
| 72 | + clipBehavior: Clip.hardEdge, |
| 73 | + borderRadius: const BorderRadius.all(Radius.circular(2)), |
| 74 | + child: SizedBox( |
| 75 | + // height / width / color (including opacity) from this table: |
| 76 | + // https://m3.material.io/components/bottom-sheets/specs#7c093473-d9e1-48f3-9659-b75519c2a29d |
| 77 | + height: 4, |
| 78 | + width: 32, |
| 79 | + child: ColoredBox(color: colorScheme.onSurfaceVariant.withOpacity(0.40)), |
| 80 | + ), |
| 81 | + ))); |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +/// Show a modal bottom sheet that drags and scrolls to present lots of content. |
| 86 | +/// |
| 87 | +/// Aims to follow Material 3's "bottom sheet" with a drag handle: |
| 88 | +/// https://m3.material.io/components/bottom-sheets/overview |
| 89 | +Future<T?> showDraggableScrollableModalBottomSheet<T>({ |
| 90 | + required BuildContext context, |
| 91 | + required WidgetBuilder builder, |
| 92 | +}) { |
| 93 | + return showModalBottomSheet<T>( |
| 94 | + context: context, |
| 95 | + |
| 96 | + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect |
| 97 | + // on my iPhone 13 Pro but is marked as "much slower": |
| 98 | + // https://api.flutter.dev/flutter/dart-ui/Clip.html |
| 99 | + clipBehavior: Clip.antiAlias, |
| 100 | + |
| 101 | + // The spec: |
| 102 | + // https://m3.material.io/components/bottom-sheets/specs |
| 103 | + // defines the container's shape with the design token |
| 104 | + // `md.sys.shape.corner.extra-large.top`, which in the table at |
| 105 | + // https://m3.material.io/styles/shape/shape-scale-tokens#6f668ba1-b671-4ea2-bcf3-c1cff4f4099e |
| 106 | + // maps to: |
| 107 | + // 28dp,28dp,0dp,0dp |
| 108 | + // SHAPE_FAMILY_ROUNDED_CORNERS |
| 109 | + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28.0))), |
| 110 | + |
| 111 | + useSafeArea: true, |
| 112 | + isScrollControlled: true, |
| 113 | + builder: (BuildContext context) { |
| 114 | + // Make the content start below the drag handle in the y-direction, but |
| 115 | + // when the content is scrollable, let it scroll under the drag handle in |
| 116 | + // the z-direction. |
| 117 | + return Stack( |
| 118 | + children: [ |
| 119 | + _DraggableScrollableLayer(builder: builder), |
| 120 | + _DragHandleLayer(), |
| 121 | + ], |
| 122 | + ); |
| 123 | + }); |
| 124 | +} |
0 commit comments