Bottom sheet scrolling (#21896)
This commit is contained in:
parent
fdcc8aafa7
commit
6a48e663ef
@ -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) {
|
||||
final Completer<T> completer = Completer<T>();
|
||||
final GlobalKey<_PersistentBottomSheetState> bottomSheetKey = GlobalKey<_PersistentBottomSheetState>();
|
||||
_PersistentBottomSheet bottomSheet;
|
||||
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<_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;
|
||||
|
||||
bottomSheet = _PersistentBottomSheet(
|
||||
key: bottomSheetKey,
|
||||
animationController: controller,
|
||||
enableDrag: isLocalHistoryEntry,
|
||||
onClosing: () {
|
||||
assert(_currentBottomSheet._widget == bottomSheet);
|
||||
if (isLocalHistoryEntry)
|
||||
entry.remove();
|
||||
else
|
||||
final LocalHistoryEntry entry = isPersistent
|
||||
? null
|
||||
: LocalHistoryEntry(onRemove: () {
|
||||
if (!removedEntry) {
|
||||
_removeCurrentBottomSheet();
|
||||
}
|
||||
});
|
||||
|
||||
bottomSheet = _StandardBottomSheet(
|
||||
key: bottomSheetKey,
|
||||
animationController: animationController,
|
||||
enableDrag: !isPersistent,
|
||||
onClosing: () {
|
||||
if (_currentBottomSheet == null) {
|
||||
return;
|
||||
}
|
||||
assert(_currentBottomSheet._widget == bottomSheet);
|
||||
if (!isPersistent && !removedEntry) {
|
||||
assert(entry != null);
|
||||
entry.remove();
|
||||
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));
|
||||
|
Loading…
x
Reference in New Issue
Block a user