Skip to content

Commit 069aabf

Browse files
authored
Draggable Scrollable sheet (flutter#30058)
* Draggable Scrollable sheet
1 parent 3c9ffbc commit 069aabf

File tree

3 files changed

+581
-0
lines changed

3 files changed

+581
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
// Copyright 2019 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/gestures.dart';
6+
7+
import 'basic.dart';
8+
import 'framework.dart';
9+
import 'layout_builder.dart';
10+
import 'scroll_context.dart';
11+
import 'scroll_controller.dart';
12+
import 'scroll_physics.dart';
13+
import 'scroll_position.dart';
14+
import 'scroll_position_with_single_context.dart';
15+
import 'scroll_simulation.dart';
16+
17+
/// The signature of a method that provides a [BuildContext] and
18+
/// [ScrollController] for building a widget that may overflow the draggable
19+
/// [Axis] of the containing [DraggableScrollSheet].
20+
///
21+
/// Users should apply the [scrollController] to a [ScrollView] subclass, such
22+
/// as a [SingleChildScrollView], [ListView] or [GridView], to have the whole
23+
/// sheet be draggable.
24+
typedef ScrollableWidgetBuilder = Widget Function(
25+
BuildContext context,
26+
ScrollController scrollController,
27+
);
28+
29+
/// A container for a [Scrollable] that responds to drag gestures by resizing
30+
/// the scrollable until a limit is reached, and then scrolling.
31+
///
32+
/// This widget can be dragged along the vertical axis between its
33+
/// [minChildSize], which defaults to `0.25` and [maxChildSize], which defaults
34+
/// to `1.0`. These sizes are percentages of the height of the parent container.
35+
///
36+
/// The widget coordinates resizing and scrolling of the widget returned by
37+
/// builder as the user drags along the horizontal axis.
38+
///
39+
/// The widget will initially be displayed at its initialChildSize which
40+
/// defaults to `0.5`, meaning half the height of its parent. Dragging will work
41+
/// between the range of minChildSize and maxChildSize (as percentages of the
42+
/// parent container's height) as long as the builder creates a widget which
43+
/// uses the provided [ScrollController]. If the widget created by the
44+
/// [ScrollableWidgetBuilder] does not use provided [ScrollController], the
45+
/// sheet will remain at the initialChildSize.
46+
///
47+
/// {@tool sample}
48+
///
49+
/// This is a sample widget which shows a [ListView] that has 25 [ListTile]s.
50+
/// It starts out as taking up half the body of the [Scaffold], and can be
51+
/// dragged up to the full height of the scaffold or down to 25% of the height
52+
/// of the scaffold. Upon reaching full height, the list contents will be
53+
/// scrolled up or down, until they reach the top of the list again and the user
54+
/// drags the sheet back down.
55+
///
56+
/// ```dart
57+
/// class HomePage extends StatelessWidget {
58+
/// @override
59+
/// Widget build(BuildContext context) {
60+
/// return Scaffold(
61+
/// appBar: AppBar(
62+
/// title: const Text('DraggableScrollableSheet'),
63+
/// ),
64+
/// body: SizedBox.expand(
65+
/// child: DraggableScrollableSheet(
66+
/// builder: (BuildContext context, ScrollController scrollController) {
67+
/// return Container(
68+
/// color: Colors.blue[100],
69+
/// child: ListView.builder(
70+
/// controller: scrollController,
71+
/// itemCount: 25,
72+
/// itemBuilder: (BuildContext context, int index) {
73+
/// return ListTile(title: Text('Item $index'));
74+
/// },
75+
/// ),
76+
/// );
77+
/// },
78+
/// ),
79+
/// ),
80+
/// );
81+
/// }
82+
/// }
83+
/// ```
84+
/// {@end-tool}
85+
class DraggableScrollableSheet extends StatefulWidget {
86+
/// Creates a widget that can be dragged and scrolled in a single gesture.
87+
///
88+
/// The [builder], [initialChildSize], [minChildSize], and [maxChildSize]
89+
/// parameters must not be null.
90+
const DraggableScrollableSheet({
91+
Key key,
92+
this.initialChildSize = 0.5,
93+
this.minChildSize = 0.25,
94+
this.maxChildSize = 1.0,
95+
@required this.builder,
96+
}) : assert(initialChildSize != null),
97+
assert(minChildSize != null),
98+
assert(maxChildSize != null),
99+
assert(minChildSize >= 0.0),
100+
assert(maxChildSize <= 1.0),
101+
assert(minChildSize <= initialChildSize),
102+
assert(initialChildSize <= maxChildSize),
103+
assert(builder != null),
104+
super(key: key);
105+
106+
/// The initial fractional value of the parent container's height to use when
107+
/// displaying the widget.
108+
///
109+
/// The default value is `0.5`.
110+
final double initialChildSize;
111+
112+
/// The minimum fractional value of the parent container's height to use when
113+
/// displaying the widget.
114+
///
115+
/// The default value is `0.25`.
116+
final double minChildSize;
117+
118+
/// The maximum fractional value of the parent container's height to use when
119+
/// displaying the widget.
120+
///
121+
/// The default value is `1.0`.
122+
final double maxChildSize;
123+
124+
/// The builder that creates a child to display in this widget, which will
125+
/// use the provided [ScrollController] to enable dragging and scrolling
126+
/// of the contents.
127+
final ScrollableWidgetBuilder builder;
128+
129+
@override
130+
_DraggableScrollableSheetState createState() => _DraggableScrollableSheetState();
131+
}
132+
133+
/// Manages state between [_DraggableScrollableSheetState],
134+
/// [_DraggableScrollableSheetScrollController], and
135+
/// [_DraggableScrollableSheetScrollPosition].
136+
///
137+
/// The State knows the pixels available along the axis the widget wants to
138+
/// scroll, but expects to get a fraction of those pixels to render the sheet.
139+
///
140+
/// The ScrollPosition knows the number of pixels a user wants to move the sheet.
141+
///
142+
/// The [currentExtent] will never be null.
143+
/// The [availablePixels] will never be null, but may be `double.infinity`.
144+
class _DraggableSheetExtent {
145+
_DraggableSheetExtent({
146+
@required this.minExtent,
147+
@required this.maxExtent,
148+
@required this.initialExtent,
149+
@required VoidCallback listener,
150+
}) : assert(minExtent != null),
151+
assert(maxExtent != null),
152+
assert(initialExtent != null),
153+
assert(minExtent >= 0),
154+
assert(maxExtent <= 1),
155+
assert(minExtent <= initialExtent),
156+
assert(initialExtent <= maxExtent),
157+
_currentExtent = ValueNotifier<double>(initialExtent)..addListener(listener),
158+
availablePixels = double.infinity;
159+
160+
final double minExtent;
161+
final double maxExtent;
162+
final double initialExtent;
163+
final ValueNotifier<double> _currentExtent;
164+
double availablePixels;
165+
166+
bool get isAtMin => minExtent >= _currentExtent.value;
167+
bool get isAtMax => maxExtent <= _currentExtent.value;
168+
169+
set currentExtent(double value) {
170+
assert(value != null);
171+
_currentExtent.value = value.clamp(minExtent, maxExtent);
172+
}
173+
double get currentExtent => _currentExtent.value;
174+
175+
/// The scroll position gets inputs in terms of pixels, but the extent is
176+
/// expected to be expressed as a number between 0..1.
177+
void addPixelDelta(double delta) {
178+
currentExtent += delta / availablePixels;
179+
}
180+
}
181+
182+
class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
183+
_DraggableScrollableSheetScrollController _scrollController;
184+
_DraggableSheetExtent _extent;
185+
186+
@override
187+
void initState() {
188+
super.initState();
189+
_extent = _DraggableSheetExtent(
190+
minExtent: widget.minChildSize,
191+
maxExtent: widget.maxChildSize,
192+
initialExtent: widget.initialChildSize,
193+
listener: _setExtent,
194+
);
195+
_scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
196+
}
197+
198+
void _setExtent() {
199+
setState(() {
200+
// _extent has been updated when this is called.
201+
});
202+
}
203+
204+
@override
205+
Widget build(BuildContext context) {
206+
return LayoutBuilder(
207+
builder: (BuildContext context, BoxConstraints constraints) {
208+
_extent.availablePixels = widget.maxChildSize * constraints.biggest.height;
209+
return SizedBox.expand(
210+
child: FractionallySizedBox(
211+
heightFactor: _extent.currentExtent,
212+
child: widget.builder(context, _scrollController),
213+
alignment: Alignment.bottomCenter,
214+
),
215+
);
216+
},
217+
);
218+
}
219+
220+
@override
221+
void dispose() {
222+
_scrollController.dispose();
223+
super.dispose();
224+
}
225+
}
226+
227+
/// A [ScrollController] suitable for use in a [ScrollableWidgetBuilder] created
228+
/// by a [DraggableScrollableSheet].
229+
///
230+
/// If a [DraggableScrollableSheet] contains content that is exceeds the height
231+
/// of its container, this controller will allow the sheet to both be dragged to
232+
/// fill the container and then scroll the child content.
233+
///
234+
/// See also:
235+
///
236+
/// * [_DraggableScrollableSheetScrollPosition], which manages the positioning logic for
237+
/// this controller.
238+
/// * [PrimaryScrollController], which can be used to establish a
239+
/// [_DraggableScrollableSheetScrollController] as the primary controller for
240+
/// descendants.
241+
class _DraggableScrollableSheetScrollController extends ScrollController {
242+
_DraggableScrollableSheetScrollController({
243+
double initialScrollOffset = 0.0,
244+
String debugLabel,
245+
@required this.extent,
246+
}) : assert(extent != null),
247+
super(
248+
debugLabel: debugLabel,
249+
initialScrollOffset: initialScrollOffset,
250+
);
251+
252+
final _DraggableSheetExtent extent;
253+
254+
@override
255+
_DraggableScrollableSheetScrollPosition createScrollPosition(
256+
ScrollPhysics physics,
257+
ScrollContext context,
258+
ScrollPosition oldPosition,
259+
) {
260+
return _DraggableScrollableSheetScrollPosition(
261+
physics: physics,
262+
context: context,
263+
oldPosition: oldPosition,
264+
extent: extent,
265+
);
266+
}
267+
268+
@override
269+
void debugFillDescription(List<String> description) {
270+
super.debugFillDescription(description);
271+
description.add('extent: $extent');
272+
}
273+
}
274+
275+
/// A scroll position that manages scroll activities for
276+
/// [_DraggableScrollableSheetScrollController].
277+
///
278+
/// This class is a concrete subclass of [ScrollPosition] logic that handles a
279+
/// single [ScrollContext], such as a [Scrollable]. An instance of this class
280+
/// manages [ScrollActivity] instances, which changes the
281+
/// [_DraggableSheetExtent.currentExtent] or visible content offset in the
282+
/// [Scrollable]'s [Viewport]
283+
///
284+
/// See also:
285+
///
286+
/// * [_DraggableScrollableSheetScrollController], which uses this as its [ScrollPosition].
287+
class _DraggableScrollableSheetScrollPosition
288+
extends ScrollPositionWithSingleContext {
289+
_DraggableScrollableSheetScrollPosition({
290+
@required ScrollPhysics physics,
291+
@required ScrollContext context,
292+
double initialPixels = 0.0,
293+
bool keepScrollOffset = true,
294+
ScrollPosition oldPosition,
295+
String debugLabel,
296+
@required this.extent,
297+
}) : assert(extent != null),
298+
super(
299+
physics: physics,
300+
context: context,
301+
initialPixels: initialPixels,
302+
keepScrollOffset: keepScrollOffset,
303+
oldPosition: oldPosition,
304+
debugLabel: debugLabel,
305+
);
306+
307+
VoidCallback _dragCancelCallback;
308+
final _DraggableSheetExtent extent;
309+
bool get listShouldScroll => pixels > 0.0;
310+
311+
@override
312+
void applyUserOffset(double delta) {
313+
if (!listShouldScroll &&
314+
!(extent.isAtMin || extent.isAtMax) ||
315+
(extent.isAtMin && delta < 0) ||
316+
(extent.isAtMax && delta > 0)) {
317+
extent.addPixelDelta(-delta);
318+
} else {
319+
super.applyUserOffset(delta);
320+
}
321+
}
322+
323+
@override
324+
void goBallistic(double velocity) {
325+
if (velocity == 0.0 ||
326+
(velocity < 0.0 && listShouldScroll) ||
327+
(velocity > 0.0 && extent.isAtMax)) {
328+
super.goBallistic(velocity);
329+
return;
330+
}
331+
// Scrollable expects that we will dispose of its current _dragCancelCallback
332+
_dragCancelCallback?.call();
333+
_dragCancelCallback = null;
334+
335+
// The iOS bouncing simulation just isn't right here - once we delegate
336+
// the ballistic back to the ScrollView, it will use the right simulation.
337+
final Simulation simulation = ClampingScrollSimulation(
338+
position: extent.currentExtent,
339+
velocity: velocity,
340+
tolerance: physics.tolerance,
341+
);
342+
343+
final AnimationController ballisticController = AnimationController.unbounded(
344+
debugLabel: '$runtimeType',
345+
vsync: context.vsync,
346+
);
347+
double lastDelta = 0;
348+
void _tick() {
349+
final double delta = ballisticController.value - lastDelta;
350+
lastDelta = ballisticController.value;
351+
extent.addPixelDelta(delta);
352+
if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) {
353+
// Make sure we pass along enough velocity to keep scrolling - otherwise
354+
// we just "bounce" off the top making it look like the list doesn't
355+
// have more to scroll.
356+
velocity = ballisticController.velocity + (physics.tolerance.velocity * ballisticController.velocity.sign);
357+
super.goBallistic(velocity);
358+
ballisticController.stop();
359+
}
360+
}
361+
362+
ballisticController
363+
..addListener(_tick)
364+
..animateWith(simulation).whenCompleteOrCancel(
365+
ballisticController.dispose,
366+
);
367+
}
368+
369+
@override
370+
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
371+
// Save this so we can call it later if we have to [goBallistic] on our own.
372+
_dragCancelCallback = dragCancelCallback;
373+
return super.drag(details, dragCancelCallback);
374+
}
375+
}

packages/flutter/lib/widgets.dart

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export 'src/widgets/container.dart';
3030
export 'src/widgets/debug.dart';
3131
export 'src/widgets/dismissible.dart';
3232
export 'src/widgets/drag_target.dart';
33+
export 'src/widgets/draggable_scrollable_sheet.dart';
3334
export 'src/widgets/editable_text.dart';
3435
export 'src/widgets/fade_in_image.dart';
3536
export 'src/widgets/focus_manager.dart';

0 commit comments

Comments
 (0)