Skip to content

Instantly share code, notes, and snippets.

@sventropy
Last active May 24, 2021 07:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sventropy/e68fdf3d00c1d4ea71327ffbc18cd505 to your computer and use it in GitHub Desktop.
Save sventropy/e68fdf3d00c1d4ea71327ffbc18cd505 to your computer and use it in GitHub Desktop.
Material Design standard bottom sheet built in Flutter
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
const double kMinimumDragDistanceThreshold = 8.0;
const double kMinimumDragCollapseDistance = 52.0;
abstract class StandardBottomSheetListener {
expandUpdated(bool isExpanded);
}
/// A bottom-align, persistent, standard bottom sheet in the sense of the
/// Material Design (https://material.io/components/sheets-bottom#standard-bottom-sheet)
/// The [collapsedHeight] and [expandedHeight] parameters allow defining the
/// boundaries in which the sheet can change its size.
class StandardBottomSheet extends StatefulWidget {
StandardBottomSheet(
{Key key,
this.child,
this.collapsedHeight = 64.0,
this.expandedHeight,
this.isExpanded = false,
this.listener,
this.backgroundColor})
: super(key: key);
final Widget child;
final double collapsedHeight;
final double expandedHeight;
final bool isExpanded;
final StandardBottomSheetListener listener;
final Color backgroundColor;
@override
_StandardBottomSheetState createState() => _StandardBottomSheetState();
}
class _StandardBottomSheetState extends State<StandardBottomSheet>
with TickerProviderStateMixin {
AnimationController _expandController;
AnimationController _collapseController;
Animation<double> _expandAnimation;
Animation<double> _collapseAnimation;
double _currentHeight;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_expandController = AnimationController(vsync: this);
_expandController.addListener(() {
_currentHeight = _expandAnimation.value;
});
_collapseController = AnimationController(vsync: this);
_collapseController.addListener(() {
_currentHeight = _collapseAnimation.value;
});
_expandAnimation =
Tween<double>(begin: widget.collapsedHeight, end: widget.expandedHeight)
.animate(_expandController);
_collapseAnimation =
Tween<double>(begin: widget.expandedHeight, end: widget.collapsedHeight)
.animate(_collapseController);
_redetermineExpandedState();
}
@override
void dispose() {
_expandController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
builder: _buildAnimation,
animation: Listenable.merge([_expandController, _collapseController]),
);
}
void didUpdateWidget(StandardBottomSheet oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isExpanded != _isExpanded) {
_runAnimation(Offset(0, -1), MediaQuery.of(context).size, _isExpanded,
notifyListener: false);
}
}
_redetermineExpandedState() {
_currentHeight =
widget.isExpanded ? widget.expandedHeight : widget.collapsedHeight;
_isExpanded = widget.isExpanded;
}
/// Creates the animation based on a [Listener] and its content, wraping the
/// [widget.child]
Widget _buildAnimation(BuildContext context, Widget child) {
final size = MediaQuery.of(context).size;
return GestureDetector(
onVerticalDragUpdate: (dragUpdateDetails) {
if (_currentHeight <= widget.collapsedHeight ||
_currentHeight >= widget.expandedHeight) {
return;
}
final yTranslation = dragUpdateDetails.delta.dy;
setState(() {
_currentHeight -= yTranslation;
});
},
onTap: () async {
if (_isExpanded) {
return;
}
await _runAnimation(Offset(0, 1), size, false);
},
onVerticalDragEnd: (dragEndDetails) async {
if (_isExpanded && dragEndDetails.primaryVelocity.isNegative) {
return;
}
if (!_isExpanded && dragEndDetails.primaryVelocity > 0) {
return;
}
if (dragEndDetails.primaryVelocity == 0.0 &&
(_currentHeight == widget.expandedHeight ||
_currentHeight == widget.collapsedHeight)) {
return;
}
await _runToggleAnimation(dragEndDetails, context, size);
},
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
height: _currentHeight,
width: size.width,
child: widget.child,
color: widget.backgroundColor ?? Colors.white,
),
),
);
}
/// Runs either a collapse or expand animation, depending on the state
/// and current drag position.
Future<void> _runToggleAnimation(
DragEndDetails dragEndDetails, BuildContext context, Size size) {
bool isCollapsing = _isExpanded;
if (isCollapsing) {
return _runAnimation(
dragEndDetails.velocity.pixelsPerSecond, size, isCollapsing);
}
return _runAnimation(
dragEndDetails.velocity.pixelsPerSecond, size, isCollapsing);
}
/// Runs either a collapse or expand animation, based on the given
/// [isCollapsing] parameter. Updates the [_isExpanded] state when finished.
Future<void> _runAnimation(
Offset pixelsPerSecond, Size size, bool isCollapsing,
{bool notifyListener = true}) async {
_isExpanded = !isCollapsing;
if (notifyListener) {
widget.listener?.expandUpdated(_isExpanded);
}
final simulation = _buildSpringSimulation(pixelsPerSecond, size);
if (isCollapsing) {
await _collapseController.animateWith(simulation);
} else {
await _expandController.animateWith(simulation);
}
}
/// Builds a [SpringSimulation] to run from the current position, based on the
/// user's drag velocity and distance.
SpringSimulation _buildSpringSimulation(Offset pixelsPerSecond, Size size) {
final unitsPerSecondX = pixelsPerSecond.dx / size.width;
final unitsPerSecondY = pixelsPerSecond.dy / size.height;
final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
final unitVelocity = unitsPerSecond.distance;
const spring = SpringDescription(
mass: 30,
stiffness: 1,
damping: 1,
);
final remainingDistanceRatio =
(_currentHeight / (widget.expandedHeight - widget.collapsedHeight));
return SpringSimulation(spring, remainingDistanceRatio, 1, -unitVelocity);
}
}
@sventropy
Copy link
Author

standard_bottom_sheet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment