Created
March 18, 2024 22:16
-
-
Save Captured-Heart/6dfbe144920eac602ed3d07f6ac20b09 to your computer and use it in GitHub Desktop.
It is a fork from expandable_bottom_sheet previously on pub.dev. it allows persistent header and drag
This file contains hidden or 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
//you can remove some of the exports here and refactor the affected part of the code | |
import 'package:animated_widgets/widgets/rotation_animated.dart'; | |
import 'package:animated_widgets/widgets/shake_animated_widget.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; | |
/// [ExpandableBottomSheet] is a BottomSheet with a draggable height like the | |
/// Google Maps App on Android. | |
/// | |
/// __Example:__ | |
/// | |
/// ```dart | |
/// ExpandableBottomSheet( | |
/// background: Container( | |
/// color: Colors.red, | |
/// child: Center( | |
/// child: Text('Background'), | |
/// ), | |
/// ), | |
/// persistentHeader: Container( | |
/// height: 40, | |
/// color: Colors.blue, | |
/// child: Center( | |
/// child: Text('Header'), | |
/// ), | |
/// ), | |
/// expandableContent: Container( | |
/// height: 500, | |
/// color: Colors.green, | |
/// child: Center( | |
/// child: Text('Content'), | |
/// ), | |
/// ), | |
/// ); | |
/// ``` | |
class ExpandableBottomSheet extends StatefulWidget { | |
/// [expandableContent] is the widget which you can hide and show by dragging. | |
/// It has to be a widget with a constant height. It is required for the [ExpandableBottomSheet]. | |
final Widget expandableContent; | |
/// [background] is the widget behind the [expandableContent] which holds | |
/// usually the content of your page. It is required for the [ExpandableBottomSheet]. | |
final Widget background; | |
/// [persistentHeader] is a Widget which is on top of the [expandableContent] | |
/// and will never be hidden. It is made for a widget which indicates the | |
/// user he can expand the content by dragging. | |
final Widget? persistentHeader; | |
/// [persistentFooter] is a widget which is always shown at the bottom. The [expandableContent] | |
/// is if it is expanded on top of it so you don't need margin to see all of | |
/// your content. You can use it for example for navigation or a menu. | |
final Widget? persistentFooter; | |
/// [persistentContentHeight] is the height of the content which will never | |
/// been contracted. It only relates to [expandableContent]. [persistentHeader] | |
/// and [persistentFooter] will not be affected by this. | |
final double persistentContentHeight; | |
/// [animationDurationExtend] is the duration for the animation if you stop | |
/// dragging with high speed. | |
final Duration animationDurationExtend; | |
/// [animationDurationContract] is the duration for the animation to bottom | |
/// if you stop dragging with high speed. If it is `null` [animationDurationExtend] will be used. | |
final Duration animationDurationContract; | |
/// [animationCurveExpand] is the curve of the animation for expanding | |
/// the [expandableContent] if the drag ended with high speed. | |
final Curve animationCurveExpand; | |
/// [animationCurveContract] is the curve of the animation for contracting | |
/// the [expandableContent] if the drag ended with high speed. | |
final Curve animationCurveContract; | |
/// [onIsExtendedCallback] will be executed if the extend reaches its maximum. | |
final Function()? onIsExtendedCallback; | |
/// [onIsContractedCallback] will be executed if the extend reaches its minimum. | |
final Function()? onIsContractedCallback; | |
/// [enableToggle] will enable tap to toggle option on header. | |
final bool enableToggle; | |
/// Creates the [ExpandableBottomSheet]. | |
/// | |
/// [persistentContentHeight] has to be greater 0. | |
const ExpandableBottomSheet({ | |
Key? key, | |
required this.expandableContent, | |
required this.background, | |
this.persistentHeader, | |
this.persistentFooter, | |
this.persistentContentHeight = 0.0, | |
this.animationCurveExpand = Curves.ease, | |
this.animationCurveContract = Curves.ease, | |
this.animationDurationExtend = const Duration(milliseconds: 250), | |
this.animationDurationContract = const Duration(milliseconds: 250), | |
this.onIsExtendedCallback, | |
this.onIsContractedCallback, | |
this.enableToggle = false, | |
}) : assert(persistentContentHeight >= 0), | |
super(key: key); | |
@override | |
ExpandableBottomSheetState createState() => ExpandableBottomSheetState(); | |
} | |
class ExpandableBottomSheetState extends State<ExpandableBottomSheet> | |
with TickerProviderStateMixin { | |
final GlobalKey _contentKey = GlobalKey(debugLabel: 'contentKey'); | |
final GlobalKey _headerKey = GlobalKey(debugLabel: 'headerKey'); | |
final GlobalKey _footerKey = GlobalKey(debugLabel: 'footerKey'); | |
late AnimationController _controller; | |
double _draggableHeight = 0; | |
double? _positionOffset; | |
double _startOffsetAtDragDown = 0; | |
double? _startPositionAtDragDown = 0; | |
double _minOffset = 0; | |
double _maxOffset = 0; | |
double _animationMinOffset = 0; | |
AnimationStatus _oldStatus = AnimationStatus.dismissed; | |
bool _useDrag = true; | |
bool _callCallbacks = false; | |
/// Expands the content of the widget. | |
void expand() { | |
_afterUpdateWidgetBuild(false); | |
_callCallbacks = true; | |
_animateToTop(); | |
} | |
/// Contracts the content of the widget. | |
void contract() { | |
_afterUpdateWidgetBuild(false); | |
_callCallbacks = true; | |
_animateToBottom(); | |
} | |
/// The status of the expansion. | |
ExpansionStatus get expansionStatus { | |
if (_positionOffset == null) return ExpansionStatus.contracted; | |
if (_positionOffset == _maxOffset) return ExpansionStatus.contracted; | |
if (_positionOffset == _minOffset) return ExpansionStatus.expanded; | |
return ExpansionStatus.middle; | |
} | |
@override | |
void initState() { | |
super.initState(); | |
_controller = AnimationController( | |
vsync: this, | |
lowerBound: 0.0, | |
upperBound: 1.0, | |
); | |
_controller.addStatusListener(_handleAnimationStatusUpdate); | |
WidgetsBinding.instance | |
.addPostFrameCallback((_) => _afterUpdateWidgetBuild(true)); | |
} | |
@override | |
Widget build(BuildContext context) { | |
WidgetsBinding.instance | |
.addPostFrameCallback((_) => _afterUpdateWidgetBuild(false)); | |
return Stack( | |
clipBehavior: Clip.none, | |
// fit: StackFit.passthrough, | |
children: [ | |
SizedBox( | |
height: MediaQuery.of(context).size.height * 0.6, | |
// 0.12, | |
width: MediaQuery.of(context).size.width, | |
child: ClipRRect( | |
borderRadius: const BorderRadius.only( | |
topLeft: Radius.circular(50), | |
topRight: Radius.circular(50), | |
), | |
child: Column( | |
mainAxisSize: MainAxisSize.max, | |
children: <Widget>[ | |
Expanded( | |
child: Stack( | |
clipBehavior: Clip.hardEdge, | |
children: <Widget>[ | |
Align( | |
alignment: Alignment.topLeft, | |
child: widget.background, | |
), | |
AnimatedBuilder( | |
animation: _controller, | |
builder: (_, Widget? child) { | |
if (_controller.isAnimating) { | |
_positionOffset = _animationMinOffset + | |
_controller.value * _draggableHeight; | |
} | |
return Positioned( | |
top: _positionOffset, | |
right: 0.0, | |
left: 0.0, | |
child: child!, | |
); | |
}, | |
child: GestureDetector( | |
onTap: _toggle, | |
onVerticalDragDown: _dragDown, | |
onVerticalDragUpdate: _dragUpdate, | |
onVerticalDragEnd: _dragEnd, | |
child: Stack( | |
clipBehavior: Clip.none, | |
children: [ | |
Column( | |
mainAxisSize: MainAxisSize.min, | |
children: <Widget>[ | |
Container( | |
key: _headerKey, | |
child: widget.persistentHeader ?? | |
Container()), | |
Container( | |
key: _contentKey, | |
child: widget.expandableContent, | |
), | |
], | |
), | |
Positioned( | |
top: -15, | |
right: 2, | |
child: GestureDetector( | |
onTap: _toggle, | |
child: ShakeAnimatedWidget( | |
curve: Curves.ease, | |
duration: | |
const Duration(milliseconds: 2000), | |
shakeAngle: | |
Rotation.deg(x: 15, y: 10, z: 15), | |
child: Container( | |
height: 40, | |
width: 30, | |
// borderOnForeground: false, | |
decoration: BoxDecoration( | |
borderRadius: | |
BorderRadius.circular(20), | |
color: Colors.blue[600], | |
border: | |
Border.all(color: Colors.black)), | |
child: IconButton( | |
icon: const FaIcon( | |
FontAwesomeIcons.angleUp, | |
color: Colors.white, | |
), | |
splashRadius: 140, | |
tooltip: 'Maximize', | |
iconSize: 20, | |
onPressed: _toggle), | |
), | |
), | |
), | |
) | |
], | |
), | |
), | |
), | |
], | |
), | |
), | |
Container( | |
key: _footerKey, | |
child: widget.persistentFooter ?? Container()), | |
], | |
), | |
), | |
), | |
expansionStatus == ExpansionStatus.expanded | |
? Positioned( | |
top: -5, | |
// left: MediaQuery.of(context).size.width * 0.75, | |
right: 5, | |
child: GestureDetector( | |
onTap: () { | |
_callCallbacks = true; | |
_animateToBottom(); | |
}, | |
child: ShakeAnimatedWidget( | |
curve: Curves.ease, | |
duration: const Duration(milliseconds: 2000), | |
shakeAngle: Rotation.deg(x: 15, y: 10, z: 15), | |
child: Container( | |
height: 40, | |
width: 30, | |
// borderOnForeground: false, | |
decoration: BoxDecoration( | |
borderRadius: BorderRadius.circular(20), | |
color: Colors.blue[600], | |
border: Border.all( | |
color: const Color(0xff004694), | |
)), | |
child: IconButton( | |
icon: const Icon( | |
FontAwesomeIcons.angleDown, | |
color: Colors.white, | |
), | |
splashRadius: 140, | |
tooltip: 'Minimize', | |
iconSize: 20, | |
onPressed: () { | |
_callCallbacks = true; | |
_animateToBottom(); | |
}, | |
), | |
), | |
), | |
), | |
) | |
: const SizedBox(), | |
], | |
); | |
} | |
void _handleAnimationStatusUpdate(AnimationStatus status) { | |
if (status == AnimationStatus.completed) { | |
if (_oldStatus == AnimationStatus.forward) { | |
setState(() { | |
_draggableHeight = _maxOffset - _minOffset; | |
_positionOffset = _minOffset; | |
}); | |
if (widget.onIsExtendedCallback != null && _callCallbacks) { | |
widget.onIsExtendedCallback!(); | |
} | |
} | |
if (_oldStatus == AnimationStatus.reverse) { | |
setState(() { | |
_draggableHeight = _maxOffset - _minOffset; | |
_positionOffset = _maxOffset; | |
}); | |
if (widget.onIsContractedCallback != null && _callCallbacks) { | |
widget.onIsContractedCallback!(); | |
} | |
} | |
} | |
} | |
void _afterUpdateWidgetBuild(bool isFirstBuild) { | |
double headerHeight = _headerKey.currentContext!.size!.height; | |
double footerHeight = _footerKey.currentContext!.size!.height; | |
double contentHeight = _contentKey.currentContext!.size!.height; | |
double checkedPersistentContentHeight = | |
(widget.persistentContentHeight < contentHeight) | |
? widget.persistentContentHeight | |
: contentHeight; | |
_minOffset = | |
context.size!.height - headerHeight - contentHeight - footerHeight; | |
_maxOffset = context.size!.height - | |
headerHeight - | |
footerHeight - | |
checkedPersistentContentHeight; | |
if (!isFirstBuild) { | |
_positionOutOfBounds(); | |
} else { | |
setState(() { | |
_positionOffset = _maxOffset; | |
_draggableHeight = _maxOffset - _minOffset; | |
}); | |
} | |
} | |
void _positionOutOfBounds() { | |
if (_positionOffset! < _minOffset) { | |
//the extend is larger than contentHeight | |
_callCallbacks = false; | |
_animateToMin(); | |
} else { | |
if (_positionOffset! > _maxOffset) { | |
//the extend is smaller than persistentContentHeight | |
_callCallbacks = false; | |
_animateToMax(); | |
} else { | |
_draggableHeight = _maxOffset - _minOffset; | |
} | |
} | |
} | |
void _animateOnIsAnimating() { | |
if (_controller.isAnimating) { | |
_controller.stop(); | |
} | |
} | |
void _toggle() { | |
if (widget.enableToggle) { | |
// if (expansionStatus == ExpansionStatus.expanded) { | |
// _callCallbacks = true; | |
// _animateToBottom(); | |
// } | |
if (expansionStatus == ExpansionStatus.contracted) { | |
_callCallbacks = true; | |
_animateToTop(); | |
} | |
} | |
} | |
void _dragDown(DragDownDetails details) { | |
if (_controller.isAnimating) { | |
_useDrag = false; | |
} else { | |
_useDrag = true; | |
_startOffsetAtDragDown = details.localPosition.dy; | |
_startPositionAtDragDown = _positionOffset; | |
} | |
} | |
void _dragUpdate(DragUpdateDetails details) { | |
if (!_useDrag) return; | |
double offset = details.localPosition.dy; | |
double newOffset = | |
_startPositionAtDragDown! + offset - _startOffsetAtDragDown; | |
if (_minOffset <= newOffset && _maxOffset >= newOffset) { | |
setState(() { | |
_positionOffset = newOffset; | |
}); | |
} else { | |
if (_minOffset > newOffset) { | |
setState(() { | |
_positionOffset = _minOffset; | |
}); | |
} | |
if (_maxOffset < newOffset) { | |
setState(() { | |
_positionOffset = _maxOffset; | |
}); | |
} | |
} | |
} | |
void _dragEnd(DragEndDetails details) { | |
if (_startPositionAtDragDown == _positionOffset || !_useDrag) return; | |
if (details.primaryVelocity! < -250) { | |
//drag up ended with high speed | |
_callCallbacks = true; | |
_animateToTop(); | |
} else { | |
if (details.primaryVelocity! > 250) { | |
//drag down ended with high speed | |
_callCallbacks = true; | |
_animateToBottom(); | |
} else { | |
if (_positionOffset == _maxOffset && | |
widget.onIsContractedCallback != null) { | |
widget.onIsContractedCallback!(); | |
} | |
if (_positionOffset == _minOffset && | |
widget.onIsExtendedCallback != null) { | |
widget.onIsExtendedCallback!(); | |
} | |
} | |
} | |
} | |
void _animateToTop() { | |
_animateOnIsAnimating(); | |
_controller.value = (_positionOffset! - _minOffset) / _draggableHeight; | |
_animationMinOffset = _minOffset; | |
_oldStatus = AnimationStatus.forward; | |
_controller.animateTo( | |
0.0, | |
duration: widget.animationDurationExtend, | |
curve: widget.animationCurveExpand, | |
); | |
} | |
void _animateToBottom() { | |
_animateOnIsAnimating(); | |
_controller.value = (_positionOffset! - _minOffset) / _draggableHeight; | |
_animationMinOffset = _minOffset; | |
_oldStatus = AnimationStatus.reverse; | |
_controller.animateTo( | |
1.0, | |
duration: widget.animationDurationContract, | |
curve: widget.animationCurveContract, | |
); | |
} | |
void _animateToMax() { | |
_animateOnIsAnimating(); | |
_controller.value = 1.0; | |
_draggableHeight = _positionOffset! - _maxOffset; | |
_animationMinOffset = _maxOffset; | |
_oldStatus = AnimationStatus.reverse; | |
_controller.animateTo( | |
0.0, | |
duration: widget.animationDurationExtend, | |
curve: widget.animationCurveExpand, | |
); | |
} | |
void _animateToMin() { | |
_animateOnIsAnimating(); | |
_controller.value = 1.0; | |
_draggableHeight = _positionOffset! - _minOffset; | |
_animationMinOffset = _minOffset; | |
_oldStatus = AnimationStatus.forward; | |
_controller.animateTo( | |
0.0, | |
duration: widget.animationDurationContract, | |
curve: widget.animationCurveContract, | |
); | |
} | |
@override | |
void dispose() { | |
_controller.dispose(); | |
super.dispose(); | |
} | |
} | |
/// The status of the expandable content. | |
enum ExpansionStatus { | |
expanded, | |
middle, | |
contracted, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment