Bottom sheet scrolling (#21896)
This commit is contained in:
parent
fdcc8aafa7
commit
6a48e663ef
@ -5,6 +5,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'colors.dart';
|
import 'colors.dart';
|
||||||
@ -14,9 +15,9 @@ import 'material_localizations.dart';
|
|||||||
import 'scaffold.dart';
|
import 'scaffold.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
const Duration _kBottomSheetDuration = Duration(milliseconds: 200);
|
const Duration _bottomSheetDuration = Duration(milliseconds: 200);
|
||||||
const double _kMinFlingVelocity = 700.0;
|
const double _minFlingVelocity = 700.0;
|
||||||
const double _kCloseProgressThreshold = 0.5;
|
const double _closeProgressThreshold = 0.5;
|
||||||
|
|
||||||
/// A material design bottom sheet.
|
/// A material design bottom sheet.
|
||||||
///
|
///
|
||||||
@ -56,6 +57,7 @@ class BottomSheet extends StatefulWidget {
|
|||||||
this.animationController,
|
this.animationController,
|
||||||
this.enableDrag = true,
|
this.enableDrag = true,
|
||||||
this.elevation = 0.0,
|
this.elevation = 0.0,
|
||||||
|
this.backgroundColor,
|
||||||
@required this.onClosing,
|
@required this.onClosing,
|
||||||
@required this.builder,
|
@required this.builder,
|
||||||
}) : assert(enableDrag != null),
|
}) : assert(enableDrag != null),
|
||||||
@ -64,7 +66,8 @@ class BottomSheet extends StatefulWidget {
|
|||||||
assert(elevation != null && elevation >= 0.0),
|
assert(elevation != null && elevation >= 0.0),
|
||||||
super(key: key);
|
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
|
/// The BottomSheet widget will manipulate the position of this animation, it
|
||||||
/// is not just a passive observer.
|
/// is not just a passive observer.
|
||||||
@ -83,8 +86,8 @@ class BottomSheet extends StatefulWidget {
|
|||||||
/// [Material] widget.
|
/// [Material] widget.
|
||||||
final WidgetBuilder builder;
|
final WidgetBuilder builder;
|
||||||
|
|
||||||
/// If true, the bottom sheet can dragged up and down and dismissed by swiping
|
/// If true, the bottom sheet can be dragged up and down and dismissed by
|
||||||
/// downwards.
|
/// swiping downards.
|
||||||
///
|
///
|
||||||
/// Default is true.
|
/// Default is true.
|
||||||
final bool enableDrag;
|
final bool enableDrag;
|
||||||
@ -96,13 +99,23 @@ class BottomSheet extends StatefulWidget {
|
|||||||
/// Defaults to 0. The value is non-negative.
|
/// Defaults to 0. The value is non-negative.
|
||||||
final double elevation;
|
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
|
@override
|
||||||
_BottomSheetState createState() => _BottomSheetState();
|
_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) {
|
static AnimationController createAnimationController(TickerProvider vsync) {
|
||||||
return AnimationController(
|
return AnimationController(
|
||||||
duration: _kBottomSheetDuration,
|
duration: _bottomSheetDuration,
|
||||||
debugLabel: 'BottomSheet',
|
debugLabel: 'BottomSheet',
|
||||||
vsync: vsync,
|
vsync: vsync,
|
||||||
);
|
);
|
||||||
@ -121,21 +134,25 @@ class _BottomSheetState extends State<BottomSheet> {
|
|||||||
bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse;
|
bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse;
|
||||||
|
|
||||||
void _handleDragUpdate(DragUpdateDetails details) {
|
void _handleDragUpdate(DragUpdateDetails details) {
|
||||||
|
assert(widget.enableDrag);
|
||||||
if (_dismissUnderway)
|
if (_dismissUnderway)
|
||||||
return;
|
return;
|
||||||
widget.animationController.value -= details.primaryDelta / (_childHeight ?? details.primaryDelta);
|
widget.animationController.value -= details.primaryDelta / (_childHeight ?? details.primaryDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDragEnd(DragEndDetails details) {
|
void _handleDragEnd(DragEndDetails details) {
|
||||||
|
assert(widget.enableDrag);
|
||||||
if (_dismissUnderway)
|
if (_dismissUnderway)
|
||||||
return;
|
return;
|
||||||
if (details.velocity.pixelsPerSecond.dy > _kMinFlingVelocity) {
|
if (details.velocity.pixelsPerSecond.dy > _minFlingVelocity) {
|
||||||
final double flingVelocity = -details.velocity.pixelsPerSecond.dy / _childHeight;
|
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);
|
widget.animationController.fling(velocity: flingVelocity);
|
||||||
if (flingVelocity < 0.0)
|
}
|
||||||
|
if (flingVelocity < 0.0) {
|
||||||
widget.onClosing();
|
widget.onClosing();
|
||||||
} else if (widget.animationController.value < _kCloseProgressThreshold) {
|
}
|
||||||
|
} else if (widget.animationController.value < _closeProgressThreshold) {
|
||||||
if (widget.animationController.value > 0.0)
|
if (widget.animationController.value > 0.0)
|
||||||
widget.animationController.fling(velocity: -1.0);
|
widget.animationController.fling(velocity: -1.0);
|
||||||
widget.onClosing();
|
widget.onClosing();
|
||||||
@ -144,12 +161,23 @@ class _BottomSheetState extends State<BottomSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool extentChanged(DraggableScrollableNotification notification) {
|
||||||
|
if (notification.extent == notification.minExtent) {
|
||||||
|
widget.onClosing();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final Widget bottomSheet = Material(
|
final Widget bottomSheet = Material(
|
||||||
key: _childKey,
|
key: _childKey,
|
||||||
|
color: widget.backgroundColor,
|
||||||
elevation: widget.elevation,
|
elevation: widget.elevation,
|
||||||
|
child: NotificationListener<DraggableScrollableNotification>(
|
||||||
|
onNotification: extentChanged,
|
||||||
child: widget.builder(context),
|
child: widget.builder(context),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return !widget.enableDrag ? bottomSheet : GestureDetector(
|
return !widget.enableDrag ? bottomSheet : GestureDetector(
|
||||||
onVerticalDragUpdate: _handleDragUpdate,
|
onVerticalDragUpdate: _handleDragUpdate,
|
||||||
@ -166,11 +194,11 @@ class _BottomSheetState extends State<BottomSheet> {
|
|||||||
|
|
||||||
|
|
||||||
// MODAL BOTTOM SHEETS
|
// MODAL BOTTOM SHEETS
|
||||||
|
|
||||||
class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
|
class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
|
||||||
_ModalBottomSheetLayout(this.progress);
|
_ModalBottomSheetLayout(this.progress, this.isScrollControlled);
|
||||||
|
|
||||||
final double progress;
|
final double progress;
|
||||||
|
final bool isScrollControlled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||||
@ -178,7 +206,9 @@ class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
|
|||||||
minWidth: constraints.maxWidth,
|
minWidth: constraints.maxWidth,
|
||||||
maxWidth: constraints.maxWidth,
|
maxWidth: constraints.maxWidth,
|
||||||
minHeight: 0.0,
|
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 {
|
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 _ModalBottomSheetRoute<T> route;
|
||||||
|
final bool isScrollControlled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>();
|
_ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
|
class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
|
||||||
@override
|
String _getRouteLabel(MaterialLocalizations localizations) {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final MediaQueryData mediaQuery = MediaQuery.of(context);
|
|
||||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
|
||||||
String routeLabel;
|
|
||||||
switch (defaultTargetPlatform) {
|
switch (defaultTargetPlatform) {
|
||||||
case TargetPlatform.iOS:
|
case TargetPlatform.iOS:
|
||||||
routeLabel = '';
|
return '';
|
||||||
break;
|
|
||||||
case TargetPlatform.android:
|
case TargetPlatform.android:
|
||||||
case TargetPlatform.fuchsia:
|
case TargetPlatform.fuchsia:
|
||||||
routeLabel = localizations.dialogLabel;
|
return localizations.dialogLabel;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
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(
|
return GestureDetector(
|
||||||
excludeFromSemantics: true,
|
excludeFromSemantics: true,
|
||||||
onTap: () => Navigator.pop(context),
|
onTap: () {
|
||||||
|
if (widget.route.isCurrent)
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: widget.route.animation,
|
animation: widget.route.animation,
|
||||||
builder: (BuildContext context, Widget child) {
|
builder: (BuildContext context, Widget child) {
|
||||||
@ -234,10 +277,15 @@ class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
|
|||||||
explicitChildNodes: true,
|
explicitChildNodes: true,
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: CustomSingleChildLayout(
|
child: CustomSingleChildLayout(
|
||||||
delegate: _ModalBottomSheetLayout(animationValue),
|
delegate: _ModalBottomSheetLayout(animationValue, widget.isScrollControlled),
|
||||||
child: BottomSheet(
|
child: BottomSheet(
|
||||||
|
backgroundColor: widget.route.backgroundColor,
|
||||||
animationController: widget.route._animationController,
|
animationController: widget.route._animationController,
|
||||||
onClosing: () => Navigator.pop(context),
|
onClosing: () {
|
||||||
|
if (widget.route.isCurrent) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
builder: widget.route.builder,
|
builder: widget.route.builder,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -254,14 +302,19 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
|
|||||||
this.builder,
|
this.builder,
|
||||||
this.theme,
|
this.theme,
|
||||||
this.barrierLabel,
|
this.barrierLabel,
|
||||||
|
@required this.isScrollControlled,
|
||||||
|
this.backgroundColor,
|
||||||
RouteSettings settings,
|
RouteSettings settings,
|
||||||
}) : super(settings: settings);
|
}) : assert(isScrollControlled != null),
|
||||||
|
super(settings: settings);
|
||||||
|
|
||||||
final WidgetBuilder builder;
|
final WidgetBuilder builder;
|
||||||
final ThemeData theme;
|
final ThemeData theme;
|
||||||
|
final bool isScrollControlled;
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Duration get transitionDuration => _kBottomSheetDuration;
|
Duration get transitionDuration => _bottomSheetDuration;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get barrierDismissible => true;
|
bool get barrierDismissible => true;
|
||||||
@ -288,7 +341,7 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
|
|||||||
Widget bottomSheet = MediaQuery.removePadding(
|
Widget bottomSheet = MediaQuery.removePadding(
|
||||||
context: context,
|
context: context,
|
||||||
removeTop: true,
|
removeTop: true,
|
||||||
child: _ModalBottomSheet<T>(route: this),
|
child: _ModalBottomSheet<T>(route: this, isScrollControlled: isScrollControlled),
|
||||||
);
|
);
|
||||||
if (theme != null)
|
if (theme != null)
|
||||||
bottomSheet = Theme(data: theme, child: bottomSheet);
|
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
|
/// corresponding widget can be safely removed from the tree before the bottom
|
||||||
/// sheet is closed.
|
/// 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
|
/// Returns a `Future` that resolves to the value (if any) that was passed to
|
||||||
/// [Navigator.pop] when the modal bottom sheet was closed.
|
/// [Navigator.pop] when the modal bottom sheet was closed.
|
||||||
///
|
///
|
||||||
@ -325,18 +384,26 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
|
|||||||
Future<T> showModalBottomSheet<T>({
|
Future<T> showModalBottomSheet<T>({
|
||||||
@required BuildContext context,
|
@required BuildContext context,
|
||||||
@required WidgetBuilder builder,
|
@required WidgetBuilder builder,
|
||||||
|
bool isScrollControlled = false,
|
||||||
|
Color backgroundColor,
|
||||||
}) {
|
}) {
|
||||||
assert(context != null);
|
assert(context != null);
|
||||||
assert(builder != null);
|
assert(builder != null);
|
||||||
|
assert(isScrollControlled != null);
|
||||||
|
assert(debugCheckHasMediaQuery(context));
|
||||||
assert(debugCheckHasMaterialLocalizations(context));
|
assert(debugCheckHasMaterialLocalizations(context));
|
||||||
|
|
||||||
return Navigator.push(context, _ModalBottomSheetRoute<T>(
|
return Navigator.push(context, _ModalBottomSheetRoute<T>(
|
||||||
builder: builder,
|
builder: builder,
|
||||||
theme: Theme.of(context, shadowThemeOnly: true),
|
theme: Theme.of(context, shadowThemeOnly: true),
|
||||||
|
isScrollControlled: isScrollControlled,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
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
|
/// Returns a controller that can be used to close and otherwise manipulate the
|
||||||
/// bottom sheet.
|
/// bottom sheet.
|
||||||
@ -353,10 +420,6 @@ Future<T> showModalBottomSheet<T>({
|
|||||||
/// does not add a back button to the enclosing Scaffold's appbar, use the
|
/// does not add a back button to the enclosing Scaffold's appbar, use the
|
||||||
/// [Scaffold.bottomSheet] constructor parameter.
|
/// [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
|
/// 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
|
/// 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
|
/// 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>({
|
PersistentBottomSheetController<T> showBottomSheet<T>({
|
||||||
@required BuildContext context,
|
@required BuildContext context,
|
||||||
@required WidgetBuilder builder,
|
@required WidgetBuilder builder,
|
||||||
|
Color backgroundColor,
|
||||||
}) {
|
}) {
|
||||||
assert(context != null);
|
assert(context != null);
|
||||||
assert(builder != 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.dart';
|
||||||
import 'material_localizations.dart';
|
import 'material_localizations.dart';
|
||||||
|
import 'scaffold.dart' show Scaffold;
|
||||||
|
|
||||||
/// Asserts that the given context has a [Material] ancestor.
|
/// Asserts that the given context has a [Material] ancestor.
|
||||||
///
|
///
|
||||||
@ -127,3 +128,36 @@ bool debugCheckHasMaterialLocalizations(BuildContext context) {
|
|||||||
}());
|
}());
|
||||||
return true;
|
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/foundation.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
||||||
|
|
||||||
@ -16,6 +15,7 @@ import 'app_bar.dart';
|
|||||||
import 'bottom_sheet.dart';
|
import 'bottom_sheet.dart';
|
||||||
import 'button_bar.dart';
|
import 'button_bar.dart';
|
||||||
import 'button_theme.dart';
|
import 'button_theme.dart';
|
||||||
|
import 'colors.dart';
|
||||||
import 'divider.dart';
|
import 'divider.dart';
|
||||||
import 'drawer.dart';
|
import 'drawer.dart';
|
||||||
import 'flexible_space_bar.dart';
|
import 'flexible_space_bar.dart';
|
||||||
@ -37,9 +37,16 @@ import 'theme_data.dart';
|
|||||||
const FloatingActionButtonLocation _kDefaultFloatingActionButtonLocation = FloatingActionButtonLocation.endFloat;
|
const FloatingActionButtonLocation _kDefaultFloatingActionButtonLocation = FloatingActionButtonLocation.endFloat;
|
||||||
const FloatingActionButtonAnimator _kDefaultFloatingActionButtonAnimator = FloatingActionButtonAnimator.scaling;
|
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 {
|
enum _ScaffoldSlot {
|
||||||
body,
|
body,
|
||||||
appBar,
|
appBar,
|
||||||
|
bodyScrim,
|
||||||
bottomSheet,
|
bottomSheet,
|
||||||
snackBar,
|
snackBar,
|
||||||
persistentFooter,
|
persistentFooter,
|
||||||
@ -451,6 +458,14 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
|
|||||||
|
|
||||||
Size bottomSheetSize = Size.zero;
|
Size bottomSheetSize = Size.zero;
|
||||||
Size snackBarSize = 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
|
// Set the size of the SnackBar early if the behavior is fixed so
|
||||||
// the FAB can be positioned correctly.
|
// the FAB can be positioned correctly.
|
||||||
@ -550,8 +565,10 @@ class _FloatingActionButtonTransition extends StatefulWidget {
|
|||||||
@required this.fabMoveAnimation,
|
@required this.fabMoveAnimation,
|
||||||
@required this.fabMotionAnimator,
|
@required this.fabMotionAnimator,
|
||||||
@required this.geometryNotifier,
|
@required this.geometryNotifier,
|
||||||
|
@required this.currentController,
|
||||||
}) : assert(fabMoveAnimation != null),
|
}) : assert(fabMoveAnimation != null),
|
||||||
assert(fabMotionAnimator != null),
|
assert(fabMotionAnimator != null),
|
||||||
|
assert(currentController != null),
|
||||||
super(key: key);
|
super(key: key);
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@ -559,18 +576,19 @@ class _FloatingActionButtonTransition extends StatefulWidget {
|
|||||||
final FloatingActionButtonAnimator fabMotionAnimator;
|
final FloatingActionButtonAnimator fabMotionAnimator;
|
||||||
final _ScaffoldGeometryNotifier geometryNotifier;
|
final _ScaffoldGeometryNotifier geometryNotifier;
|
||||||
|
|
||||||
|
/// Controls the current child widget.child as it exits.
|
||||||
|
final AnimationController currentController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_FloatingActionButtonTransitionState createState() => _FloatingActionButtonTransitionState();
|
_FloatingActionButtonTransitionState createState() => _FloatingActionButtonTransitionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTransition> with TickerProviderStateMixin {
|
class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTransition> with TickerProviderStateMixin {
|
||||||
// The animations applied to the Floating Action Button when it is entering or exiting.
|
// 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;
|
AnimationController _previousController;
|
||||||
Animation<double> _previousScaleAnimation;
|
Animation<double> _previousScaleAnimation;
|
||||||
Animation<double> _previousRotationAnimation;
|
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.
|
// The animations to run, considering the widget's fabMoveAnimation and the current/previous entrance/exit animations.
|
||||||
Animation<double> _currentScaleAnimation;
|
Animation<double> _currentScaleAnimation;
|
||||||
Animation<double> _extendedCurrentScaleAnimation;
|
Animation<double> _extendedCurrentScaleAnimation;
|
||||||
@ -585,18 +603,12 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
|
|||||||
duration: kFloatingActionButtonSegue,
|
duration: kFloatingActionButtonSegue,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
)..addStatusListener(_handlePreviousAnimationStatusChanged);
|
)..addStatusListener(_handlePreviousAnimationStatusChanged);
|
||||||
|
|
||||||
_currentController = AnimationController(
|
|
||||||
duration: kFloatingActionButtonSegue,
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
_updateAnimations();
|
_updateAnimations();
|
||||||
|
|
||||||
if (widget.child != null) {
|
if (widget.child != null) {
|
||||||
// If we start out with a child, have the child appear fully visible instead
|
// If we start out with a child, have the child appear fully visible instead
|
||||||
// of animating in.
|
// of animating in.
|
||||||
_currentController.value = 1.0;
|
widget.currentController.value = 1.0;
|
||||||
} else {
|
} else {
|
||||||
// If we start without a child we update the geometry object with a
|
// 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.
|
// floating action button scale of 0, as it is not showing on the screen.
|
||||||
@ -607,7 +619,6 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_previousController.dispose();
|
_previousController.dispose();
|
||||||
_currentController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -623,13 +634,13 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
|
|||||||
_updateAnimations();
|
_updateAnimations();
|
||||||
}
|
}
|
||||||
if (_previousController.status == AnimationStatus.dismissed) {
|
if (_previousController.status == AnimationStatus.dismissed) {
|
||||||
final double currentValue = _currentController.value;
|
final double currentValue = widget.currentController.value;
|
||||||
if (currentValue == 0.0 || oldWidget.child == null) {
|
if (currentValue == 0.0 || oldWidget.child == null) {
|
||||||
// The current child hasn't started its entrance animation yet. We can
|
// The current child hasn't started its entrance animation yet. We can
|
||||||
// just skip directly to the new child's entrance.
|
// just skip directly to the new child's entrance.
|
||||||
_previousChild = null;
|
_previousChild = null;
|
||||||
if (widget.child != null)
|
if (widget.child != null)
|
||||||
_currentController.forward();
|
widget.currentController.forward();
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, we need to copy the state from the current controller to
|
// Otherwise, we need to copy the state from the current controller to
|
||||||
// the previous controller and run an exit animation for the previous
|
// the previous controller and run an exit animation for the previous
|
||||||
@ -638,7 +649,7 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
|
|||||||
_previousController
|
_previousController
|
||||||
..value = currentValue
|
..value = currentValue
|
||||||
..reverse();
|
..reverse();
|
||||||
_currentController.value = 0.0;
|
widget.currentController.value = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -662,10 +673,10 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
|
|||||||
);
|
);
|
||||||
|
|
||||||
final CurvedAnimation currentEntranceScaleAnimation = CurvedAnimation(
|
final CurvedAnimation currentEntranceScaleAnimation = CurvedAnimation(
|
||||||
parent: _currentController,
|
parent: widget.currentController,
|
||||||
curve: Curves.easeIn,
|
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.
|
// Get the animations for when the FAB is moving.
|
||||||
final Animation<double> moveScaleAnimation = widget.fabMotionAnimator.getScaleAnimation(parent: widget.fabMoveAnimation);
|
final Animation<double> moveScaleAnimation = widget.fabMotionAnimator.getScaleAnimation(parent: widget.fabMoveAnimation);
|
||||||
@ -686,9 +697,9 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
|
|||||||
void _handlePreviousAnimationStatusChanged(AnimationStatus status) {
|
void _handlePreviousAnimationStatusChanged(AnimationStatus status) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (status == AnimationStatus.dismissed) {
|
if (status == AnimationStatus.dismissed) {
|
||||||
assert(_currentController.status == AnimationStatus.dismissed);
|
assert(widget.currentController.status == AnimationStatus.dismissed);
|
||||||
if (widget.child != null)
|
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> _drawerKey = GlobalKey<DrawerControllerState>();
|
||||||
final GlobalKey<DrawerControllerState> _endDrawerKey = 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].
|
/// Whether this scaffold has a non-null [Scaffold.drawer].
|
||||||
bool get hasDrawer => widget.drawer != null;
|
bool get hasDrawer => widget.drawer != null;
|
||||||
/// Whether this scaffold has a non-null [Scaffold.endDrawer].
|
/// Whether this scaffold has a non-null [Scaffold.endDrawer].
|
||||||
bool get hasEndDrawer => widget.endDrawer != null;
|
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 _drawerOpened = false;
|
||||||
bool _endDrawerOpened = false;
|
bool _endDrawerOpened = false;
|
||||||
|
|
||||||
@ -1455,85 +1475,168 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
|
|
||||||
// PERSISTENT BOTTOM SHEET API
|
// 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;
|
PersistentBottomSheetController<dynamic> _currentBottomSheet;
|
||||||
|
|
||||||
void _maybeBuildCurrentBottomSheet() {
|
void _maybeBuildPersistentBottomSheet() {
|
||||||
if (widget.bottomSheet != null) {
|
if (widget.bottomSheet != null && _currentBottomSheet == null) {
|
||||||
// The new _currentBottomSheet is not a local history entry so a "back" button
|
// 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
|
// will not be added to the Scaffold's appbar and the bottom sheet will not
|
||||||
// support drag or swipe to dismiss.
|
// 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>(
|
_currentBottomSheet = _buildBottomSheet<void>(
|
||||||
(BuildContext context) => widget.bottomSheet,
|
(BuildContext context) {
|
||||||
BottomSheet.createAnimationController(this) ..value = 1.0,
|
return NotificationListener<DraggableScrollableNotification>(
|
||||||
false,
|
onNotification: _persistentBottomSheetExtentChanged,
|
||||||
|
child: DraggableScrollableActuator(
|
||||||
|
child: widget.bottomSheet,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
animationController: animationController,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _closeCurrentBottomSheet() {
|
void _closeCurrentBottomSheet() {
|
||||||
if (_currentBottomSheet != null) {
|
if (_currentBottomSheet != null) {
|
||||||
|
if (!_currentBottomSheet._isLocalHistoryEntry) {
|
||||||
_currentBottomSheet.close();
|
_currentBottomSheet.close();
|
||||||
|
}
|
||||||
|
assert(() {
|
||||||
|
_currentBottomSheet?._completer?.future?.whenComplete(() {
|
||||||
assert(_currentBottomSheet == null);
|
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 Completer<T> completer = Completer<T>();
|
||||||
final GlobalKey<_PersistentBottomSheetState> bottomSheetKey = GlobalKey<_PersistentBottomSheetState>();
|
final GlobalKey<_StandardBottomSheetState> bottomSheetKey = GlobalKey<_StandardBottomSheetState>();
|
||||||
_PersistentBottomSheet bottomSheet;
|
_StandardBottomSheet bottomSheet;
|
||||||
|
|
||||||
|
bool removedEntry = false;
|
||||||
void _removeCurrentBottomSheet() {
|
void _removeCurrentBottomSheet() {
|
||||||
|
removedEntry = true;
|
||||||
|
if (_currentBottomSheet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
assert(_currentBottomSheet._widget == bottomSheet);
|
assert(_currentBottomSheet._widget == bottomSheet);
|
||||||
assert(bottomSheetKey.currentState != null);
|
assert(bottomSheetKey.currentState != null);
|
||||||
bottomSheetKey.currentState.close();
|
_showFloatingActionButton();
|
||||||
if (controller.status != AnimationStatus.dismissed)
|
|
||||||
_dismissedBottomSheets.add(bottomSheet);
|
void _closed(void value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentBottomSheet = null;
|
_currentBottomSheet = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (animationController.status != AnimationStatus.dismissed) {
|
||||||
|
_dismissedBottomSheets.add(bottomSheet);
|
||||||
|
}
|
||||||
completer.complete();
|
completer.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
final LocalHistoryEntry entry = isLocalHistoryEntry
|
final Future<void> closing = bottomSheetKey.currentState.close();
|
||||||
? LocalHistoryEntry(onRemove: _removeCurrentBottomSheet)
|
if (closing != null) {
|
||||||
: null;
|
closing.then(_closed);
|
||||||
|
} else {
|
||||||
|
_closed(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bottomSheet = _PersistentBottomSheet(
|
final LocalHistoryEntry entry = isPersistent
|
||||||
key: bottomSheetKey,
|
? null
|
||||||
animationController: controller,
|
: LocalHistoryEntry(onRemove: () {
|
||||||
enableDrag: isLocalHistoryEntry,
|
if (!removedEntry) {
|
||||||
onClosing: () {
|
|
||||||
assert(_currentBottomSheet._widget == bottomSheet);
|
|
||||||
if (isLocalHistoryEntry)
|
|
||||||
entry.remove();
|
|
||||||
else
|
|
||||||
_removeCurrentBottomSheet();
|
_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: () {
|
onDismissed: () {
|
||||||
if (_dismissedBottomSheets.contains(bottomSheet)) {
|
if (_dismissedBottomSheets.contains(bottomSheet)) {
|
||||||
bottomSheet.animationController.dispose();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_dismissedBottomSheets.remove(bottomSheet);
|
_dismissedBottomSheets.remove(bottomSheet);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: builder,
|
builder: builder,
|
||||||
|
isPersistent: isPersistent,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLocalHistoryEntry)
|
if (!isPersistent)
|
||||||
ModalRoute.of(context).addLocalHistoryEntry(entry);
|
ModalRoute.of(context).addLocalHistoryEntry(entry);
|
||||||
|
|
||||||
return PersistentBottomSheetController<T>._(
|
return PersistentBottomSheetController<T>._(
|
||||||
bottomSheet,
|
bottomSheet,
|
||||||
completer,
|
completer,
|
||||||
isLocalHistoryEntry ? entry.remove : _removeCurrentBottomSheet,
|
entry != null
|
||||||
|
? entry.remove
|
||||||
|
: _removeCurrentBottomSheet,
|
||||||
(VoidCallback fn) { bottomSheetKey.currentState?.setState(fn); },
|
(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
|
/// Returns a controller that can be used to close and otherwise manipulate the
|
||||||
/// bottom sheet.
|
/// bottom sheet.
|
||||||
@ -1567,12 +1670,31 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
/// sheet.
|
/// sheet.
|
||||||
/// * [Scaffold.of], for information about how to obtain the [ScaffoldState].
|
/// * [Scaffold.of], for information about how to obtain the [ScaffoldState].
|
||||||
/// * <https://material.io/design/components/sheets-bottom.html#standard-bottom-sheet>
|
/// * <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();
|
_closeCurrentBottomSheet();
|
||||||
final AnimationController controller = BottomSheet.createAnimationController(this)
|
final AnimationController controller = BottomSheet.createAnimationController(this)..forward();
|
||||||
..forward();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentBottomSheet = _buildBottomSheet<T>(builder, controller, true);
|
_currentBottomSheet = _buildBottomSheet<T>(
|
||||||
|
builder,
|
||||||
|
false,
|
||||||
|
animationController: controller,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return _currentBottomSheet;
|
return _currentBottomSheet;
|
||||||
}
|
}
|
||||||
@ -1583,6 +1705,27 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
FloatingActionButtonLocation _previousFloatingActionButtonLocation;
|
FloatingActionButtonLocation _previousFloatingActionButtonLocation;
|
||||||
FloatingActionButtonLocation _floatingActionButtonLocation;
|
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.
|
// Moves the Floating Action Button to the new Floating Action Button Location.
|
||||||
void _moveFloatingActionButton(final FloatingActionButtonLocation newLocation) {
|
void _moveFloatingActionButton(final FloatingActionButtonLocation newLocation) {
|
||||||
FloatingActionButtonLocation previousLocation = _floatingActionButtonLocation;
|
FloatingActionButtonLocation previousLocation = _floatingActionButtonLocation;
|
||||||
@ -1646,7 +1789,11 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
value: 1.0,
|
value: 1.0,
|
||||||
duration: kFloatingActionButtonSegue * 2,
|
duration: kFloatingActionButtonSegue * 2,
|
||||||
);
|
);
|
||||||
_maybeBuildCurrentBottomSheet();
|
|
||||||
|
_floatingActionButtonVisibilityController = AnimationController(
|
||||||
|
duration: kFloatingActionButtonSegue,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -1671,7 +1818,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
return true;
|
return true;
|
||||||
}());
|
}());
|
||||||
_closeCurrentBottomSheet();
|
_closeCurrentBottomSheet();
|
||||||
_maybeBuildCurrentBottomSheet();
|
_maybeBuildPersistentBottomSheet();
|
||||||
}
|
}
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
}
|
}
|
||||||
@ -1690,6 +1837,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
|
hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
|
||||||
}
|
}
|
||||||
_accessibleNavigation = mediaQuery.accessibleNavigation;
|
_accessibleNavigation = mediaQuery.accessibleNavigation;
|
||||||
|
_maybeBuildPersistentBottomSheet();
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1699,11 +1847,14 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
_snackBarTimer?.cancel();
|
_snackBarTimer?.cancel();
|
||||||
_snackBarTimer = null;
|
_snackBarTimer = null;
|
||||||
_geometryNotifier.dispose();
|
_geometryNotifier.dispose();
|
||||||
for (_PersistentBottomSheet bottomSheet in _dismissedBottomSheets)
|
for (_StandardBottomSheet bottomSheet in _dismissedBottomSheets) {
|
||||||
bottomSheet.animationController.dispose();
|
bottomSheet.animationController?.dispose();
|
||||||
if (_currentBottomSheet != null)
|
}
|
||||||
_currentBottomSheet._widget.animationController.dispose();
|
if (_currentBottomSheet != null) {
|
||||||
|
_currentBottomSheet._widget.animationController?.dispose();
|
||||||
|
}
|
||||||
_floatingActionButtonMoveController.dispose();
|
_floatingActionButtonMoveController.dispose();
|
||||||
|
_floatingActionButtonVisibilityController.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
assert(debugCheckHasMediaQuery(context));
|
assert(debugCheckHasMediaQuery(context));
|
||||||
@ -1822,17 +1990,31 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
removeBottomPadding: widget.bottomNavigationBar != null || widget.persistentFooterButtons != null,
|
removeBottomPadding: widget.bottomNavigationBar != null || widget.persistentFooterButtons != null,
|
||||||
removeBottomInset: _resizeToAvoidBottomInset,
|
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) {
|
if (widget.appBar != null) {
|
||||||
final double topPadding = widget.primary ? mediaQuery.padding.top : 0.0;
|
final double topPadding = widget.primary ? mediaQuery.padding.top : 0.0;
|
||||||
final double extent = widget.appBar.preferredSize.height + topPadding;
|
_appBarMaxHeight = widget.appBar.preferredSize.height + topPadding;
|
||||||
assert(extent >= 0.0 && extent.isFinite);
|
assert(_appBarMaxHeight >= 0.0 && _appBarMaxHeight.isFinite);
|
||||||
_addIfNonNull(
|
_addIfNonNull(
|
||||||
children,
|
children,
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxHeight: extent),
|
constraints: BoxConstraints(maxHeight: _appBarMaxHeight),
|
||||||
child: FlexibleSpaceBar.createSettings(
|
child: FlexibleSpaceBar.createSettings(
|
||||||
currentExtent: extent,
|
currentExtent: _appBarMaxHeight,
|
||||||
child: widget.appBar,
|
child: widget.appBar,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1930,6 +2112,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
|
|||||||
fabMoveAnimation: _floatingActionButtonMoveController,
|
fabMoveAnimation: _floatingActionButtonMoveController,
|
||||||
fabMotionAnimator: _floatingActionButtonAnimator,
|
fabMotionAnimator: _floatingActionButtonAnimator,
|
||||||
geometryNotifier: _geometryNotifier,
|
geometryNotifier: _geometryNotifier,
|
||||||
|
currentController: _floatingActionButtonVisibilityController,
|
||||||
),
|
),
|
||||||
_ScaffoldSlot.floatingActionButton,
|
_ScaffoldSlot.floatingActionButton,
|
||||||
removeLeftPadding: true,
|
removeLeftPadding: true,
|
||||||
@ -2023,85 +2206,137 @@ class ScaffoldFeatureController<T extends Widget, U> {
|
|||||||
final StateSetter setState;
|
final StateSetter setState;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PersistentBottomSheet extends StatefulWidget {
|
class _StandardBottomSheet extends StatefulWidget {
|
||||||
const _PersistentBottomSheet({
|
const _StandardBottomSheet({
|
||||||
Key key,
|
Key key,
|
||||||
this.animationController,
|
this.animationController,
|
||||||
this.enableDrag = true,
|
this.enableDrag = true,
|
||||||
this.onClosing,
|
this.onClosing,
|
||||||
this.onDismissed,
|
this.onDismissed,
|
||||||
this.builder,
|
this.builder,
|
||||||
|
this.isPersistent = false,
|
||||||
|
this.backgroundColor,
|
||||||
}) : super(key: key);
|
}) : 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 bool enableDrag;
|
||||||
final VoidCallback onClosing;
|
final VoidCallback onClosing;
|
||||||
final VoidCallback onDismissed;
|
final VoidCallback onDismissed;
|
||||||
final WidgetBuilder builder;
|
final WidgetBuilder builder;
|
||||||
|
final bool isPersistent;
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_PersistentBottomSheetState createState() => _PersistentBottomSheetState();
|
_StandardBottomSheetState createState() => _StandardBottomSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PersistentBottomSheetState extends State<_PersistentBottomSheet> {
|
class _StandardBottomSheetState extends State<_StandardBottomSheet> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
assert(widget.animationController != null);
|
||||||
assert(widget.animationController.status == AnimationStatus.forward
|
assert(widget.animationController.status == AnimationStatus.forward
|
||||||
|| widget.animationController.status == AnimationStatus.completed);
|
|| widget.animationController.status == AnimationStatus.completed);
|
||||||
widget.animationController.addStatusListener(_handleStatusChange);
|
widget.animationController.addStatusListener(_handleStatusChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(_PersistentBottomSheet oldWidget) {
|
void didUpdateWidget(_StandardBottomSheet oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
assert(widget.animationController == oldWidget.animationController);
|
assert(widget.animationController == oldWidget.animationController);
|
||||||
}
|
}
|
||||||
|
|
||||||
void close() {
|
Future<void> close() {
|
||||||
|
assert(widget.animationController != null);
|
||||||
widget.animationController.reverse();
|
widget.animationController.reverse();
|
||||||
|
if (widget.onClosing != null) {
|
||||||
|
widget.onClosing();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleStatusChange(AnimationStatus status) {
|
void _handleStatusChange(AnimationStatus status) {
|
||||||
if (status == AnimationStatus.dismissed && widget.onDismissed != null)
|
if (status == AnimationStatus.dismissed && widget.onDismissed != null) {
|
||||||
widget.onDismissed();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.animationController != null) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: widget.animationController,
|
animation: widget.animationController,
|
||||||
builder: (BuildContext context, Widget child) {
|
builder: (BuildContext context, Widget child) {
|
||||||
return Align(
|
return Align(
|
||||||
alignment: AlignmentDirectional.topStart,
|
alignment: AlignmentDirectional.topStart,
|
||||||
heightFactor: widget.animationController.value,
|
heightFactor: widget.animationController.value,
|
||||||
child: child,
|
child: child
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Semantics(
|
child: _wrapBottomSheet(
|
||||||
container: true,
|
BottomSheet(
|
||||||
onDismiss: () {
|
|
||||||
close();
|
|
||||||
widget.onClosing();
|
|
||||||
},
|
|
||||||
child: BottomSheet(
|
|
||||||
animationController: widget.animationController,
|
animationController: widget.animationController,
|
||||||
enableDrag: widget.enableDrag,
|
enableDrag: widget.enableDrag,
|
||||||
onClosing: widget.onClosing,
|
onClosing: widget.onClosing,
|
||||||
builder: widget.builder,
|
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].
|
/// 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._(
|
const PersistentBottomSheetController._(
|
||||||
_PersistentBottomSheet widget,
|
_StandardBottomSheet widget,
|
||||||
Completer<T> completer,
|
Completer<T> completer,
|
||||||
VoidCallback close,
|
VoidCallback close,
|
||||||
StateSetter setState,
|
StateSetter setState,
|
||||||
|
@ -6,9 +6,12 @@ import 'package:flutter/gestures.dart';
|
|||||||
|
|
||||||
import 'basic.dart';
|
import 'basic.dart';
|
||||||
import 'framework.dart';
|
import 'framework.dart';
|
||||||
|
import 'inherited_notifier.dart';
|
||||||
import 'layout_builder.dart';
|
import 'layout_builder.dart';
|
||||||
|
import 'notification_listener.dart';
|
||||||
import 'scroll_context.dart';
|
import 'scroll_context.dart';
|
||||||
import 'scroll_controller.dart';
|
import 'scroll_controller.dart';
|
||||||
|
import 'scroll_notification.dart';
|
||||||
import 'scroll_physics.dart';
|
import 'scroll_physics.dart';
|
||||||
import 'scroll_position.dart';
|
import 'scroll_position.dart';
|
||||||
import 'scroll_position_with_single_context.dart';
|
import 'scroll_position_with_single_context.dart';
|
||||||
@ -44,6 +47,11 @@ typedef ScrollableWidgetBuilder = Widget Function(
|
|||||||
/// [ScrollableWidgetBuilder] does not use provided [ScrollController], the
|
/// [ScrollableWidgetBuilder] does not use provided [ScrollController], the
|
||||||
/// sheet will remain at the initialChildSize.
|
/// 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}
|
/// {@tool sample}
|
||||||
///
|
///
|
||||||
/// This is a sample widget which shows a [ListView] that has 25 [ListTile]s.
|
/// 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 {
|
class DraggableScrollableSheet extends StatefulWidget {
|
||||||
/// Creates a widget that can be dragged and scrolled in a single gesture.
|
/// Creates a widget that can be dragged and scrolled in a single gesture.
|
||||||
///
|
///
|
||||||
/// The [builder], [initialChildSize], [minChildSize], and [maxChildSize]
|
/// The [builder], [initialChildSize], [minChildSize], [maxChildSize] and
|
||||||
/// parameters must not be null.
|
/// [expand] parameters must not be null.
|
||||||
const DraggableScrollableSheet({
|
const DraggableScrollableSheet({
|
||||||
Key key,
|
Key key,
|
||||||
this.initialChildSize = 0.5,
|
this.initialChildSize = 0.5,
|
||||||
this.minChildSize = 0.25,
|
this.minChildSize = 0.25,
|
||||||
this.maxChildSize = 1.0,
|
this.maxChildSize = 1.0,
|
||||||
|
this.expand = true,
|
||||||
@required this.builder,
|
@required this.builder,
|
||||||
}) : assert(initialChildSize != null),
|
}) : assert(initialChildSize != null),
|
||||||
assert(minChildSize != null),
|
assert(minChildSize != null),
|
||||||
@ -100,6 +109,7 @@ class DraggableScrollableSheet extends StatefulWidget {
|
|||||||
assert(maxChildSize <= 1.0),
|
assert(maxChildSize <= 1.0),
|
||||||
assert(minChildSize <= initialChildSize),
|
assert(minChildSize <= initialChildSize),
|
||||||
assert(initialChildSize <= maxChildSize),
|
assert(initialChildSize <= maxChildSize),
|
||||||
|
assert(expand != null),
|
||||||
assert(builder != null),
|
assert(builder != null),
|
||||||
super(key: key);
|
super(key: key);
|
||||||
|
|
||||||
@ -121,6 +131,16 @@ class DraggableScrollableSheet extends StatefulWidget {
|
|||||||
/// The default value is `1.0`.
|
/// The default value is `1.0`.
|
||||||
final double maxChildSize;
|
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
|
/// The builder that creates a child to display in this widget, which will
|
||||||
/// use the provided [ScrollController] to enable dragging and scrolling
|
/// use the provided [ScrollController] to enable dragging and scrolling
|
||||||
/// of the contents.
|
/// of the contents.
|
||||||
@ -130,6 +150,76 @@ class DraggableScrollableSheet extends StatefulWidget {
|
|||||||
_DraggableScrollableSheetState createState() => _DraggableScrollableSheetState();
|
_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],
|
/// Manages state between [_DraggableScrollableSheetState],
|
||||||
/// [_DraggableScrollableSheetScrollController], and
|
/// [_DraggableScrollableSheetScrollController], and
|
||||||
/// [_DraggableScrollableSheetScrollPosition].
|
/// [_DraggableScrollableSheetScrollPosition].
|
||||||
@ -174,8 +264,18 @@ class _DraggableSheetExtent {
|
|||||||
|
|
||||||
/// The scroll position gets inputs in terms of pixels, but the extent is
|
/// The scroll position gets inputs in terms of pixels, but the extent is
|
||||||
/// expected to be expressed as a number between 0..1.
|
/// 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;
|
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);
|
_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() {
|
void _setExtent() {
|
||||||
setState(() {
|
setState(() {
|
||||||
// _extent has been updated when this is called.
|
// _extent has been updated when this is called.
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -206,13 +325,12 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (BuildContext context, BoxConstraints constraints) {
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
_extent.availablePixels = widget.maxChildSize * constraints.biggest.height;
|
_extent.availablePixels = widget.maxChildSize * constraints.biggest.height;
|
||||||
return SizedBox.expand(
|
final Widget sheet = FractionallySizedBox(
|
||||||
child: FractionallySizedBox(
|
|
||||||
heightFactor: _extent.currentExtent,
|
heightFactor: _extent.currentExtent,
|
||||||
child: widget.builder(context, _scrollController),
|
child: widget.builder(context, _scrollController),
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
return widget.expand ? SizedBox.expand(child: sheet) : sheet;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -311,10 +429,10 @@ class _DraggableScrollableSheetScrollPosition
|
|||||||
@override
|
@override
|
||||||
void applyUserOffset(double delta) {
|
void applyUserOffset(double delta) {
|
||||||
if (!listShouldScroll &&
|
if (!listShouldScroll &&
|
||||||
!(extent.isAtMin || extent.isAtMax) ||
|
(!(extent.isAtMin || extent.isAtMax) ||
|
||||||
(extent.isAtMin && delta < 0) ||
|
(extent.isAtMin && delta < 0) ||
|
||||||
(extent.isAtMax && delta > 0)) {
|
(extent.isAtMax && delta > 0))) {
|
||||||
extent.addPixelDelta(-delta);
|
extent.addPixelDelta(-delta, context.notificationContext);
|
||||||
} else {
|
} else {
|
||||||
super.applyUserOffset(delta);
|
super.applyUserOffset(delta);
|
||||||
}
|
}
|
||||||
@ -348,7 +466,7 @@ class _DraggableScrollableSheetScrollPosition
|
|||||||
void _tick() {
|
void _tick() {
|
||||||
final double delta = ballisticController.value - lastDelta;
|
final double delta = ballisticController.value - lastDelta;
|
||||||
lastDelta = ballisticController.value;
|
lastDelta = ballisticController.value;
|
||||||
extent.addPixelDelta(delta);
|
extent.addPixelDelta(delta, context.notificationContext);
|
||||||
if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) {
|
if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) {
|
||||||
// Make sure we pass along enough velocity to keep scrolling - otherwise
|
// 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
|
// 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);
|
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(find.text('BottomSheet'), findsOneWidget);
|
||||||
expect(showBottomSheetThenCalled, isFalse);
|
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.tap(find.text('BottomSheet'));
|
||||||
await tester.pump(); // bottom sheet dismiss animation starts
|
await tester.pump(); // bottom sheet dismiss animation starts
|
||||||
expect(showBottomSheetThenCalled, isTrue);
|
expect(showBottomSheetThenCalled, isTrue);
|
||||||
@ -169,6 +169,7 @@ void main() {
|
|||||||
child: MediaQuery(
|
child: MediaQuery(
|
||||||
data: const MediaQueryData(
|
data: const MediaQueryData(
|
||||||
padding: EdgeInsets.all(50.0),
|
padding: EdgeInsets.all(50.0),
|
||||||
|
size: Size(400.0, 600.0),
|
||||||
),
|
),
|
||||||
child: Navigator(
|
child: Navigator(
|
||||||
onGenerateRoute: (_) {
|
onGenerateRoute: (_) {
|
||||||
@ -249,4 +250,66 @@ void main() {
|
|||||||
), ignoreTransform: true, ignoreRect: true, ignoreId: true));
|
), ignoreTransform: true, ignoreRect: true, ignoreId: true));
|
||||||
semantics.dispose();
|
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();
|
await tester.pump();
|
||||||
expect(buildCount, equals(1));
|
expect(buildCount, equals(1));
|
||||||
|
|
||||||
bottomSheet.setState(() { });
|
bottomSheet.setState(() { });
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(buildCount, equals(2));
|
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 {
|
testWidgets('Verify that a scrollable BottomSheet can be dismissed', (WidgetTester tester) async {
|
||||||
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
|
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
|
|
||||||
@ -67,6 +97,210 @@ void main() {
|
|||||||
expect(find.text('Two'), findsNothing);
|
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 {
|
testWidgets('showBottomSheet()', (WidgetTester tester) async {
|
||||||
final GlobalKey key = GlobalKey();
|
final GlobalKey key = GlobalKey();
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(MaterialApp(
|
||||||
@ -83,7 +317,7 @@ void main() {
|
|||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
buildCount += 1;
|
buildCount += 1;
|
||||||
return Container(height: 200.0);
|
return Container(height: 200.0);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -191,4 +425,29 @@ void main() {
|
|||||||
expect(find.text('showModalBottomSheet'), findsNothing);
|
expect(find.text('showModalBottomSheet'), findsNothing);
|
||||||
expect(find.byKey(bottomSheetKey), 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.tap(find.text('X'));
|
||||||
await tester.pump(); // start animation
|
await tester.pump(); // start animation
|
||||||
await tester.pump(const Duration(seconds: 1));
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
Loading…
x
Reference in New Issue
Block a user