Implementing Swipe Actions in Flutter ListView

In this tutorial I show you how to implement swipe actions in Flutter ListView

Swipe gestures are a handy way to add actions to a ListView in Flutter. In this tutorial, we are going to implement two swipe actions – swipe to delete and toggle favorite – and have a look at using a Snackbar for adding an undo action.

Goal of this Tutorial

We are going to implement a simple app displaying a list of ice cream flavors. By swiping a list element to the right, we will add or remove the flavor as a favorite flavor.

Swiping to the left will be a swipe-to-delete action and removes the element from the list. In this case, a Snackbar is shown offering to undo the delete action.

The code for this tutorial is available on GitHub.

Prerequisite

First, we start by creating a new Flutter app and clean up the generated project code. For this tutorial we do not need any dependencies, so my pubspec.yaml looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
name: list_view_swipe
description: List View Swipe Example
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

flutter:
  uses-material-design: true

Next, we clean up our main.dart. We remove most of the boilerplate code, which leaves us with the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import 'package:flutter/material.dart';
import 'package:list_view_swipe/list_page.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: ListPage(),
    );
  }
}

Finally, we create a new file called list_page.dart and add a StatefulWidget called ListPage. We set up a basic Scaffold with and AppBar like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import 'package:flutter/material.dart';

class ListPage extends StatefulWidget {
  const ListPage({Key? key}) : super(key: key);

  @override
  _ListPageState createState() => _ListPageState();
}

class _ListPageState extends State<ListPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Flavors'),
      ),
    );
  }
}

Now, when we build our app it should look like this:

So far, our app should look like this.

Displaying a list of flavors

To have some content that we can display in our list, we continue by creating a new file called flavor.dart and add a simple class holding all information we need to know about an ice cream flavor.

Every flavor has a name of type String and an attribute isFavorite which is a bool and is false by default. This is our Flavor class:

1
2
3
4
5
6
7
8
9
class Flavor {
  final String name;
  final bool isFavorite;

  Flavor({required this.name, this.isFavorite = false});

  Flavor copyWith({String? name, bool? isFavorite}) =>
      Flavor(name: name ?? this.name, isFavorite: isFavorite ?? this.isFavorite);
}

You can see that I have also added a copyWith function. Since our Flavor is immutable – all its attributes are final, thus unchangeable – this function helps us to create a new version of a Flavor with adjusted attributes. We will use this function later to toggle the isFavorite flag of the Flavors.

Next, we go back to flavor_list.dart and add a list of Flavors to _ListPageState.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class _ListPageState extends State<ListPage> {
  final List<Flavor> flavors = [
    Flavor(name: 'Chocolate'),
    Flavor(name: 'Strawberry'),
    Flavor(name: 'Hazelnut'),
    Flavor(name: 'Vanilla'),
    Flavor(name: 'Lemon'),
    Flavor(name: 'Yoghurt'),
  ];

  ...
}

To display our Flavors, we use a ListView. We use the builder factory to create a performance optimized ListView. Using the builder factory, only elements are generated that are currently displayed on the screen. Elements that do not fit on the screen are only created on demand.

Let’s look at the build function of _ListPageState:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  @override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text('Flavors'),
    ),
    body: ListView.builder(
      itemCount: flavors.length,
      itemBuilder: (context, index) {
        final flavor = flavors[index];
        return ListTile(
          title: Text(flavor.name),
        );
      },
    ),
  );
}

We specify the number of flavors that are available using the itemCount attribute and define how the ListView should build the list items by implementing the itemBuilder callback.

For every item, we fetch the corresponding flavor (line 10) and then use a ListTile to display the name of the Flavor.

Next we add an icon to each ListTile indicating whether the Flavor is a favorite or not. For this, we add the following line to the ListTile:

1
trailing: Icon(flavor.isFavorite ? Icons.favorite : Icons.favorite_border),

Now, let’s have a look at our app again.

Now our app shows all our Flavors.

Swipe Action: Toggle Favorite

Next, we add a swipe action to toggle the favorite state of a flavor. For this, we wrap the ListTile in a Dismissible widget. A Dismissible requires to have a Key, so we add it like this:

1
2
3
4
5
6
Dismissible(
  key: Key(flavor.name),
  child: ListTile(
    ...
  ),
);

Now you can see that you are able to move your ListTiles.

To give the user a hint about the action that is going to happen, we add a background attribute to the Dismissible widget.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
...
background: Container(
  color: Colors.green,
  child: Align(
    child: Padding(
      padding: const EdgeInsets.only(left: 16),
      child: Icon(Icons.favorite),
    ),
    alignment: Alignment.centerLeft,
  ),
),
...

When swiping an item to the right, we see a green background with a favorite icon.

When swiping an item to the right, we see a green background and a favorite icon.

Finally, there are two things left to do: First, we need to prevent the item from being removed from the list when it is swiped to the right. Second, we need to add an action to the swipe gesture.

To keep the item in the list, we need to implement the confirmDismiss callback of Dismissible. It provides us the direction in which the item has been swiped and requires us to return true if the item should be deleted. We also use this callback to add an action to the swipe gesture.

1
2
3
4
5
6
7
8
confirmDismiss: (direction) async {
  if (direction == DismissDirection.startToEnd) {
    setState(() {
      flavors[index] = flavor.copyWith(isFavorite: !flavor.isFavorite);
    });
    return false;
  }
},

Now, when swiping from start to end – left to right in our case – we update the state of our ListPage. We do this by calling the setState function and replacing the Flavor at position index with a copy with inverted isFavorite flag.

We return false to signal that we do not want our item removed from the list.

Swipe Action: Swipe to delete

Now, we want to add a second action: the item should be deleted when it is swiped to the left side. Additionally, we want to add a different background for this action.

For this, we copy the Container we added to background and add it to secondaryBackground attribute for Dismissible.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
...
secondaryBackground: Container(
  color: Colors.red,
  child: Align(
    child: Padding(
      padding: const EdgeInsets.only(right: 16),
      child: Icon(Icons.delete),
    ),
    alignment: Alignment.centerRight,
  ),
),
...

We adjust the color to Colors.red, change the padding from left to right, replace the icon, and change the alignment to centerRight.

When we swipe our item to the left side, we see that the secondaryBackground is used.

When swiping left, the delete action is revealed.

Next, we are going to extend the confirmDismiss callback. We add an else block to handle our delete action. Here, we are going to show a SnackBar with a SnackBarAction. Let’s see what this looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
 confirmDismiss: (direction) async {
   if (direction == DismissDirection.startToEnd) {
    ...
   } else {
     bool delete = true;
     final snackbarController = ScaffoldMessenger.of(context).showSnackBar(
       SnackBar(
         content: Text('Deleted ${flavor.name}'),
         action: SnackBarAction(label: 'Undo', onPressed: () => delete = false),
       ),
     );
     await snackbarController.closed;
     return delete;
   }
 },

First, in line 5, we add a variable to store our decision whether the item should be deleted.

In line 6 we use the ScaffoldMessenger to show a SnackBar and store the returned SnackBarController. We need this instance later in line 12 to wait for the SnackBar to close.

The SnackBar contains a Text content (line 8) and defines a SnackBarAction (line 9). The action changes the delete variable to false, indicating that the item should not be deleted.

After the SnackBar has been closed, we return the decision whether to delete the item.

Finally, we need to implement onDismissed to actually remove the item from our List, to match the state of the UI.

The callback should look like this:

1
2
3
4
5
onDismissed: (_) {
  setState(() {
    flavors.removeAt(index);
  });
},

We ignore the callback parameter and replace it with _. We use the setState function to redraw our UI and remove the item from the list.

Finally

The complete ListPage should now look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import 'package:flutter/material.dart';
import 'package:list_view_swipe/flavor.dart';

class ListPage extends StatefulWidget {
  const ListPage({Key? key}) : super(key: key);

  @override
  _ListPageState createState() => _ListPageState();
}

class _ListPageState extends State<ListPage> {
  final List<Flavor> flavors = [
    Flavor(name: 'Chocolate'),
    Flavor(name: 'Strawberry'),
    Flavor(name: 'Hazelnut'),
    Flavor(name: 'Vanilla'),
    Flavor(name: 'Lemon'),
    Flavor(name: 'Yoghurt'),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Flavors'),
      ),
      body: ListView.builder(
        itemCount: flavors.length,
        itemBuilder: (context, index) {
          final flavor = flavors[index];
          return Dismissible(
            key: Key(flavor.name),
            background: Container(
              color: Colors.green,
              child: Align(
                child: Padding(
                  padding: const EdgeInsets.only(left: 16),
                  child: Icon(Icons.favorite),
                ),
                alignment: Alignment.centerLeft,
              ),
            ),
            secondaryBackground: Container(
              color: Colors.red,
              child: Align(
                child: Padding(
                  padding: const EdgeInsets.only(right: 16),
                  child: Icon(Icons.delete),
                ),
                alignment: Alignment.centerRight,
              ),
            ),
            confirmDismiss: (direction) async {
              if (direction == DismissDirection.startToEnd) {
                setState(() {
                  flavors[index] = flavor.copyWith(isFavorite: !flavor.isFavorite);
                });
                return false;
              } else {
                bool delete = true;
                final snackbarController = ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(
                    content: Text('Deleted ${flavor.name}'),
                    action: SnackBarAction(label: 'Undo', onPressed: () => delete = false),
                  ),
                );
                await snackbarController.closed;
                return delete;
              }
            },
            onDismissed: (_) {
              setState(() {
                flavors.removeAt(index);
              });
            },
            child: ListTile(
              title: Text(flavor.name),
              trailing: Icon(flavor.isFavorite ? Icons.favorite : Icons.favorite_border),
            ),
          );
        },
      ),
    );
  }
}

We used the Dismissible widget to create swipe actions to toggle the favorite state and to remove items. It allows us to handle the gesture differently depending on the swipe direction. Furthermore, we can specify different background per swipe direction.

To offer an undo action, we used a SnackBar with a SnackBar action.

Built with Hugo
Theme based on Stack designed by Jimmy