Last active
May 24, 2021 07:29
-
-
Save sventropy/e68fdf3d00c1d4ea71327ffbc18cd505 to your computer and use it in GitHub Desktop.
Material Design standard bottom sheet built in Flutter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
Author
sventropy
commented
May 18, 2021
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment