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

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

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

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

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

Multiple backgrounds

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

More intuitive backgrounds

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

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

Undoing a swipe-to-dismiss 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

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

Widget resizes and duration

Dismiss thresholds

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

movementDuration: Duration(seconds: 3)

results in this:

Conclusion

Flutter Community

Articles and Stories from the Flutter Community

Thanks to Nash

Wilberforce Uwadiegwu

Written by

I code.

Flutter Community

Articles and Stories from the Flutter Community

More From Medium

More from Flutter Community

More from Flutter Community

More from Flutter Community

Flutter — Shadows & glows

More from Flutter Community

More from Flutter Community

How refactoring your Flutter App.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade