Unverified Commit 6a48e663 authored by Dan Field's avatar Dan Field Committed by GitHub

Bottom sheet scrolling (#21896)

parent fdcc8aaf
......@@ -5,6 +5,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
......@@ -14,9 +15,9 @@ import 'material_localizations.dart';
import 'scaffold.dart';
import 'theme.dart';
const Duration _kBottomSheetDuration = Duration(milliseconds: 200);
const double _kMinFlingVelocity = 700.0;
const double _kCloseProgressThreshold = 0.5;
const Duration _bottomSheetDuration = Duration(milliseconds: 200);
const double _minFlingVelocity = 700.0;
const double _closeProgressThreshold = 0.5;
/// A material design bottom sheet.
///
......@@ -56,6 +57,7 @@ class BottomSheet extends StatefulWidget {
this.animationController,
this.enableDrag = true,
this.elevation = 0.0,
this.backgroundColor,
@required this.onClosing,
@required this.builder,
}) : assert(enableDrag != null),
......@@ -64,7 +66,8 @@ class BottomSheet extends StatefulWidget {
assert(elevation != null && elevation >= 0.0),
super(key: key);
/// The animation that controls the bottom sheet's position.
/// The animation controller that controls the bottom sheet's entrance and
/// exit animations.
///
/// The BottomSheet widget will manipulate the position of this animation, it
/// is not just a passive observer.
......@@ -83,8 +86,8 @@ class BottomSheet extends StatefulWidget {
/// [Material] widget.
final WidgetBuilder builder;
/// If true, the bottom sheet can dragged up and down and dismissed by swiping
/// downwards.
/// If true, the bottom sheet can be dragged up and down and dismissed by
/// swiping downards.
///
/// Default is true.
final bool enableDrag;
......@@ -96,13 +99,23 @@ class BottomSheet extends StatefulWidget {
/// Defaults to 0. The value is non-negative.
final double elevation;
/// The color for the [Material] of the bottom sheet.
///
/// Defaults to [Colors.white]. The value must not be null.
final Color backgroundColor;
@override
_BottomSheetState createState() => _BottomSheetState();
/// Creates an animation controller suitable for controlling a [BottomSheet].
/// Creates an [AnimationController] suitable for a
/// [BottomSheet.animationController].
///
/// This API available as a convenience for a Material compliant bottom sheet
/// animation. If alternative animation durations are required, a different
/// animation controller could be provided.
static AnimationController createAnimationController(TickerProvider vsync) {
return AnimationController(
duration: _kBottomSheetDuration,
duration: _bottomSheetDuration,
debugLabel: 'BottomSheet',
vsync: vsync,
);
......@@ -121,35 +134,50 @@ class _BottomSheetState extends State<BottomSheet> {
bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse;
void _handleDragUpdate(DragUpdateDetails details) {
assert(widget.enableDrag);
if (_dismissUnderway)
return;
widget.animationController.value -= details.primaryDelta / (_childHeight ?? details.primaryDelta);
}
void _handleDragEnd(DragEndDetails details) {
assert(widget.enableDrag);
if (_dismissUnderway)
return;
if (details.velocity.pixelsPerSecond.dy > _kMinFlingVelocity) {
if (details.velocity.pixelsPerSecond.dy > _minFlingVelocity) {
final double flingVelocity = -details.velocity.pixelsPerSecond.dy / _childHeight;
if (widget.animationController.value > 0.0)
if (widget.animationController.value > 0.0) {
widget.animationController.fling(velocity: flingVelocity);
if (flingVelocity < 0.0)
}
if (flingVelocity < 0.0) {
widget.onClosing();
} else if (widget.animationController.value < _kCloseProgressThreshold) {
}
} else if (widget.animationController.value < _closeProgressThreshold) {
if (widget.animationController.value > 0.0)
widget.animationController.fling(velocity: -1.0);
widget.onClosing();
} else {
widget.animationController.forward();
}
}
bool extentChanged(DraggableScrollableNotification notification) {
if (notification.extent == notification.minExtent) {
widget.onClosing();
}
return false;
}
@override
Widget build(BuildContext context) {
final Widget bottomSheet = Material(
key: _childKey,
color: widget.backgroundColor,
elevation: widget.elevation,
child: widget.builder(context),
child: NotificationListener<DraggableScrollableNotification>(
onNotification: extentChanged,
child: widget.builder(context),
),
);
return !widget.enableDrag ? bottomSheet : GestureDetector(
onVerticalDragUpdate: _handleDragUpdate,
......@@ -166,11 +194,11 @@ class _BottomSheetState extends State<BottomSheet> {
// MODAL BOTTOM SHEETS
class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
_ModalBottomSheetLayout(this.progress);
_ModalBottomSheetLayout(this.progress, this.isScrollControlled);
final double progress;
final bool isScrollControlled;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
......@@ -178,7 +206,9 @@ class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
minWidth: constraints.maxWidth,
maxWidth: constraints.maxWidth,
minHeight: 0.0,
maxHeight: constraints.maxHeight * 9.0 / 16.0,
maxHeight: isScrollControlled
? constraints.maxHeight
: constraints.maxHeight * 9.0 / 16.0,
);
}
......@@ -194,33 +224,46 @@ class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
}
class _ModalBottomSheet<T> extends StatefulWidget {
const _ModalBottomSheet({ Key key, this.route }) : super(key: key);
const _ModalBottomSheet({
Key key,
this.route,
this.isScrollControlled = false,
}) : assert(isScrollControlled != null),
super(key: key);
final _ModalBottomSheetRoute<T> route;
final bool isScrollControlled;
@override
_ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>();
}
class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
@override
Widget build(BuildContext context) {
final MediaQueryData mediaQuery = MediaQuery.of(context);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
String routeLabel;
String _getRouteLabel(MaterialLocalizations localizations) {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
routeLabel = '';
break;
return '';
case TargetPlatform.android:
case TargetPlatform.fuchsia:
routeLabel = localizations.dialogLabel;
break;
return localizations.dialogLabel;
}
return null;
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
assert(debugCheckHasMaterialLocalizations(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final String routeLabel = _getRouteLabel(localizations);
return GestureDetector(
excludeFromSemantics: true,
onTap: () => Navigator.pop(context),
onTap: () {
if (widget.route.isCurrent)
Navigator.pop(context);
},
child: AnimatedBuilder(
animation: widget.route.animation,
builder: (BuildContext context, Widget child) {
......@@ -234,10 +277,15 @@ class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
explicitChildNodes: true,
child: ClipRect(
child: CustomSingleChildLayout(
delegate: _ModalBottomSheetLayout(animationValue),
delegate: _ModalBottomSheetLayout(animationValue, widget.isScrollControlled),
child: BottomSheet(
backgroundColor: widget.route.backgroundColor,
animationController: widget.route._animationController,
onClosing: () => Navigator.pop(context),
onClosing: () {
if (widget.route.isCurrent) {
Navigator.pop(context);
}
},
builder: widget.route.builder,
),
),
......@@ -254,14 +302,19 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
this.builder,
this.theme,
this.barrierLabel,
@required this.isScrollControlled,
this.backgroundColor,
RouteSettings settings,
}) : super(settings: settings);
}) : assert(isScrollControlled != null),
super(settings: settings);
final WidgetBuilder builder;
final ThemeData theme;
final bool isScrollControlled;
final Color backgroundColor;
@override
Duration get transitionDuration => _kBottomSheetDuration;
Duration get transitionDuration => _bottomSheetDuration;
@override
bool get barrierDismissible => true;
......@@ -288,7 +341,7 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
Widget bottomSheet = MediaQuery.removePadding(
context: context,
removeTop: true,
child: _ModalBottomSheet<T>(route: this),
child: _ModalBottomSheet<T>(route: this, isScrollControlled: isScrollControlled),
);
if (theme != null)
bottomSheet = Theme(data: theme, child: bottomSheet);
......@@ -312,6 +365,12 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
/// corresponding widget can be safely removed from the tree before the bottom
/// sheet is closed.
///
/// The `isScrollControlled` parameter specifies whether this is a route for
/// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish
/// to have a bottom sheet that has a scrollable child such as a [ListView] or
/// a [GridView] and have the bottom sheet be draggable, you should set this
/// parameter to true.
///
/// Returns a `Future` that resolves to the value (if any) that was passed to
/// [Navigator.pop] when the modal bottom sheet was closed.
///
......@@ -325,18 +384,26 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
Future<T> showModalBottomSheet<T>({
@required BuildContext context,
@required WidgetBuilder builder,
bool isScrollControlled = false,
Color backgroundColor,
}) {
assert(context != null);
assert(builder != null);
assert(isScrollControlled != null);
assert(debugCheckHasMediaQuery(context));
assert(debugCheckHasMaterialLocalizations(context));
return Navigator.push(context, _ModalBottomSheetRoute<T>(
builder: builder,
theme: Theme.of(context, shadowThemeOnly: true),
isScrollControlled: isScrollControlled,
backgroundColor: backgroundColor,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
));
}
/// Shows a persistent material design bottom sheet in the nearest [Scaffold].
/// Shows a material design bottom sheet in the nearest [Scaffold] ancestor. If
/// you wish to show a persistent bottom sheet, use [Scaffold.bottomSheet].
///
/// Returns a controller that can be used to close and otherwise manipulate the
/// bottom sheet.
......@@ -353,10 +420,6 @@ Future<T> showModalBottomSheet<T>({
/// does not add a back button to the enclosing Scaffold's appbar, use the
/// [Scaffold.bottomSheet] constructor parameter.
///
/// A persistent bottom sheet shows information that supplements the primary
/// content of the app. A persistent bottom sheet remains visible even when
/// the user interacts with other parts of the app.
///
/// A closely related widget is a modal bottom sheet, which is an alternative
/// to a menu or a dialog and prevents the user from interacting with the rest
/// of the app. Modal bottom sheets can be created and displayed with the
......@@ -376,8 +439,14 @@ Future<T> showModalBottomSheet<T>({
PersistentBottomSheetController<T> showBottomSheet<T>({
@required BuildContext context,
@required WidgetBuilder builder,
Color backgroundColor,
}) {
assert(context != null);
assert(builder != null);
return Scaffold.of(context).showBottomSheet<T>(builder);
assert(debugCheckHasScaffold(context));
return Scaffold.of(context).showBottomSheet<T>(
builder,
backgroundColor: backgroundColor,
);
}
......@@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart';
import 'material.dart';
import 'material_localizations.dart';
import 'scaffold.dart' show Scaffold;
/// Asserts that the given context has a [Material] ancestor.
///
......@@ -127,3 +128,36 @@ bool debugCheckHasMaterialLocalizations(BuildContext context) {
}());
return true;
}
/// Asserts that the given context has a [Scaffold] ancestor.
///
/// Used by various widgets to make sure that they are only used in an
/// appropriate context.
///
/// To invoke this function, use the following pattern, typically in the
/// relevant Widget's build method:
///
/// ```dart
/// assert(debugCheckHasScaffold(context));
/// ```
///
/// Does nothing if asserts are disabled. Always returns true.
bool debugCheckHasScaffold(BuildContext context) {
assert(() {
if (context.widget is! Scaffold && context.ancestorWidgetOfExactType(Scaffold) == null) {
final Element element = context;
throw FlutterError(
'No Scaffold widget found.\n'
'${context.widget.runtimeType} widgets require a Scaffold widget ancestor.\n'
'The Specific widget that could not find a Scaffold ancestor was:\n'
' ${context.widget}\n'
'The ownership chain for the affected widget is:\n'
' ${element.debugGetCreatorChain(10)}\n'
'Typically, the Scaffold widget is introduced by the MaterialApp or '
'WidgetsApp widget at the top of your application widget tree.'
);
}
return true;
}());
return true;
}
......@@ -8,7 +8,6 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior;
......@@ -16,6 +15,7 @@ import 'app_bar.dart';
import 'bottom_sheet.dart';
import 'button_bar.dart';
import 'button_theme.dart';
import 'colors.dart';
import 'divider.dart';
import 'drawer.dart';
import 'flexible_space_bar.dart';
......@@ -37,9 +37,16 @@ import 'theme_data.dart';
const FloatingActionButtonLocation _kDefaultFloatingActionButtonLocation = FloatingActionButtonLocation.endFloat;
const FloatingActionButtonAnimator _kDefaultFloatingActionButtonAnimator = FloatingActionButtonAnimator.scaling;
// When the top of the BottomSheet crosses this threshold, it will start to
// shrink the FAB and show a scrim.
const double _kBottomSheetDominatesPercentage = 0.3;
const double _kMinBottomSheetScrimOpacity = 0.1;
const double _kMaxBottomSheetScrimOpacity = 0.6;
enum _ScaffoldSlot {
body,
appBar,
bodyScrim,
bottomSheet,
snackBar,
persistentFooter,
......@@ -451,6 +458,14 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
Size bottomSheetSize = Size.zero;
Size snackBarSize = Size.zero;
if (hasChild(_ScaffoldSlot.bodyScrim)) {
final BoxConstraints bottomSheetScrimConstraints = BoxConstraints(
maxWidth: fullWidthConstraints.maxWidth,
maxHeight: contentBottom,
);
layoutChild(_ScaffoldSlot.bodyScrim, bottomSheetScrimConstraints);
positionChild(_ScaffoldSlot.bodyScrim, Offset.zero);
}
// Set the size of the SnackBar early if the behavior is fixed so
// the FAB can be positioned correctly.
......@@ -550,8 +565,10 @@ class _FloatingActionButtonTransition extends StatefulWidget {
@required this.fabMoveAnimation,
@required this.fabMotionAnimator,
@required this.geometryNotifier,
@required this.currentController,
}) : assert(fabMoveAnimation != null),
assert(fabMotionAnimator != null),
assert(currentController != null),
super(key: key);
final Widget child;
......@@ -559,18 +576,19 @@ class _FloatingActionButtonTransition extends StatefulWidget {
final FloatingActionButtonAnimator fabMotionAnimator;
final _ScaffoldGeometryNotifier geometryNotifier;
/// Controls the current child widget.child as it exits.
final AnimationController currentController;
@override
_FloatingActionButtonTransitionState createState() => _FloatingActionButtonTransitionState();
}
class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTransition> with TickerProviderStateMixin {
// The animations applied to the Floating Action Button when it is entering or exiting.
// Controls the previous widget.child as it exits
// Controls the previous widget.child as it exits.
AnimationController _previousController;
Animation<double> _previousScaleAnimation;
Animation<double> _previousRotationAnimation;
// Controls the current child widget.child as it exits
AnimationController _currentController;
// The animations to run, considering the widget's fabMoveAnimation and the current/previous entrance/exit animations.
Animation<double> _currentScaleAnimation;
Animation<double> _extendedCurrentScaleAnimation;
......@@ -585,18 +603,12 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
duration: kFloatingActionButtonSegue,
vsync: this,
)..addStatusListener(_handlePreviousAnimationStatusChanged);
_currentController = AnimationController(
duration: kFloatingActionButtonSegue,
vsync: this,
);
_updateAnimations();
if (widget.child != null) {
// If we start out with a child, have the child appear fully visible instead
// of animating in.
_currentController.value = 1.0;
widget.currentController.value = 1.0;
} else {
// If we start without a child we update the geometry object with a
// floating action button scale of 0, as it is not showing on the screen.
......@@ -607,7 +619,6 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
@override
void dispose() {
_previousController.dispose();
_currentController.dispose();
super.dispose();
}
......@@ -623,13 +634,13 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
_updateAnimations();
}
if (_previousController.status == AnimationStatus.dismissed) {
final double currentValue = _currentController.value;
final double currentValue = widget.currentController.value;
if (currentValue == 0.0 || oldWidget.child == null) {
// The current child hasn't started its entrance animation yet. We can
// just skip directly to the new child's entrance.
_previousChild = null;
if (widget.child != null)
_currentController.forward();
widget.currentController.forward();
} else {
// Otherwise, we need to copy the state from the current controller to
// the previous controller and run an exit animation for the previous
......@@ -638,7 +649,7 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
_previousController
..value = currentValue
..reverse();
_currentController.value = 0.0;
widget.currentController.value = 0.0;
}
}
}
......@@ -662,10 +673,10 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
);
final CurvedAnimation currentEntranceScaleAnimation = CurvedAnimation(
parent: _currentController,
parent: widget.currentController,
curve: Curves.easeIn,
);
final Animation<double> currentEntranceRotationAnimation = _currentController.drive(_entranceTurnTween);
final Animation<double> currentEntranceRotationAnimation = widget.currentController.drive(_entranceTurnTween);
// Get the animations for when the FAB is moving.
final Animation<double> moveScaleAnimation = widget.fabMotionAnimator.getScaleAnimation(parent: widget.fabMoveAnimation);
......@@ -686,9 +697,9 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
void _handlePreviousAnimationStatusChanged(AnimationStatus status) {
setState(() {
if (status == AnimationStatus.dismissed) {
assert(_currentController.status == AnimationStatus.dismissed);
assert(widget.currentController.status == AnimationStatus.dismissed);
if (widget.child != null)
_currentController.forward();
widget.currentController.forward();
}
});
}
......@@ -1271,11 +1282,20 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
final GlobalKey<DrawerControllerState> _drawerKey = GlobalKey<DrawerControllerState>();
final GlobalKey<DrawerControllerState> _endDrawerKey = GlobalKey<DrawerControllerState>();
/// Whether this scaffold has a non-null [Scaffold.appBar].
bool get hasAppBar => widget.appBar != null;
/// Whether this scaffold has a non-null [Scaffold.drawer].
bool get hasDrawer => widget.drawer != null;
/// Whether this scaffold has a non-null [Scaffold.endDrawer].
bool get hasEndDrawer => widget.endDrawer != null;
/// Whether this scaffold has a non-null [Scaffold.floatingActionButton].
bool get hasFloatingActionButton => widget.floatingActionButton != null;
double _appBarMaxHeight;
/// The max height the [Scaffold.appBar] uses.
///
/// This is based on the appBar preferred height plus the top padding.
double get appBarMaxHeight => _appBarMaxHeight;
bool _drawerOpened = false;
bool _endDrawerOpened = false;
......@@ -1455,85 +1475,168 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
// PERSISTENT BOTTOM SHEET API
final List<_PersistentBottomSheet> _dismissedBottomSheets = <_PersistentBottomSheet>[];
// Contains bottom sheets that may still be animating out of view.
// Important if the app/user takes an action that could repeatedly show a
// bottom sheet.
final List<_StandardBottomSheet> _dismissedBottomSheets = <_StandardBottomSheet>[];
PersistentBottomSheetController<dynamic> _currentBottomSheet;
void _maybeBuildCurrentBottomSheet() {
if (widget.bottomSheet != null) {
void _maybeBuildPersistentBottomSheet() {
if (widget.bottomSheet != null && _currentBottomSheet == null) {
// The new _currentBottomSheet is not a local history entry so a "back" button
// will not be added to the Scaffold's appbar and the bottom sheet will not
// support drag or swipe to dismiss.
final AnimationController animationController = BottomSheet.createAnimationController(this)..value = 1.0;
LocalHistoryEntry _persistentSheetHistoryEntry;
bool _persistentBottomSheetExtentChanged(DraggableScrollableNotification notification) {
if (notification.extent > notification.initialExtent) {
if (_persistentSheetHistoryEntry == null) {
_persistentSheetHistoryEntry = LocalHistoryEntry(onRemove: () {
if (notification.extent > notification.initialExtent) {
DraggableScrollableActuator.reset(notification.context);
}
showBodyScrim(false, 0.0);
_floatingActionButtonVisibilityValue = 1.0;
_persistentSheetHistoryEntry = null;
});
ModalRoute.of(context).addLocalHistoryEntry(_persistentSheetHistoryEntry);
}
} else if (_persistentSheetHistoryEntry != null) {
ModalRoute.of(context).removeLocalHistoryEntry(_persistentSheetHistoryEntry);
}
return false;
}
_currentBottomSheet = _buildBottomSheet<void>(
(BuildContext context) => widget.bottomSheet,
BottomSheet.createAnimationController(this) ..value = 1.0,
false,
(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: _persistentBottomSheetExtentChanged,
child: DraggableScrollableActuator(
child: widget.bottomSheet,
),
);
},
true,
animationController: animationController,
);
}
}
void _closeCurrentBottomSheet() {
if (_currentBottomSheet != null) {
_currentBottomSheet.close();
assert(_currentBottomSheet == null);
if (!_currentBottomSheet._isLocalHistoryEntry) {
_currentBottomSheet.close();
}
assert(() {
_currentBottomSheet?._completer?.future?.whenComplete(() {
assert(_currentBottomSheet == null);
});
return true;
}());
}
}
PersistentBottomSheetController<T> _buildBottomSheet<T>(WidgetBuilder builder, AnimationController controller, bool isLocalHistoryEntry) {
PersistentBottomSheetController<T> _buildBottomSheet<T>(
WidgetBuilder builder,
bool isPersistent, {
AnimationController animationController,
Color backgroundColor,
}) {
assert(() {
if (widget.bottomSheet != null && isPersistent && _currentBottomSheet != null) {
throw FlutterError(
'Scaffold.bottomSheet cannot be specified while a bottom sheet displayed '
'with showBottomSheet() is still visible.\n Rebuild the Scaffold with a null '
'bottomSheet before calling showBottomSheet().'
);
}
return true;
}());
final Completer<T> completer = Completer<T>();
final GlobalKey<_PersistentBottomSheetState> bottomSheetKey = GlobalKey<_PersistentBottomSheetState>();
_PersistentBottomSheet bottomSheet;
final GlobalKey<_StandardBottomSheetState> bottomSheetKey = GlobalKey<_StandardBottomSheetState>();
_StandardBottomSheet bottomSheet;
bool removedEntry = false;
void _removeCurrentBottomSheet() {
removedEntry = true;
if (_currentBottomSheet == null) {
return;
}
assert(_currentBottomSheet._widget == bottomSheet);
assert(bottomSheetKey.currentState != null);
bottomSheetKey.currentState.close();
if (controller.status != AnimationStatus.dismissed)
_dismissedBottomSheets.add(bottomSheet);
setState(() {
_currentBottomSheet = null;
});
completer.complete();
_showFloatingActionButton();
void _closed(void value) {
setState(() {
_currentBottomSheet = null;
});
if (animationController.status != AnimationStatus.dismissed) {
_dismissedBottomSheets.add(bottomSheet);
}
completer.complete();
}
final Future<void> closing = bottomSheetKey.currentState.close();
if (closing != null) {
closing.then(_closed);
} else {
_closed(null);
}
}
final LocalHistoryEntry entry = isLocalHistoryEntry
? LocalHistoryEntry(onRemove: _removeCurrentBottomSheet)
: null;
final LocalHistoryEntry entry = isPersistent
? null
: LocalHistoryEntry(onRemove: () {
if (!removedEntry) {
_removeCurrentBottomSheet();
}
});
bottomSheet = _PersistentBottomSheet(
bottomSheet = _StandardBottomSheet(
key: bottomSheetKey,
animationController: controller,
enableDrag: isLocalHistoryEntry,
animationController: animationController,
enableDrag: !isPersistent,
onClosing: () {
if (_currentBottomSheet == null) {
return;
}
assert(_currentBottomSheet._widget == bottomSheet);
if (isLocalHistoryEntry)
if (!isPersistent && !removedEntry) {
assert(entry != null);
entry.remove();
else
_removeCurrentBottomSheet();
removedEntry = true;
}
},
onDismissed: () {
if (_dismissedBottomSheets.contains(bottomSheet)) {
bottomSheet.animationController.dispose();
setState(() {
_dismissedBottomSheets.remove(bottomSheet);
});
}
},
builder: builder,
isPersistent: isPersistent,
backgroundColor: backgroundColor,
);
if (isLocalHistoryEntry)
if (!isPersistent)
ModalRoute.of(context).addLocalHistoryEntry(entry);
return PersistentBottomSheetController<T>._(
bottomSheet,
completer,
isLocalHistoryEntry ? entry.remove : _removeCurrentBottomSheet,
entry != null
? entry.remove
: _removeCurrentBottomSheet,
(VoidCallback fn) { bottomSheetKey.currentState?.setState(fn); },
isLocalHistoryEntry,
!isPersistent,
);
}
/// Shows a persistent material design bottom sheet in the nearest [Scaffold].
/// Shows a material design bottom sheet in the nearest [Scaffold]. To show
/// a persistent bottom sheet, use the [Scaffold.bottomSheet].
///
/// Returns a controller that can be used to close and otherwise manipulate the
/// bottom sheet.
......@@ -1567,12 +1670,31 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
/// sheet.
/// * [Scaffold.of], for information about how to obtain the [ScaffoldState].
/// * <https://material.io/design/components/sheets-bottom.html#standard-bottom-sheet>
PersistentBottomSheetController<T> showBottomSheet<T>(WidgetBuilder builder) {
PersistentBottomSheetController<T> showBottomSheet<T>(
WidgetBuilder builder, {
Color backgroundColor,
}) {
assert(() {
if (widget.bottomSheet != null) {
throw FlutterError(
'Scaffold.bottomSheet cannot be specified while a bottom sheet displayed '
'with showBottomSheet() is still visible.\n Rebuild the Scaffold with a null '
'bottomSheet before calling showBottomSheet().'
);
}
return true;
}());
assert(debugCheckHasMediaQuery(context));
_closeCurrentBottomSheet();
final AnimationController controller = BottomSheet.createAnimationController(this)
..forward();
final AnimationController controller = BottomSheet.createAnimationController(this)..forward();
setState(() {
_currentBottomSheet = _buildBottomSheet<T>(builder, controller, true);
_currentBottomSheet = _buildBottomSheet<T>(
builder,
false,
animationController: controller,
backgroundColor: backgroundColor,
);
});
return _currentBottomSheet;
}
......@@ -1583,6 +1705,27 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
FloatingActionButtonLocation _previousFloatingActionButtonLocation;
FloatingActionButtonLocation _floatingActionButtonLocation;
AnimationController _floatingActionButtonVisibilityController;
/// Gets the current value of the visibility animation for the
/// [Scaffold.floatingActionButton].
double get _floatingActionButtonVisibilityValue => _floatingActionButtonVisibilityController.value;
/// Sets the current value of the visibility animation for the
/// [Scaffold.floatingActionButton]. This value must not be null.
set _floatingActionButtonVisibilityValue(double newValue) {
assert(newValue != null);
_floatingActionButtonVisibilityController.value = newValue.clamp(
_floatingActionButtonVisibilityController.lowerBound,
_floatingActionButtonVisibilityController.upperBound,
);
}
/// Shows the [Scaffold.floatingActionButton].
TickerFuture _showFloatingActionButton() {
return _floatingActionButtonVisibilityController.forward();
}
// Moves the Floating Action Button to the new Floating Action Button Location.
void _moveFloatingActionButton(final FloatingActionButtonLocation newLocation) {
FloatingActionButtonLocation previousLocation = _floatingActionButtonLocation;
......@@ -1646,7 +1789,11 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
value: 1.0,
duration: kFloatingActionButtonSegue * 2,
);
_maybeBuildCurrentBottomSheet();
_floatingActionButtonVisibilityController = AnimationController(
duration: kFloatingActionButtonSegue,
vsync: this,
);
}
@override
......@@ -1671,7 +1818,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
return true;
}());
_closeCurrentBottomSheet();
_maybeBuildCurrentBottomSheet();
_maybeBuildPersistentBottomSheet();
}
super.didUpdateWidget(oldWidget);
}
......@@ -1690,6 +1837,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
}
_accessibleNavigation = mediaQuery.accessibleNavigation;
_maybeBuildPersistentBottomSheet();
super.didChangeDependencies();
}
......@@ -1699,11 +1847,14 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
_snackBarTimer?.cancel();
_snackBarTimer = null;
_geometryNotifier.dispose();
for (_PersistentBottomSheet bottomSheet in _dismissedBottomSheets)
bottomSheet.animationController.dispose();
if (_currentBottomSheet != null)
_currentBottomSheet._widget.animationController.dispose();
for (_StandardBottomSheet bottomSheet in _dismissedBottomSheets) {
bottomSheet.animationController?.dispose();
}
if (_currentBottomSheet != null) {
_currentBottomSheet._widget.animationController?.dispose();
}
_floatingActionButtonMoveController.dispose();
_floatingActionButtonVisibilityController.dispose();
super.dispose();
}
......@@ -1780,6 +1931,23 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
}
}
bool _showBodyScrim = false;
Color _bodyScrimColor = Colors.black;
/// Whether to show a [ModalBarrier] over the body of the scaffold.
///
/// The `value` parameter must not be null.
void showBodyScrim(bool value, double opacity) {
assert(value != null);
if (_showBodyScrim == value && _bodyScrimColor.opacity == opacity) {
return;
}
setState(() {
_showBodyScrim = value;
_bodyScrimColor = Colors.black.withOpacity(opacity);
});
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
......@@ -1822,17 +1990,31 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
removeBottomPadding: widget.bottomNavigationBar != null || widget.persistentFooterButtons != null,
removeBottomInset: _resizeToAvoidBottomInset,
);
if (_showBodyScrim) {
_addIfNonNull(
children,
ModalBarrier(
dismissible: false,
color: _bodyScrimColor,
),
_ScaffoldSlot.bodyScrim,
removeLeftPadding: true,
removeTopPadding: true,
removeRightPadding: true,
removeBottomPadding: true,
);
}
if (widget.appBar != null) {
final double topPadding = widget.primary ? mediaQuery.padding.top : 0.0;
final double extent = widget.appBar.preferredSize.height + topPadding;
assert(extent >= 0.0 && extent.isFinite);
_appBarMaxHeight = widget.appBar.preferredSize.height + topPadding;
assert(_appBarMaxHeight >= 0.0 && _appBarMaxHeight.isFinite);
_addIfNonNull(
children,
ConstrainedBox(
constraints: BoxConstraints(maxHeight: extent),
constraints: BoxConstraints(maxHeight: _appBarMaxHeight),
child: FlexibleSpaceBar.createSettings(
currentExtent: extent,
currentExtent: _appBarMaxHeight,
child: widget.appBar,
),
),
......@@ -1930,6 +2112,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
fabMoveAnimation: _floatingActionButtonMoveController,
fabMotionAnimator: _floatingActionButtonAnimator,
geometryNotifier: _geometryNotifier,
currentController: _floatingActionButtonVisibilityController,
),
_ScaffoldSlot.floatingActionButton,
removeLeftPadding: true,
......@@ -2023,85 +2206,137 @@ class ScaffoldFeatureController<T extends Widget, U> {
final StateSetter setState;
}
class _PersistentBottomSheet extends StatefulWidget {
const _PersistentBottomSheet({
class _StandardBottomSheet extends StatefulWidget {
const _StandardBottomSheet({
Key key,
this.animationController,
this.enableDrag = true,
this.onClosing,
this.onDismissed,
this.builder,
this.isPersistent = false,
this.backgroundColor,
}) : super(key: key);
final AnimationController animationController; // we control it, but it must be disposed by whoever created it
final AnimationController animationController; // we control it, but it must be disposed by whoever created it.
final bool enableDrag;
final VoidCallback onClosing;
final VoidCallback onDismissed;
final WidgetBuilder builder;
final bool isPersistent;
final Color backgroundColor;
@override
_PersistentBottomSheetState createState() => _PersistentBottomSheetState();
_StandardBottomSheetState createState() => _StandardBottomSheetState();
}
class _PersistentBottomSheetState extends State<_PersistentBottomSheet> {
class _StandardBottomSheetState extends State<_StandardBottomSheet> {
@override
void initState() {
super.initState();
assert(widget.animationController != null);
assert(widget.animationController.status == AnimationStatus.forward
|| widget.animationController.status == AnimationStatus.completed);
widget.animationController.addStatusListener(_handleStatusChange);
}
@override
void didUpdateWidget(_PersistentBottomSheet oldWidget) {
void didUpdateWidget(_StandardBottomSheet oldWidget) {
super.didUpdateWidget(oldWidget);
assert(widget.animationController == oldWidget.animationController);
}
void close() {
Future<void> close() {
assert(widget.animationController != null);
widget.animationController.reverse();
if (widget.onClosing != null) {
widget.onClosing();
}
return null;
}
void _handleStatusChange(AnimationStatus status) {
if (status == AnimationStatus.dismissed && widget.onDismissed != null)
if (status == AnimationStatus.dismissed && widget.onDismissed != null) {
widget.onDismissed();
}
}
bool extentChanged(DraggableScrollableNotification notification) {
final double extentRemaining = 1.0 - notification.extent;
final ScaffoldState scaffold = Scaffold.of(context);
if (extentRemaining < _kBottomSheetDominatesPercentage) {
scaffold._floatingActionButtonVisibilityValue = extentRemaining * _kBottomSheetDominatesPercentage * 10;
scaffold.showBodyScrim(true, math.max(
_kMinBottomSheetScrimOpacity,
_kMaxBottomSheetScrimOpacity - scaffold._floatingActionButtonVisibilityValue,
));
} else {
scaffold._floatingActionButtonVisibilityValue = 1.0;
scaffold.showBodyScrim(false, 0.0);
}
// If the Scaffold.bottomSheet != null, we're a persistent bottom sheet.
if (notification.extent == notification.minExtent && scaffold.widget.bottomSheet == null) {
close();
}
return false;
}
Widget _wrapBottomSheet(Widget bottomSheet) {
return Semantics(
container: true,
onDismiss: close,
child: NotificationListener<DraggableScrollableNotification>(
onNotification: extentChanged,
child: bottomSheet,
)
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.animationController,
builder: (BuildContext context, Widget child) {
return Align(
alignment: AlignmentDirectional.topStart,
heightFactor: widget.animationController.value,
child: child,
);
},
child: Semantics(
container: true,
onDismiss: () {
close();
widget.onClosing();
if (widget.animationController != null) {
return AnimatedBuilder(
animation: widget.animationController,
builder: (BuildContext context, Widget child) {
return Align(
alignment: AlignmentDirectional.topStart,
heightFactor: widget.animationController.value,
child: child
);
},
child: BottomSheet(
animationController: widget.animationController,
enableDrag: widget.enableDrag,
onClosing: widget.onClosing,
builder: widget.builder,
child: _wrapBottomSheet(
BottomSheet(
animationController: widget.animationController,
enableDrag: widget.enableDrag,
onClosing: widget.onClosing,
builder: widget.builder,
backgroundColor: widget.backgroundColor,
),
),
);
}
return _wrapBottomSheet(
BottomSheet(
onClosing: widget.onClosing,
builder: widget.builder,
backgroundColor: widget.backgroundColor,
),
);
}
}
/// A [ScaffoldFeatureController] for persistent bottom sheets.
/// A [ScaffoldFeatureController] for standard bottom sheets.
///
/// This is the type of objects returned by [ScaffoldState.showBottomSheet].
class PersistentBottomSheetController<T> extends ScaffoldFeatureController<_PersistentBottomSheet, T> {
///
/// This controller is used to display both standard and persistent bottom
/// sheets. A bottom sheet is only persistent if it is set as the
/// [Scaffold.bottomSheet].
class PersistentBottomSheetController<T> extends ScaffoldFeatureController<_StandardBottomSheet, T> {
const PersistentBottomSheetController._(
_PersistentBottomSheet widget,
_StandardBottomSheet widget,
Completer<T> completer,
VoidCallback close,
StateSetter setState,
......
......@@ -6,9 +6,12 @@ import 'package:flutter/gestures.dart';
import 'basic.dart';
import 'framework.dart';
import 'inherited_notifier.dart';
import 'layout_builder.dart';
import 'notification_listener.dart';
import 'scroll_context.dart';
import 'scroll_controller.dart';
import 'scroll_notification.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart';
......@@ -44,6 +47,11 @@ typedef ScrollableWidgetBuilder = Widget Function(
/// [ScrollableWidgetBuilder] does not use provided [ScrollController], the
/// sheet will remain at the initialChildSize.
///
/// By default, the widget will expand its non-occupied area to fill availble
/// space in the parent. If this is not desired, e.g. because the parent wants
/// to position sheet based on the space it is taking, the [expand] property
/// may be set to false.
///
/// {@tool sample}
///
/// This is a sample widget which shows a [ListView] that has 25 [ListTile]s.
......@@ -85,13 +93,14 @@ typedef ScrollableWidgetBuilder = Widget Function(
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.
/// The [builder], [initialChildSize], [minChildSize], [maxChildSize] and
/// [expand] parameters must not be null.
const DraggableScrollableSheet({
Key key,
this.initialChildSize = 0.5,
this.minChildSize = 0.25,
this.maxChildSize = 1.0,
this.expand = true,
@required this.builder,
}) : assert(initialChildSize != null),
assert(minChildSize != null),
......@@ -100,6 +109,7 @@ class DraggableScrollableSheet extends StatefulWidget {
assert(maxChildSize <= 1.0),
assert(minChildSize <= initialChildSize),
assert(initialChildSize <= maxChildSize),
assert(expand != null),
assert(builder != null),
super(key: key);
......@@ -121,6 +131,16 @@ class DraggableScrollableSheet extends StatefulWidget {
/// The default value is `1.0`.
final double maxChildSize;
/// Whether the widget should expand to fill the available space in its parent
/// or not.
///
/// In most cases, this should be true. However, in the case of a parent
/// widget that will position this one based on its desired size (such as a
/// [Center]), this should be set to false.
///
/// The default value is true.
final bool expand;
/// 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.
......@@ -130,6 +150,76 @@ class DraggableScrollableSheet extends StatefulWidget {
_DraggableScrollableSheetState createState() => _DraggableScrollableSheetState();
}
/// A [Notification] related to the extent, which is the size, and scroll
/// offset, which is the position of the child list, of the
/// [DraggableScrollableSheet].
///
/// [DraggableScrollableSheet] widgets notify their ancestors when the size of
/// the sheet changes. When the extent of the sheet changes via a drag,
/// this notification bubbles up through the tree, which means a given
/// [NotificationListener] will recieve notifications for all descendant
/// [DraggableScrollableSheet] widgets. To focus on notifications from the
/// nearest [DraggableScorllableSheet] descendant, check that the [depth]
/// property of the notification is zero.
///
/// When an extent notification is received by a [NotificationListener], the
/// listener will already have completed build and layout, and it is therefore
/// too late for that widget to call [State.setState]. Any attempt to adjust the
/// build or layout based on an extent notification would result in a layout
/// that lagged one frame behind, which is a poor user experience. Extent
/// notifications are used primarily to drive animations. The [Scaffold] widget
/// listens for extent notifications and responds by driving animations for the
/// [FloatingActionButton] as the bottom sheet scrolls up.
class DraggableScrollableNotification extends Notification with ViewportNotificationMixin {
/// Creates a notification that the extent of a [DraggableScrollableSheet] has
/// changed.
///
/// All parameters are required. The [minExtent] must be >= 0. The [maxExtent]
/// must be <= 1.0. The [extent] must be between [minExtent] and [maxExtent].
DraggableScrollableNotification({
@required this.extent,
@required this.minExtent,
@required this.maxExtent,
@required this.initialExtent,
@required this.context,
}) : assert(extent != null),
assert(initialExtent != null),
assert(minExtent != null),
assert(maxExtent != null),
assert(0.0 <= minExtent),
assert(maxExtent <= 1.0),
assert(minExtent <= extent),
assert(minExtent <= initialExtent),
assert(extent <= maxExtent),
assert(initialExtent <= maxExtent),
assert(context != null);
/// The current value of the extent, between [minExtent] and [maxExtent].
final double extent;
/// The minimum value of [extent], which is >= 0.
final double minExtent;
/// The maximum value of [extent].
final double maxExtent;
/// The initially requested value for [extent].
final double initialExtent;
/// The build context of the widget that fired this notification.
///
/// This can be used to find the sheet's render objects to determine the size
/// of the viewport, for instance. A listener can only assume this context
/// is live when it first gets the notification.
final BuildContext context;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('minExtent: $minExtent, extent: $extent, maxExtent: $maxExtent, initialExtent: $initialExtent');
}
}
/// Manages state between [_DraggableScrollableSheetState],
/// [_DraggableScrollableSheetScrollController], and
/// [_DraggableScrollableSheetScrollPosition].
......@@ -174,8 +264,18 @@ class _DraggableSheetExtent {
/// 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) {
void addPixelDelta(double delta, BuildContext context) {
if (availablePixels == 0) {
return;
}
currentExtent += delta / availablePixels;
DraggableScrollableNotification(
minExtent: minExtent,
maxExtent: maxExtent,
extent: currentExtent,
initialExtent: initialExtent,
context: context,
).dispatch(context);
}
}
......@@ -195,10 +295,29 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
_scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_InheritedResetNotifier.shouldReset(context)) {
// jumpTo can result in trying to replace semantics during build.
// Just animate really fast.
// Avoid doing it at all if the offset is already 0.0.
if (_scrollController.offset != 0.0) {
_scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 1),
curve: Curves.linear,
);
}
_extent._currentExtent.value = _extent.initialExtent;
}
}
void _setExtent() {
setState(() {
// _extent has been updated when this is called.
});
}
@override
......@@ -206,13 +325,12 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
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,
),
final Widget sheet = FractionallySizedBox(
heightFactor: _extent.currentExtent,
child: widget.builder(context, _scrollController),
alignment: Alignment.bottomCenter,
);
return widget.expand ? SizedBox.expand(child: sheet) : sheet;
},
);
}
......@@ -311,10 +429,10 @@ class _DraggableScrollableSheetScrollPosition
@override
void applyUserOffset(double delta) {
if (!listShouldScroll &&
!(extent.isAtMin || extent.isAtMax) ||
(extent.isAtMin && delta < 0) ||
(extent.isAtMax && delta > 0)) {
extent.addPixelDelta(-delta);
(!(extent.isAtMin || extent.isAtMax) ||
(extent.isAtMin && delta < 0) ||
(extent.isAtMax && delta > 0))) {
extent.addPixelDelta(-delta, context.notificationContext);
} else {
super.applyUserOffset(delta);
}
......@@ -348,7 +466,7 @@ class _DraggableScrollableSheetScrollPosition
void _tick() {
final double delta = ballisticController.value - lastDelta;
lastDelta = ballisticController.value;
extent.addPixelDelta(delta);
extent.addPixelDelta(delta, context.notificationContext);
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
......@@ -373,3 +491,99 @@ class _DraggableScrollableSheetScrollPosition
return super.drag(details, dragCancelCallback);
}
}
/// A widget that can notify a descendent [DraggableScrollableSheet] that it
/// should reset its position to the initial state.
///
/// The [Scaffold] uses this widget to notify a persistentent bottom sheet that
/// the user has tapped back if the sheet has started to cover more of the body
/// than when at its initial position. This is important for users of assistive
/// technology, where dragging may be difficult to communicate.
class DraggableScrollableActuator extends StatelessWidget {
/// Creates a widget that can notify descendent [DraggableScrollableSheet]s
/// to reset to their initial position.
///
/// The [child] parameter is required.
DraggableScrollableActuator({
Key key,
@required this.child
}) : super(key: key);
/// This child's [DraggableScrollableSheet] descendant will be reset when the
/// [reset] method is applied to a context that includes it.
///
/// Must not be null.
final Widget child;
final _ResetNotifier _notifier = _ResetNotifier();
/// Notifies any descendant [DraggableScrollableSheet] that it should reset
/// to its initial position.
///
/// Returns `true` if a [DraggableScrollableActuator] is available and
/// some [DraggableScrollableSheet] is listening for updates, `false`
/// otherwise.
static bool reset(BuildContext context) {
final _InheritedResetNotifier notifier = context.inheritFromWidgetOfExactType(_InheritedResetNotifier);
if (notifier == null) {
return false;
}
return notifier._sendReset();
}
@override
Widget build(BuildContext context) {
return _InheritedResetNotifier(child: child, notifier: _notifier);
}
}
/// A [ChangeNotifier] to use with [InheritedResetNotifer] to notify
/// descendants that they should reset to initial state.
class _ResetNotifier extends ChangeNotifier {
/// Whether someone called [sendReset] or not.
///
/// This flag should be reset after checking it.
bool _wasCalled = false;
/// Fires a reset notification to descendants.
///
/// Returns false if there are no listeners.
bool sendReset() {
if (!hasListeners) {
return false;
}
_wasCalled = true;
notifyListeners();
return true;
}
}
class _InheritedResetNotifier extends InheritedNotifier<_ResetNotifier> {
/// Creates an [InheritedNotifier] that the [DraggableScrollableSheet] will
/// listen to for an indication that it should change its extent.
///
/// The [child] and [notifier] properties must not be null.
const _InheritedResetNotifier({
Key key,
@required Widget child,
@required _ResetNotifier notifier,
}) : super(key: key, child: child, notifier: notifier);
bool _sendReset() => notifier.sendReset();
/// Specifies whether the [DraggableScrollableSheet] should reset to its
/// initial position.
///
/// Returns true if the notifier requested a reset, false otherwise.
static bool shouldReset(BuildContext context) {
final InheritedWidget widget = context.inheritFromWidgetOfExactType(_InheritedResetNotifier);
if (widget == null) {
return false;
}
assert(widget is _InheritedResetNotifier);
final _InheritedResetNotifier inheritedNotifier = widget;
final bool wasCalled = inheritedNotifier.notifier._wasCalled;
inheritedNotifier.notifier._wasCalled = false;
return wasCalled;
}
}
......@@ -38,7 +38,7 @@ void main() {
expect(find.text('BottomSheet'), findsOneWidget);
expect(showBottomSheetThenCalled, isFalse);
// Tap on the bottom sheet itself to dismiss it
// Tap on the bottom sheet itself to dismiss it.
await tester.tap(find.text('BottomSheet'));
await tester.pump(); // bottom sheet dismiss animation starts
expect(showBottomSheetThenCalled, isTrue);
......@@ -169,6 +169,7 @@ void main() {
child: MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.all(50.0),
size: Size(400.0, 600.0),
),
child: Navigator(
onGenerateRoute: (_) {
......@@ -249,4 +250,66 @@ void main() {
), ignoreTransform: true, ignoreRect: true, ignoreId: true));
semantics.dispose();
});
testWidgets('modal BottomSheet with scrollController has semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body'))
)
));
showModalBottomSheet<void>(
context: scaffoldKey.currentContext,
builder: (BuildContext context) {
return DraggableScrollableSheet(
expand: false,
builder: (_, ScrollController controller) {
return SingleChildScrollView(
controller: controller,
child: Container(
child: const Text('BottomSheet'),
),
);
},
);
},
);
await tester.pump(); // bottom sheet show animation starts
await tester.pump(const Duration(seconds: 1)); // animation done
expect(semantics, hasSemantics(TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
children: <TestSemantics>[
TestSemantics(
label: 'Dialog',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[
SemanticsFlag.scopesRoute,
SemanticsFlag.namesRoute,
],
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling],
children: <TestSemantics>[
TestSemantics(
label: 'BottomSheet',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
), ignoreTransform: true, ignoreRect: true, ignoreId: true));
semantics.dispose();
});
}
......@@ -29,12 +29,42 @@ void main() {
await tester.pump();
expect(buildCount, equals(1));
bottomSheet.setState(() { });
await tester.pump();
expect(buildCount, equals(2));
});
testWidgets('Verify that a persistent BottomSheet cannot be dismissed', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: const Center(child: Text('body')),
bottomSheet: DraggableScrollableSheet(
expand: false,
builder: (_, ScrollController controller) {
return ListView(
controller: controller,
shrinkWrap: true,
children: <Widget>[
Container(height: 100.0, child: const Text('One')),
Container(height: 100.0, child: const Text('Two')),
Container(height: 100.0, child: const Text('Three')),
],
);
},
),
)
));
await tester.pumpAndSettle();
expect(find.text('Two'), findsOneWidget);
await tester.drag(find.text('Two'), const Offset(0.0, 400.0));
await tester.pumpAndSettle();
expect(find.text('Two'), findsOneWidget);
});
testWidgets('Verify that a scrollable BottomSheet can be dismissed', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
......@@ -67,6 +97,210 @@ void main() {
expect(find.text('Two'), findsNothing);
});
testWidgets('Verify that a scrollControlled BottomSheet can be dismissed', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body'))
)
));
scaffoldKey.currentState.showBottomSheet<void>(
(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
builder: (_, ScrollController controller) {
return ListView(
shrinkWrap: true,
controller: controller,
children: <Widget>[
Container(height: 100.0, child: const Text('One')),
Container(height: 100.0, child: const Text('Two')),
Container(height: 100.0, child: const Text('Three')),
],
);
},
);
},
);
await tester.pumpAndSettle();
expect(find.text('Two'), findsOneWidget);
await tester.drag(find.text('Two'), const Offset(0.0, 400.0));
await tester.pumpAndSettle();
expect(find.text('Two'), findsNothing);
});
testWidgets('Verify that a persistent BottomSheet can fling up and hide the fab', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(),
body: const Center(child: Text('body')),
bottomSheet: DraggableScrollableSheet(
expand: false,
builder: (_, ScrollController controller) {
return ListView.builder(
itemExtent: 50.0,
itemCount: 50,
itemBuilder: (_, int index) => Text('Item $index'),
controller: controller,
);
},
),
floatingActionButton: const FloatingActionButton(
onPressed: null,
child: Text('fab'),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 22'), findsNothing);
expect(find.byType(FloatingActionButton), findsOneWidget);
expect(find.byType(FloatingActionButton).hitTestable(), findsOneWidget);
expect(find.byType(BackButton).hitTestable(), findsNothing);
await tester.drag(find.text('Item 2'), const Offset(0, -20.0));
await tester.pumpAndSettle();
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 22'), findsNothing);
expect(find.byType(FloatingActionButton), findsOneWidget);
expect(find.byType(FloatingActionButton).hitTestable(), findsOneWidget);
await tester.fling(find.text('Item 2'), const Offset(0.0, -600.0), 2000.0);
await tester.pumpAndSettle();
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 22'), findsOneWidget);
expect(find.byType(FloatingActionButton), findsOneWidget);
expect(find.byType(FloatingActionButton).hitTestable(), findsNothing);
});
testWidgets('Verify that a back button resets a persistent BottomSheet', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(),
body: const Center(child: Text('body')),
bottomSheet: DraggableScrollableSheet(
expand: false,
builder: (_, ScrollController controller) {
return ListView.builder(
itemExtent: 50.0,
itemCount: 50,
itemBuilder: (_, int index) => Text('Item $index'),
controller: controller,
);
},
),
floatingActionButton: const FloatingActionButton(
onPressed: null,
child: Text('fab'),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 22'), findsNothing);
expect(find.byType(BackButton).hitTestable(), findsNothing);
await tester.drag(find.text('Item 2'), const Offset(0, -20.0));
await tester.pumpAndSettle();
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 22'), findsNothing);
// We've started to drag up, we should have a back button now for a11y
expect(find.byType(BackButton).hitTestable(), findsOneWidget);
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(find.byType(BackButton).hitTestable(), findsNothing);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 22'), findsNothing);
await tester.fling(find.text('Item 2'), const Offset(0.0, -600.0), 2000.0);
await tester.pumpAndSettle();
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 22'), findsOneWidget);
expect(find.byType(BackButton).hitTestable(), findsOneWidget);
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(find.byType(BackButton).hitTestable(), findsNothing);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 22'), findsNothing);
});
testWidgets('Verify that a scrollable BottomSheet hides the fab when scrolled up', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body')),
floatingActionButton: const FloatingActionButton(
onPressed: null,
child: Text('fab'),
),
)
));
scaffoldKey.currentState.showBottomSheet<void>(
(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
builder: (_, ScrollController controller) {
return ListView(
controller: controller,
shrinkWrap: true,
children: <Widget>[
Container(height: 100.0, child: const Text('One')),
Container(height: 100.0, child: const Text('Two')),
Container(height: 100.0, child: const Text('Three')),
Container(height: 100.0, child: const Text('Three')),
Container(height: 100.0, child: const Text('Three')),
Container(height: 100.0, child: const Text('Three')),
Container(height: 100.0, child: const Text('Three')),
Container(height: 100.0, child: const Text('Three')),
Container(height: 100.0, child: const Text('Three')),
Container(height: 100.0, child: const Text('Three')),
Container(height: 100.0, child: const Text('Three')),
],
);
},
);
},
);
await tester.pumpAndSettle();
expect(find.text('Two'), findsOneWidget);
expect(find.byType(FloatingActionButton).hitTestable(), findsOneWidget);
await tester.drag(find.text('Two'), const Offset(0.0, -600.0));
await tester.pumpAndSettle();
expect(find.text('Two'), findsOneWidget);
expect(find.byType(FloatingActionButton), findsOneWidget);
expect(find.byType(FloatingActionButton).hitTestable(), findsNothing);
});
testWidgets('showBottomSheet()', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(MaterialApp(
......@@ -83,7 +317,7 @@ void main() {
builder: (BuildContext context) {
buildCount += 1;
return Container(height: 200.0);
}
},
);
},
);
......@@ -191,4 +425,29 @@ void main() {
expect(find.text('showModalBottomSheet'), findsNothing);
expect(find.byKey(bottomSheetKey), findsNothing);
});
testWidgets('PersistentBottomSheetController.close dismisses the bottom sheet', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
key: scaffoldKey,
body: const Center(child: Text('body'))
)
));
final PersistentBottomSheetController<void> bottomSheet = scaffoldKey.currentState.showBottomSheet<void>((_) {
return Builder(
builder: (BuildContext context) {
return Container(height: 200.0);
}
);
});
await tester.pump();
expect(find.byType(BottomSheet), findsOneWidget);
bottomSheet.close();
await tester.pump();
expect(find.byType(BottomSheet), findsNothing);
});
}
......@@ -335,7 +335,6 @@ void main() {
),
),
);
await tester.tap(find.text('X'));
await tester.pump(); // start animation
await tester.pump(const Duration(seconds: 1));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment