An in-depth dive into implementing swipe-to-dismiss in Flutter

Wilberforce Uwadiegwu
Flutter Community
Published in
8 min readOct 26, 2019

When you display a linear list of widgets with data from some model you might want the user to perform some actions on it. With the small screen real estate of mobile devices, it makes sense to delegate some of these users actions to gestures. Say swipe left to archive or swipe right to delete.

Coming from a background that’s deeply rooted in the verboseness of Android, one of the things that I admire about Flutter is the ease with which you can implement some features. From one-liner asynchronous calls to handy animation widgets and displaying a scrollable linear array of on-demand widgets without having to write whole classes of Adapters and ViewHolders.

This also applies when implementing dismissibles, you don’t have to write a class that implements ItemTouchHelper.Callback and override a lot of functions.

Dismissible

At the barest minimum, implementing a widget that can be dismissed by dragging requires that you wrap said widget in a Dismissible widget, and then pass a key to the latter. That’s it!

class HomeWidget extends StatelessWidget {
final items = List<int>.generate(10, (i) => i + 1);
@override
Widget build(BuildContext context) {
return Column(
children: items.map((i) {
return Dismissible(
key: ValueKey(i),
child: ListTile(
title: Text("Item $i"),
),
);
}).toList(),
);
}
}

Running that got this:

Now, that is deceptively too easy. While that appears to work, the widgets tree will crash with “A dismissed Dismissible widget is still part of the tree” when the state changes.

In a more realistic use-case, you probably have a large dataset, hence a ListView.builder is a better choice than a Column. Also, you have to remove the dismissed item from the backing list in onDismissed. And most importantly, your widget should be a subclass of a StatefulWidget.

class HomeWidget extends StatefulWidget {
@override
_HomeWidgetState createState() => _HomeWidgetState();
}
class _HomeWidgetState extends State<HomeWidget> {
final items = List<int>.generate(10, (i) => i + 1);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
var item = items[index];
return Dismissible(
key: ValueKey(item),
child: ListTile(
title: Text("Item $item"),
),
onDismissed: (direction) {
// Remove the dismissed item from the list
items.removeAt(index);
},
);
},
),
);
}
}

Now that works!

A more user-friendly implementation

While that works, you don’t want to push that to production, do you?
A more user-friendly implementation would be to add a background behind the item that’s being swiped.

For that, we just have to pass a background widget to the Dismissible.

...
Dismissible(
key: ValueKey(item),
background: Container(
color: Colors.red,
),
...
)

Multiple backgrounds

Say we support different actions for left and right swipe, we need visual cues to differentiate these two actions.

Dismissible(
key: ValueKey(item),
background: Container(
color: Colors.red,
),
secondaryBackground: Container(
color: Colors.amber[700],
),
...
)

More intuitive backgrounds

I bet the user doesn’t know what actions will be executed on the dismissal of the widget. We need a more intuitive implementation, maybe an icon.

We simply have to pass a child each to the background containers.

Dismissible(
key: ValueKey(item),
background: Container(
color: Colors.red,
padding: EdgeInsets.symmetric(horizontal: 20),
alignment: AlignmentDirectional.centerStart,
child: Icon(
Icons.delete,
color: Colors.white,
),
),
secondaryBackground: Container(
color: Colors.amber[700],
padding: EdgeInsets.symmetric(horizontal: 20),
alignment: AlignmentDirectional.centerEnd,
child: Icon(
Icons.archive,
color: Colors.white,
),
),
...
)

It doesn’t have to be an icon, it can be any widget you want:

Detecting swipe direction

When a swipe action occurs, we need to know whether the user swiped left or right before taking the appropriate action. Since swiping left is for archiving while the right is for deleting. We can simply get the direction of the swipe from the direction object in the onDismissed callback.

Dismissible(
...
onDismissed: (direction) {
// Remove the dismissed item from the list
items.removeAt(index);

String action;
if (direction == DismissDirection.startToEnd) {
deleteItem();
action = "deleted";
} else {
archiveItem();
action = "archived";
}
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text("Item $item $action"),
),
);
},
)

Assuming we are getting the dataset from a remote source, deleteItem() and archiveItem() are methods that make the network calls.

Confirming from the user before processing the swipe

Let’s take the user experience a notch higher and prompt the user before actually deleting or archiving the item.
The Dismissible widget has a confirmDismiss callback that expects a Future<bool> type. So we can declare a function that displays a dialog and returns a future of type bool. Returning true will dismiss the item while returning false will slide it item back into view.

Undoing a swipe-to-dismiss action

Even after our users have confirmed the dismiss action, it also makes sense to give an undo window, an opportunity for the customer to undo the action.

In my opinion, the right time to implement this undo is after removing the dismissed item and before making an API call to your backend.

When the user chooses to undo the action, simply re-adding the swiped item to the backing list and calling setState(() {}) would have sufficed but Dismissible “remembers” the keys passed to it. And since we are using the model as the key, this will still throw the “A dismissed Dismissible widget is still part of the tree” error we encountered initially.

To successfully re-add the item, we need non-literal types, literals just won’t cut it. So we’ll create a custom Model class and when we want to undo the dismissed action, we deep-copy the model at the deleted position and insert it into the backing list at the same position.

Assuming our application is an email client and our models are email objects, we would have this:

class Email {
final String sender;
final String subject;
final String body;
Email(this.sender, this.subject, this.body);Email.copy(Email other)
: this.sender = other.sender,
this.subject = other.subject,
this.body = other.body;
}

We are simply instantiating a new Email object and copying the fields of other over to the new instance. The new Email object is not equal to other and the fields don’t point to the same memory address.

final email1 = Email("sender@email.com", "Test Email", "Test test");
final email2 = Email.from(email1);
assert(email1 != email2);email1.subject = "New email1 subject";
assert(email1.subject != email2.subject);
email1.subject = "New email2 subject";
assert(email1.subject != email2.subject);

Now, back to our undo implementation.

We will move the handling of onDismissed to a function to make the code look cleaner. In the function, we will get a reference to the item at the position the user swiped, remove it from the backing list and display a Snackbar. We will also add an action button to the Snackbar to undo the swipe. In the undo implementation, we’ll deep copy the item just as I mentioned earlier, then insert it at the index of the dismissed item and call setState. Let’s see the code for better understanding.

final _scaffoldKey = GlobalKey<ScaffoldState>();
final items = List<Email>.generate(10, (i) {
var number = i + 1;
return Email(
"Sender $number",
"Subject $number",
" Body $number",
);
});
@override
Widget build(BuildContext context) {
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(),
body: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
var item = items[index];
return Dismissible(
key: ValueKey(item),
// Removed child, background,
// secondaryBackground and confirmDismiss
onDismissed: (direction) {
return handleDismiss(direction, index);
},
);
},
),
);
}

Nothing much has changed except that items is now type Email and we are passing a GlobalKey to our Scaffold; this is so we can access it outside the build method.

Now, handleDismiss looks like this:

When the Snackbar is dismissed and before making an API call to the backend, we are checking that the reason for the dismissal is not as a result of clicking the action button. While this suits the purpose of this post, it might not be the best for your usage, Snackbar can be dismissed with a lot of reasons.

That works.

However, the re-entrance of the swiped item is not easily noticeable. Hence this experience is not as smooth as we might want it. The item that was reinserted into the list should be animated for better user experience, don’t you think so?

Animating the ListView

Now, Flutter has an AnimatedList. Under the hood, it builds a ListView.builder and handles the logic behind animating an item in and out. Hence it’s also suitable for on-demand widgets.

Much has changed. We have now switched ListView.builder with AnimatedList and wrapping the Dismissible in SlideTransition which is in turn wrapped in a FadeTransition._offSetTween interpolates the animation from right to left.

Also, notice that when we add or remove from the backing list, we are also calling the corresponding removeItem and insertItem on the state of the AnimatedList. Both methods accept a Duration object; you can use this to specify the duration of the animation.

Customizing the dismiss direction

So far our Dismissible supports horizontal (left-right and right-left) swipe, but we can actually limit the swipe to only one horizontal direction (left-right or right-left), or even support vertical direction (up-down and/or down-up).
The direction can be passed to the direction parameter of the Dismissible. It accepts any of the six values of DismissDirection.

Widget resizes and duration

onResize is called several times from when confirmDismiss returns true to when onDismissed is called. This is usually to inform the caller that the Dismissible is being resized before being dismissed. And you can customize the duration of this widget resize by passing a Duration object to resizeDuration.

Dismiss thresholds

By default when you drag 40% towards the supported directions, the item widget is dismissed, otherwise, it’s slid back in. You can customize this behaviour by passing a mapping of direction-value to dismissThresholds. For example on LTR text directions, this:

dismissThresholds: {
DismissDirection.startToEnd: 0.1,
DismissDirection.endToStart: 0.7
},

means that the widget will be dismissed once it’s dragged 10% towards the right while it’ll be dismissed when dragged 70% towards the left.

Dismiss and come back duration

Movement duration is the time taken to complete the dismiss and come back animations. The latter only happens when you return a Future of value false. This:

movementDuration: Duration(seconds: 3)

results in this:

Conclusion

As at the time of writing this, there are two other parameters I didn’t mention viz. dragStartBehavior and crossAxisEndOffset. I feel that they’re not important but you can explore them if you are curious.

--

--