Flutter: Custom Draggable Bottom Sheet (2)

Follow slow drag movements & scroll fast swipes & control inner scrollable view

Soo Kim
8 min readJun 10, 2023

My last custom draggable bottom sheet post was a pretty simple one where the bottom sheet automatically goes up or down depending on the user’s scroll. This time, it’s slightly more advanced in that it follows the user’s finger movements (drag) when its slow, and when the user swipes fast, the bottom sheet scrolls up or down automatically. Also, the inner part of the bottom sheet, which is a scrollable view, is controlled.

Here is the Github repository.

Table of Contents:

  • Main.dart
  • Bottom Sheet Container UI
  • Controlling the Bottom Sheet Container
    - save initial position
    - following drag gesture
  • Continuing the User’s Drag Gesture
    - scroll to top after certain point
    - continue scroll based on user’s velocity
  • Body as List
    - main.dart
    - bottom sheet body
    - _isAtTop
    - following drag gesture
    - continue scroll

Main.dart

This is Main.dart (body is not a list).

class Home extends StatelessWidget {
Home({Key? key}) : super(key: key);

final Widget _header = Container(
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(topLeft: Radius.circular(25.0), topRight: Radius.circular(25.0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Container(
margin: const EdgeInsets.only(top: 6.0, bottom: 8.0),
width: 40.0, // 10.w
height: 6.5, // 0.8.h
decoration: BoxDecoration(color: Colors.black45, borderRadius: BorderRadius.circular(50)),
),
Text("Drag the header to see bottom sheet"),
],
),
);

@override
Widget build(BuildContext context) {
final Size _size = MediaQuery.of(context).size;

return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: <Widget>[
Container(height: _size.height),
Positioned(
left: 0.0,
right: 0.0,
bottom: 0.0,
child: CustomBottomSheet(
maxHeight: _size.height * 0.745,
headerHeight: 50.0,
header: this._header,
borderRadius: BorderRadius.only(topLeft: Radius.circular(25.0), topRight: Radius.circular(25.0)),
boxShadow: <BoxShadow>[
BoxShadow(color: Colors.black26, blurRadius: 10.0, spreadRadius: -1.0, offset: Offset(0.0, 3.0)),
BoxShadow(color: Colors.black26, blurRadius: 4.0, spreadRadius: -1.0, offset: Offset(0.0, 0.0)),
],
body: Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey, width: 1.0)),
),
alignment: Alignment.center,
width: _size.width,
height: _size.height * 0.6,
child: Text("body"),
),
),
)
],
),
);
}
}

Bottom Sheet Container UI

Here is the UI of the bottom sheet itself (the container). It is constrained by maxHeight and minHeight, both including the header, if any. The actual height of the bottom sheet is as follows:

headerHeight + _bodyHeight (which will be defined as the user drags the bottom sheet) + bottomViewPadding

The boolean “_isAtTop” is there to distinguish the bottom sheet container from the inner list and the scroll controller is for the list as well. So if you don’t plan on having a list as the body, that part is unnecessary.

final ScrollController _scrollController = ScrollController();
Animation? _animation;
AnimationController? _animationController;

double _bodyHeight = 0.0;
bool _isAtTop = false;
double _initPosition = 0.0;

@override
Widget build(BuildContext context) {
return GestureDetector(
onVerticalDragStart: this._saveInitPosition,
onVerticalDragUpdate: this._followDrag,
child: SingleChildScrollView(
controller: this._isAtTop ? null : this._scrollController,
child: Container(
padding: this.widget.hasBottomViewPadding ? EdgeInsets.only(bottom: MediaQuery.of(context).viewPadding.bottom) : null,
width: MediaQuery.of(context).size.width,
constraints: BoxConstraints(
maxHeight: this.widget.maxHeight + this.widget.headerHeight,
minHeight: this.widget.headerHeight + this.widget.minHeight
),
decoration: BoxDecoration(
color: this.widget.bgColor,
borderRadius: this.widget.borderRadius,
boxShadow: this.widget.boxShadow,
),
height: this.widget.headerHeight + this._bodyHeight + (this.widget.hasBottomViewPadding ? MediaQuery.of(context).viewPadding.bottom : 0.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
height: this.widget.headerHeight,
alignment: Alignment.center,
child: this.widget.header,
),
Expanded(
child: this.widget.body,
),
],
),
),
),
);
}

Controlling the Bottom Sheet Container

I will initially go over this section under the assumption that the body is not a list and therefore, you do not need to use the _isAtTop boolean.

Save Initial Position

First, use GestureDetector’s onVerticalDragStart to save the initial position.

void _saveInitPosition(DragStartDetails d) => this._initPosition = d.globalPosition.dy;

Following Drag Gesture

When the drag updates, find out the difference between the initial position and the updated position. If the difference is positive, the user dragged up, and if the difference is negative, the user has dragged down. Then, you want to add this difference to the current _bodyHeight. Set state the new _bodyHeight and reassign _initPosition to be the current offset.

Below, you’ll also find conditional statements to constrain _bodyHeight.

void _followDrag(DragUpdateDetails d) {
final double _movedAmount = this._initPosition - d.globalPosition.dy;

double _newHeight = this._bodyHeight + _movedAmount;
if (_newHeight < 0.0) _newHeight = 0.0; // makes sure the bodyHeight does not fall under 0.0
if (_newHeight > this.widget.maxHeight) _newHeight = this.widget.maxHeight; // makes sure the bodyHeight does not go above max height
this._bodyHeight = _newHeight;
this.setState(() {});
this._initPosition = d.globalPosition.dy;
}

Continuing the User’s Drag Gesture

Depending on the user’s drag velocity, or the position at which the user stopped dragging, you might want to continue the drag and scroll to a certain position. In order to do this, I assigned a value to _animation, and as it animates, assign a value to _bodyHeight and set state until it reaches the desired position.

**There was one problem that I couldn’t figure out how to solve: sometimes when the user lets go of the screen, the finger comes off the screen after slightly touching the other direction…so even with velocity or _movedAmount, there was room for error.

Scroll to Top After Certain Point

This is the code to scroll the bottom sheet to the top when the user dragged past half of desired max height.

@override
void initState() {
super.initState();
this._animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300))
..addListener(() {
this._bodyHeight = this._animation!.value;
this.setState(() {});
});
}

Future<void> _onDragEnd(DragEndDetails d) async {
// scroll the bottom sheet to the top when the user dragged past a certain point.
if (this._bodyHeight >= this.widget.maxHeight * 1/2) {
this._animation = Tween<double>(begin: this._bodyHeight, end: this.widget.maxHeight).animate(this._animationController!);
this._animationController?.reset();
this._animationController!.forward();
}
}

Continue Scroll Based On User’s Velocity

DragEndDetail has primaryVelocity property: if it is negative, the user swiped up and if it is positive, the user swiped down. So according to the velocity, I decided how much to scroll the bottom sheet and how fast the animation would be. As someone who knows nothing about velocity and any math or science related to it, I fiddled around to find numbers that I liked.

For fast speed (< -2000.0 or > 2000.0), the bottom sheet scrolls all the way up for down respectively for 300 milliseconds. For moderate speed (anywhere in between), the bottom sheet animates to certain position (again…very non-precise but the best I could do) for 100 milliseconds.

Future<void> _onDragEnd(DragEndDetails d) async {
if (d.primaryVelocity == null) return;

double _animateTo = this._bodyHeight - d.primaryVelocity! / 20.0; // to be used for moderate swipe speed
Duration _duration = const Duration(milliseconds: 100); // 100 for moderate swipe, 300 for fast swipe

if (d.primaryVelocity!.isNegative) {
if (d.primaryVelocity! < - 2000.0) {
_duration = const Duration(milliseconds: 300);
_animateTo = this.widget.maxHeight; /// scroll up all the way up
} else {
if (_animateTo > this.widget.maxHeight) _animateTo = this.widget.maxHeight; /// makes sure does not go past max height
}
} else {
if (d.primaryVelocity! > 2000.0) {
_duration = const Duration(milliseconds: 300);
_animateTo = 0.0; /// scroll all the way down
} else {
if (_animateTo < 100.0) _animateTo = 0.0; /// make sure does not go below 0.0
}
}

this._animationController?.duration = _duration;
this._animation = Tween<double>(begin: this._bodyHeight, end: _animateTo).animate(this._animationController!);
this._animationController?.reset();
await this._animationController?.forward();

}

Combining the Two Methods

I simplified the scroll based on velocity method and combined the two.


Future<void> _onDragEndPosition(DragEndDetails d) async {
this._animationController!.duration = const Duration(milliseconds: 300);
if (this._bodyHeight >= this.widget.maxHeight * 1/3) {
this._animation = Tween<double>(begin: this._bodyHeight, end: this.widget.maxHeight).animate(this._animationController!);
this._animationController?.reset();
await this._animationController?.forward();
}
if (this._bodyHeight < this.widget.maxHeight * 1/3) {
this._animation = Tween<double>(begin: this._bodyHeight, end: 0.0).animate(this._animationController!);
this._animationController?.reset();
await this._animationController?.forward();
}
}

Future<void> _onDragEndVelocitySimple(DragEndDetails d) async {
double _end;
if (d.primaryVelocity! < 0.0) {
_end = this.widget.maxHeight;
} else {
_end = 0.0;
}
this._animation = Tween<double>(begin: this._bodyHeight, end: _end).animate(this._animationController!);
this._animationController?.reset();
await this._animationController?.forward();
}

Future<void> _onDragEnd(DragEndDetails d) async {
if (d.primaryVelocity == null) return;
if (d.primaryVelocity == 0) await this._onDragEndPosition(d);
if (d.primaryVelocity != 0) await this._onDragEndVelocitySimple(d);
}

Body as List

Main.dart

Widget _listItem(String text) => Container(
width: double.infinity,
height: 40.0,
alignment: Alignment.center,
child: Text(text),
);
...
CustomBottomSheet(children: List.generate(20, (int index) => this._listItem("list item $index"))

Bottom Sheet Body

With the body as a list, the physics will be NeverScrollableScrollPhysics and it will only be controlled with a scroll controller.

...
height: this.widget.headerHeight + this._bodyHeight + (this.widget.hasBottomViewPadding ? MediaQuery.of(context).viewPadding.bottom : 0.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
height: this.widget.headerHeight,
alignment: Alignment.center,
child: this.widget.header,
),
Expanded(
child: this.widget.body ?? ListView.builder(
physics: const NeverScrollableScrollPhysics(),
controller: this._scrollController,
padding: const EdgeInsets.only(bottom: 45.0),
itemCount: this.widget.children!.length,
itemBuilder: (_, int index) => this.widget.children![index]
),
)
],
),

_isAtTop

The boolean _isAtTop becomes very important as it will decide whether the bottom sheet container is dragged or the inner list. There are a few areas where this value has to change.

(1) Animation Controller Listener
When the bottom sheet container is animated, _isAtTop needs to change accordingly. It needs to be when the animation status is completed!!

@override
void initState() {
super.initState();
this._animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300), )
..addListener(() {
this._bodyHeight = this._animation!.value;
this.setState(() {});
})..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed && this._bodyHeight >= this.widget.maxHeight) this._isAtTop = true;
});
}

(2) onDragEnd
There are instances where the bottom sheet container is not animated but controlled by drag only, and in that case, _isAtTop needs to change accordingly.

(3) onDragUpdate
This will be covered below.

Following Drag Gesture

Mainly, in onDragUpdate, we need to know whether the bottom sheet is moving or the inner list, which can be determined with “scrollController.position.extentBefore == 0.0”. If it is 0.0, the inner list has not been scrolled yet.

Then, if _isAtTop is true and primaryDelta is negative, we know the bottom sheet has been fully opened and the user is swiping upwards. Therefore, the inner list needs to be moved, which I did using this._scrollController.jumpTo(_scrollTo).

If that is not the case, the method that follows drag gesture as described above is used, but!! make sure that _isAtTop is set to false here.

void _followDragWithBodyAsList(DragUpdateDetails d) {
final double _movedAmount = this._initPosition - d.globalPosition.dy; /// negative = drag down, positive = drag up
final double _scrollTo = this._scrollController.offset + _movedAmount; /// needed for scrolling the inner list

/// the list inside has not been touched yet
if (this._scrollController.position.extentBefore == 0.0) {
if (this._isAtTop && d.primaryDelta!.isNegative) { /// bottom sheet has been scrolled to the top and the user is scrolling more upwards
this._scrollController.jumpTo(_scrollTo);
} else {
/// follow drag gesture
double _newHeight = this._bodyHeight + _movedAmount;
if (_newHeight < 0.0) _newHeight = 0.0; /// makes sure the bodyHeight does not fall under 0.0
if (_newHeight > this.widget.maxHeight) _newHeight = this.widget.maxHeight; /// makes sure the bodyHeight does not go above max height
this._bodyHeight = _newHeight;
this.setState(() {});
this._isAtTop = false;
}

} else {
/// user is scrolling the inner list
if (_scrollTo > this._scrollController.position.maxScrollExtent) return;
this._scrollController.jumpTo(_scrollTo);
}
this._initPosition = d.globalPosition.dy;
}

If the scroll controller’s position.extentBefore property is not 0.0, once again use the jumpTo method. Here, _scrollTo is different from _movedTo because _scrollTo considers the current offset of the inner list.

Continue Scroll (Inner List and Bottom Sheet Container)

Last but not least…automating scroll. I am actually very tired after writing this post…so I will come back to writing the explanation but here is the code.

Future<void> _onDragEndWithBodyAsList(DragEndDetails d) async {
if (d.primaryVelocity == null) return;

if (!this._isAtTop) { /// scrolls the bottom sheet container
if (d.primaryVelocity == 0) await this._onDragEndPosition(d);
if (d.primaryVelocity != 0) await this._onDragEndVelocitySimple(d);
} else {
/// scrolls the inner list
double _animateTo = this._scrollController.offset - d.primaryVelocity! / 5;

if (d.primaryVelocity! > 0.0 && _animateTo < 0.0) _animateTo = 0.0; /// does not overscroll upwards
if (d.primaryVelocity! < 0.0 && _animateTo > this._scrollController.position.maxScrollExtent) {
_animateTo = this._scrollController.position.maxScrollExtent; /// does not overscroll downwards
}

int _duration = 1600; /// scroll duration for inner parts
/// change scroll duration depending on offset
if (_animateTo == 0.0 && this._scrollController.offset - _animateTo < _duration) _duration = 600;
if (_animateTo == this._scrollController.offset && this._scrollController.offset - _animateTo < _duration) _duration = 300;
await this._scrollController.animateTo(_animateTo, duration: Duration(milliseconds: _duration), curve: Curves.easeOutCubic);
}

if (this._bodyHeight >= this.widget.maxHeight) {
this._isAtTop = true;
} else {
this._isAtTop = false;
}
}

--

--