So, what is this about?
While working on our latest app AeBeZe, we wanted a nice transition when dismissing a route. Similar to MaterialPageRoute
with fullscreenDialog = true
on iOS but with a more control and a smoother motion. A few hours and several coffees later, the Slide Container was born!
As this is the first package* that I have published on Dart Pub I’ll also talk about publishing a package for Flutter in a following blog post.
*Excluding our work with the Flutter team on the camera plugin.
The slide container package
Installation instructions
You can get the full source code on Github: https://github.com/quentinleguennec/flutter-slide-container
More information about the package, including installalation instructions and documentation can be found on Dart Pub: https://pub.dartlang.org/packages/slide_container
Overview
The slide container supports both vertical and horizontal sliding, can contain widgets of any size and features an auto-slide capability to complete a slide when the finger quit the screen. It can be fully customized and offers callbacks to know when and where the container is being dragged.
You can get started by building the demo project in the example folder. Alternatively, you can see the container being used in the AeBeZe app.
Apple Store link: https://itunes.apple.com/gb/app/aebeze/id1400832226?mt=8
Google Play Link: https://play.google.com/store/apps/details?id=com.aebeze
Points of interest for developers
Aside from some fun mathematics, there are three especially interesting pieces of code. The dampening algorithm, the Ticker that is used to update the position of the container as it moves and the custom GestureDetector to reduce as much as possible conflicts with other nested GestureDetectors.
The dampening algorithm
The algorithm is one simple yet satisfying line of code:
dragTarget += (dragValue - dragTarget) / widget.dampeningStrength;
Instead of moving the container to directly match the position of the users finger (the dragValue
), we move it to a point called dragTarget
. This point is somewhere between the current position of the container and their finger. The farther their finger is from the container, the faster dragTarget
moves towards it, slowing down as it gets closer. This motion mimics a critically damped or overdamped spring.
The Ticker
Since we are damping the containers movement we need to rebuild the Widget every frame that the users finger is in contact with the screen (even when it isn’t moving).
Unfortunately this is not possible with the GestureDetector or the Listener as they only fire events when their finger is actively moving.
After some research I realized that a Ticker was exactly what I needed. A Ticker is a simple Widget that fires an event every frame it is active. This is what the AnimationController uses to update animations.
Here is how we use the callback to update the position of the container:
followFingerTicker = createTicker((_) {
if ((dragValue - dragTarget).abs() <= 1.0) {
dragTarget = dragValue;
} else {
dragTarget += (dragValue - dragTarget) / widget.dampeningStrength;
}
animationController.value = dragTarget.abs() / maxDragDistance;
});
We need to start and stop the Ticker at the right moment. We can achieve this by using the GestureDetector’s onPanStart
and onPanEnd
callbacks and voilà, a smooth animation!
The custom GestureDetector
In the case of AebeZe app, we are using gestures a lot for navigation. Swiping left and right to browse content, swiping up to show the menu, swiping down to dismiss it… Now we also want to be able to go back to the home screen with a swipe down. And to have a nice transition we use the SlideContainer. But then we have an issue, we have 2 nested GestureDetectors that listen to the same gesture! And when you have this in Flutter, only one of them will get the gesture event, the other will not trigger at all, and you cannot change this behaviour. To be honest I have the feeling this more a bug in the GestureDetector than a feature…
Flutter has an interesting system to decide which GestureDetector should handle the event when you have several of them nested. It’s called the Gesture Arena. If your GestureDetectors are listening to different events (e.g. one a tap and the other a drag) the correct one will always win the arena and get the gesture event. But if they are both competing for the same event (in our case a vertical drag) the one that is deeper in the tree will always win, and the parent GestureDetector will not see the event at all.
In the case of AeBeZe, the SlideContainer is the child of the GestureDetector that detects up and down swipes for the menu. This means that if we use a classic GestureDetector with a onVerticalDragUpdate in the SlideContainer, the menu will never show because the SlideContainer will always win the Gesture Arena and consume the gesture. And this even if the container can not slide more in the direction of the gesture.
To fix that, we use a RawGestureDetector in the SlideContainer that only win the Gesture Arena if it can slide in the direction of the drag. For example in AeBeZe app, the SlideContainer contains the whole screen and can only slide from top to bottom (C.f. video above). With this custom RawGestureDetector it means we can still use the swipe up gesture to show the menu, because the SlideContainer will see it can’t slide more in this direction and will forfeit the Gesture Arena, allowing the parent GestureDetector to get the gesture event. Ace!
To make this work, I wanted to simply extend Flutter’s VerticalDragGestureRecognizer and HorizontalDragGestureRecognizer. But I actually had to copy-paste the whole DragGestureRecognizer for this because some of the methods are file private. Then I was able to create the LockableVerticalDragGestureRecognizer and LockableHorizontalDragGestureRecognizer.
/// Recognizes movement in the vertical direction.
///
/// Modified version of the [VerticalDragGestureRecognizer] that can be locked to prevent up or down movement.
/// Movements in the locked direction will not trigger a gesture and thus not block other gesture detectors.
class LockableVerticalDragGestureRecognizer
extends ExtendedDragGestureRecognizer {
/// Getter to know if the movement should be blocked in one direction.
final ValueGetter<SlideContainerLock> lockGetter;
LockableVerticalDragGestureRecognizer({
@required this.lockGetter,
Object debugOwner,
}) : assert(lockGetter != null),
super(debugOwner: debugOwner);
SlideContainerLock get lock => lockGetter();
@override
bool _isFlingGesture(VelocityEstimate estimate) {
final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
final double minDistance = minFlingDistance ?? kTouchSlop;
if ((lock == SlideContainerLock.top && estimate.pixelsPerSecond.dy < 0.0) ||
(lock == SlideContainerLock.bottom &&
estimate.pixelsPerSecond.dy > 0.0)) {
return false;
}
return estimate.pixelsPerSecond.dy.abs() > minVelocity &&
estimate.offset.dy.abs() > minDistance;
}
@override
bool get _hasSufficientPendingDragDeltaToAccept {
if ((lock == SlideContainerLock.top && _pendingDragOffset.dy < 0.0) ||
(lock == SlideContainerLock.bottom && _pendingDragOffset.dy > 0.0)) {
return false;
}
return _pendingDragOffset.dy.abs() > kTouchSlop;
}
@override
Offset _getDeltaForDetails(Offset delta) => new Offset(0.0, delta.dy);
@override
double _getPrimaryValueFromOffset(Offset value) => value.dy;
@override
String get debugDescription => 'lockable vertical drag';
}
We use a ValueGetter to check if the container is blocked in a direction, if that’s the case we forfeit the gesture if the drag is in the blocked direction.
Publishing a Flutter package to the world
This slide container was our first flutter package on Dart Pub. We have written a follow-up post on best practices for creating good Flutter packages which you can read here: