blob: 89f2e70384277afa170346305dafde8ceca5fec6 [file] [log] [blame]
// Copyright 2019 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/gestures.dart';
import 'basic.dart';
import 'framework.dart';
import 'layout_builder.dart';
import 'scroll_context.dart';
import 'scroll_controller.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart';
import 'scroll_simulation.dart';
/// The signature of a method that provides a [BuildContext] and
/// [ScrollController] for building a widget that may overflow the draggable
/// [Axis] of the containing [DraggableScrollSheet].
///
/// Users should apply the [scrollController] to a [ScrollView] subclass, such
/// as a [SingleChildScrollView], [ListView] or [GridView], to have the whole
/// sheet be draggable.
typedef ScrollableWidgetBuilder = Widget Function(
BuildContext context,
ScrollController scrollController,
);
/// A container for a [Scrollable] that responds to drag gestures by resizing
/// the scrollable until a limit is reached, and then scrolling.
///
/// This widget can be dragged along the vertical axis between its
/// [minChildSize], which defaults to `0.25` and [maxChildSize], which defaults
/// to `1.0`. These sizes are percentages of the height of the parent container.
///
/// The widget coordinates resizing and scrolling of the widget returned by
/// builder as the user drags along the horizontal axis.
///
/// The widget will initially be displayed at its initialChildSize which
/// defaults to `0.5`, meaning half the height of its parent. Dragging will work
/// between the range of minChildSize and maxChildSize (as percentages of the
/// parent container's height) as long as the builder creates a widget which
/// uses the provided [ScrollController]. If the widget created by the
/// [ScrollableWidgetBuilder] does not use provided [ScrollController], the
/// sheet will remain at the initialChildSize.
///
/// {@tool sample}
///
/// This is a sample widget which shows a [ListView] that has 25 [ListTile]s.
/// It starts out as taking up half the body of the [Scaffold], and can be
/// dragged up to the full height of the scaffold or down to 25% of the height
/// of the scaffold. Upon reaching full height, the list contents will be
/// scrolled up or down, until they reach the top of the list again and the user
/// drags the sheet back down.
///
/// ```dart
/// class HomePage extends StatelessWidget {
/// @override
/// Widget build(BuildContext context) {
/// return Scaffold(
/// appBar: AppBar(
/// title: const Text('DraggableScrollableSheet'),
/// ),
/// body: SizedBox.expand(
/// child: DraggableScrollableSheet(
/// builder: (BuildContext context, ScrollController scrollController) {
/// return Container(
/// color: Colors.blue[100],
/// child: ListView.builder(
/// controller: scrollController,
/// itemCount: 25,
/// itemBuilder: (BuildContext context, int index) {
/// return ListTile(title: Text('Item $index'));
/// },
/// ),
/// );
/// },
/// ),
/// ),
/// );
/// }
/// }
/// ```
/// {@end-tool}
class DraggableScrollableSheet extends StatefulWidget {
/// Creates a widget that can be dragged and scrolled in a single gesture.
///
/// The [builder], [initialChildSize], [minChildSize], and [maxChildSize]
/// parameters must not be null.
const DraggableScrollableSheet({
Key key,
this.initialChildSize = 0.5,
this.minChildSize = 0.25,
this.maxChildSize = 1.0,
@required this.builder,
}) : assert(initialChildSize != null),
assert(minChildSize != null),
assert(maxChildSize != null),
assert(minChildSize >= 0.0),
assert(maxChildSize <= 1.0),
assert(minChildSize <= initialChildSize),
assert(initialChildSize <= maxChildSize),
assert(builder != null),
super(key: key);
/// The initial fractional value of the parent container's height to use when
/// displaying the widget.
///
/// The default value is `0.5`.
final double initialChildSize;
/// The minimum fractional value of the parent container's height to use when
/// displaying the widget.
///
/// The default value is `0.25`.
final double minChildSize;
/// The maximum fractional value of the parent container's height to use when
/// displaying the widget.
///
/// The default value is `1.0`.
final double maxChildSize;
/// The builder that creates a child to display in this widget, which will
/// use the provided [ScrollController] to enable dragging and scrolling
/// of the contents.
final ScrollableWidgetBuilder builder;
@override
_DraggableScrollableSheetState createState() => _DraggableScrollableSheetState();
}
/// Manages state between [_DraggableScrollableSheetState],
/// [_DraggableScrollableSheetScrollController], and
/// [_DraggableScrollableSheetScrollPosition].
///
/// The State knows the pixels available along the axis the widget wants to
/// scroll, but expects to get a fraction of those pixels to render the sheet.
///
/// The ScrollPosition knows the number of pixels a user wants to move the sheet.
///
/// The [currentExtent] will never be null.
/// The [availablePixels] will never be null, but may be `double.infinity`.
class _DraggableSheetExtent {
_DraggableSheetExtent({
@required this.minExtent,
@required this.maxExtent,
@required this.initialExtent,
@required VoidCallback listener,
}) : assert(minExtent != null),
assert(maxExtent != null),
assert(initialExtent != null),
assert(minExtent >= 0),
assert(maxExtent <= 1),
assert(minExtent <= initialExtent),
assert(initialExtent <= maxExtent),
_currentExtent = ValueNotifier<double>(initialExtent)..addListener(listener),
availablePixels = double.infinity;
final double minExtent;
final double maxExtent;
final double initialExtent;
final ValueNotifier<double> _currentExtent;
double availablePixels;
bool get isAtMin => minExtent >= _currentExtent.value;
bool get isAtMax => maxExtent <= _currentExtent.value;
set currentExtent(double value) {
assert(value != null);
_currentExtent.value = value.clamp(minExtent, maxExtent);
}
double get currentExtent => _currentExtent.value;
/// The scroll position gets inputs in terms of pixels, but the extent is
/// expected to be expressed as a number between 0..1.
void addPixelDelta(double delta) {
currentExtent += delta / availablePixels;
}
}
class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
_DraggableScrollableSheetScrollController _scrollController;
_DraggableSheetExtent _extent;
@override
void initState() {
super.initState();
_extent = _DraggableSheetExtent(
minExtent: widget.minChildSize,
maxExtent: widget.maxChildSize,
initialExtent: widget.initialChildSize,
listener: _setExtent,
);
_scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
}
void _setExtent() {
setState(() {
// _extent has been updated when this is called.
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
_extent.availablePixels = widget.maxChildSize * constraints.biggest.height;
return SizedBox.expand(
child: FractionallySizedBox(
heightFactor: _extent.currentExtent,
child: widget.builder(context, _scrollController),
alignment: Alignment.bottomCenter,
),
);
},
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
/// A [ScrollController] suitable for use in a [ScrollableWidgetBuilder] created
/// by a [DraggableScrollableSheet].
///
/// If a [DraggableScrollableSheet] contains content that is exceeds the height
/// of its container, this controller will allow the sheet to both be dragged to
/// fill the container and then scroll the child content.
///
/// See also:
///
/// * [_DraggableScrollableSheetScrollPosition], which manages the positioning logic for
/// this controller.
/// * [PrimaryScrollController], which can be used to establish a
/// [_DraggableScrollableSheetScrollController] as the primary controller for
/// descendants.
class _DraggableScrollableSheetScrollController extends ScrollController {
_DraggableScrollableSheetScrollController({
double initialScrollOffset = 0.0,
String debugLabel,
@required this.extent,
}) : assert(extent != null),
super(
debugLabel: debugLabel,
initialScrollOffset: initialScrollOffset,
);
final _DraggableSheetExtent extent;
@override
_DraggableScrollableSheetScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition oldPosition,
) {
return _DraggableScrollableSheetScrollPosition(
physics: physics,
context: context,
oldPosition: oldPosition,
extent: extent,
);
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('extent: $extent');
}
}
/// A scroll position that manages scroll activities for
/// [_DraggableScrollableSheetScrollController].
///
/// This class is a concrete subclass of [ScrollPosition] logic that handles a
/// single [ScrollContext], such as a [Scrollable]. An instance of this class
/// manages [ScrollActivity] instances, which changes the
/// [_DraggableSheetExtent.currentExtent] or visible content offset in the
/// [Scrollable]'s [Viewport]
///
/// See also:
///
/// * [_DraggableScrollableSheetScrollController], which uses this as its [ScrollPosition].
class _DraggableScrollableSheetScrollPosition
extends ScrollPositionWithSingleContext {
_DraggableScrollableSheetScrollPosition({
@required ScrollPhysics physics,
@required ScrollContext context,
double initialPixels = 0.0,
bool keepScrollOffset = true,
ScrollPosition oldPosition,
String debugLabel,
@required this.extent,
}) : assert(extent != null),
super(
physics: physics,
context: context,
initialPixels: initialPixels,
keepScrollOffset: keepScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
);
VoidCallback _dragCancelCallback;
final _DraggableSheetExtent extent;
bool get listShouldScroll => pixels > 0.0;
@override
void applyUserOffset(double delta) {
if (!listShouldScroll &&
!(extent.isAtMin || extent.isAtMax) ||
(extent.isAtMin && delta < 0) ||
(extent.isAtMax && delta > 0)) {
extent.addPixelDelta(-delta);
} else {
super.applyUserOffset(delta);
}
}
@override
void goBallistic(double velocity) {
if (velocity == 0.0 ||
(velocity < 0.0 && listShouldScroll) ||
(velocity > 0.0 && extent.isAtMax)) {
super.goBallistic(velocity);
return;
}
// Scrollable expects that we will dispose of its current _dragCancelCallback
_dragCancelCallback?.call();
_dragCancelCallback = null;
// The iOS bouncing simulation just isn't right here - once we delegate
// the ballistic back to the ScrollView, it will use the right simulation.
final Simulation simulation = ClampingScrollSimulation(
position: extent.currentExtent,
velocity: velocity,
tolerance: physics.tolerance,
);
final AnimationController ballisticController = AnimationController.unbounded(
debugLabel: '$runtimeType',
vsync: context.vsync,
);
double lastDelta = 0;
void _tick() {
final double delta = ballisticController.value - lastDelta;
lastDelta = ballisticController.value;
extent.addPixelDelta(delta);
if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) {
// Make sure we pass along enough velocity to keep scrolling - otherwise
// we just "bounce" off the top making it look like the list doesn't
// have more to scroll.
velocity = ballisticController.velocity + (physics.tolerance.velocity * ballisticController.velocity.sign);
super.goBallistic(velocity);
ballisticController.stop();
}
}
ballisticController
..addListener(_tick)
..animateWith(simulation).whenCompleteOrCancel(
ballisticController.dispose,
);
}
@override
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
// Save this so we can call it later if we have to [goBallistic] on our own.
_dragCancelCallback = dragCancelCallback;
return super.drag(details, dragCancelCallback);
}
}