Pocket Studio Academy
HomePart 55.10

Pocket Notes 5 — swipe to delete, with undo

Full course15 min read·4 questions

Drag a card left and the note is gone from SQLite immediately — no timer, no pending state, no "are you sure?". A snackbar with an Undo button puts the very same note back where it was.

Four chapters in, and nothing can be removed

Pocket Notes writes notes and edits them. It has never deleted one. You can prove that unpleasantly: open it now and count the "Test note" rows chapter 2 left behind, which you have no way of getting rid of short of uninstalling the app.

Adding delete is easy. The @Delete function has been sitting unused in the DAO since chapter 1. What is not easy is the question that arrives with it:

How do you stop somebody deleting the wrong thing?

There are two answers, and almost every app picks the worse one.

Think of it like this

Think about the bin in a kitchen.

You scrape a plate into it without ceremony. Nobody stands beside the bin asking "are you certain about that yoghurt pot?" — and if they did, you would stop reading the question by Wednesday and start saying yes automatically, which defeats the entire point of asking.

What makes a kitchen bin safe is not permission. It is that on Thursday morning, when you realise the receipt you needed went in with the potato peelings, you can open the lid and take it back out.

That is the design in this chapter. The delete is real and immediate. The safety net is a way back, not a gate in front.

Why undo beats "are you sure?"

A confirmation dialog looks like caution. Look at what it actually does.

It taxes the right answer to catch the wrong one. Ninety-nine deletes out of a hundred are meant. All hundred get interrupted, so the ninety-nine correct ones are made slower and more annoying in order to slightly inconvenience the one mistake.

It stops being read almost immediately. A dialog that appears every single time trains you to dismiss it without looking. By the fiftieth note you are tapping "Delete" before your eyes have focused on the words — which means the one time it mattered, you tapped through that too.

It cannot answer the question you actually have. The mistake in a list is almost never "I did not mean to delete anything". It is "I did not mean to delete *that one*". A dialog saying "Delete this note?" does not tell you which note, and by the time you find out you have already answered.

It arrives at the worst possible moment. You are mid-gesture, thumb moving. The dialog stops the motion to ask a question you already answered by starting the gesture.

Undo inverts all four. The common case costs nothing. The message is passive — you only engage with it if something went wrong. It appears after the result is visible, so you can see what happened before deciding. And it costs one tap on a button that is already on screen.

That is an : do the thing, show the result, offer the way back.

The delete is genuinely a delete

This is worth being precise about, because there is a tempting shortcut here and this chapter does not take it.

The shortcut is to hide the row for four seconds, start a timer, and only really delete when the timer runs out. It sounds safer. It is worse in three ways:

  • The list is lying. For four seconds the screen shows one thing and the database holds another. Everything downstream — a count, a search, a widget — has to be told about a state that only exists in one composable's head.
  • It does not survive being killed. Android can stop your app at any moment. If the note is only "pending deletion" in memory, closing the app during those four seconds resurrects it, which is not what anybody asked for.
  • Undo gets harder, not easier. You now have two ways a note can be absent and two ways to bring it back.

Here, vm.delete(note) removes the row the instant your finger lifts. The Note object is still sitting in memory in the snackbar's lambda, complete with its id and its original timestamp — and putting it back is nothing more exotic than saving it again.

Wrapping a row in a gesture

One new file. It knows nothing about notes.

Two functions in the ViewModel

ui/NotesViewModel.ktkotlin
1    fun delete(note: Note) {
2        viewModelScope.launch { repo.delete(note) }
3    }
4
5    /**
6     * Undo. The note still carries its old id and its
7     * old timestamp, so it slots back into exactly the
8     * place it came from - not to the top of the list.
9     */
10    fun restore(note: Note) {
11        viewModelScope.launch { repo.save(note) }
12    }

Look at how little restore does. It is save — the same the editor uses — handed the whole original Note.

That is the entire reason undo works, and the reason it works well. The note still carries:

  • its id, so updates the row it used to be rather than inserting a new one with a new number;
  • its updatedAt, so the query's ORDER BY updatedAt DESC puts it back in the same position in the list.

Compare the editor's save(id, title, body), which deliberately stamps a fresh System.currentTimeMillis(). Editing a note should move it to the top. Un-deleting one should not — the note has not changed, it has come back. Two functions, because they are two different ideas, even though both end at repo.save.

The most common undo bug

If your restore path goes through the editor's save(id, title, body) instead, the note reappears — at the top of the list, with today's date, having apparently just been written. It looks almost right, which is why this one survives code review. It is Error Doctor entry 5.

Delete, then offer the way back

The Scaffold needs somewhere to put it. One new parameter:

ui/NoteListScreen.ktkotlin
        snackbarHost = { SnackbarHost(snackbars) },

That is another slot, exactly like topBar and floatingActionButton. Material knows where a snackbar belongs, knows to lift the out of its way while it is showing, and knows to keep it above the navigation bar. You supply the state; it supplies the choreography.

Wrapping every card

Three things happen in twelve lines, and NoteCard is not one of them — it is untouched from chapter 3.

Note

animateItem() is completely dependent on the you added back in chapter 3. Without keys, Compose tracks rows by position: remove row 2 and row 3 does not move up, it simply becomes the new row 2 with different contents. There is nothing to animate because, as far as Compose is concerned, nothing moved.

With key = { note -> note.id }, row id 47 is row id 47 wherever it appears, so Compose can see it change position and slide it there. One argument, added two chapters early, is what makes this line work at all.

9:41▲ ▮
Pocket Notes
Shopping
Oat milk, tomatoes, bread
Just now
🗑
Ideas
A timer that turns the lights down when a session starts, then puts…
3 h ago
Untitled
Ring Mum back
Yesterday
+

Mid-swipe. The card slides left; the red panel and the bin are underneath it.

9:41▲ ▮
Pocket Notes
Shopping
Oat milk, tomatoes, bread
Just now
Untitled
Ring Mum back
Yesterday
+
Note deleted
Undo

Let go. The row is already out of SQLite, the gap is closing, and the snackbar has the way back.

The snackbar is inverseSurface#362F27, a dark brown against a warm cream page — with inverseOnSurface text and the action in inversePrimary. Those three tokens exist so that a message laid over your app can be legible without being a foreign colour, and they flip together in dark mode.

Try it in Pocket Studio

One new file, two changed. Start from your chapter 4 project or the chapter 4 ZIP.

  1. Open Pocket StudioProjectsPocket Notes.
  2. In the ui package, tap NewKotlin File, name it SwipeToDelete, and type the whole file. Note that the imports use import androidx.compose.material3.* — the explicit import androidx.compose.material3.rememberSwipeToDismissBoxState is 64 characters, over the house limit.
  3. Open ui/NotesViewModel.kt and add delete(...) and restore(...) at the bottom of the class, under save(...).
  4. Open ui/NoteListScreen.kt. Add the two imports at the top: com.nativeworks.pocketnotes.data.Note and kotlinx.coroutines.launch.
  5. Above the Scaffold, add snackbars, scope and the whole deleteWithUndo function.
  6. Add snackbarHost = { SnackbarHost(snackbars) }, to the Scaffold, just under containerColor.
  7. Add onDelete = { deleteWithUndo(it) } to the NoteList(...) call, add onDelete: (Note) -> Unit to NoteList's parameters, and wrap the NoteCard inside SwipeToDelete { ... }.
  8. Tap Run. Drag a card to the left. Let go past halfway. The row goes and the snackbar appears at the bottom.
  9. Tap Undo before it disappears. The note comes back — check its timestamp and its position: it should be exactly where it was, not at the top.
  10. Delete another one and let the snackbar time out. Close the app completely, reopen it. Still gone, because it was really gone from the moment you let go.
  11. Now drag a card to the right. Nothing moves. Delete enableDismissFromStartToEnd = false and Run again — the card drags both ways, but a right swipe still springs back, because confirmValueChange is the gate. Put the line back.
  12. Change vm.restore(note) to vm.save(note.id, note.title, note.body) and Run. Delete a middle note, tap Undo, and watch it come back at the top of the list with a "Just now" timestamp. That is Error Doctor entry 5. Change it back.

Pocket Notes — end of chapter 5

A complete project. Unzip it, open it in Pocket Studio, and press Run.

Download ZIP
Error Doctor5 common errors
e: file:///.../SwipeToDelete.kt:32:17 This material API is experimental and is likely to change or to be removed in the future.
MeansSwipeToDismissBox, SwipeToDismissBoxValue and rememberSwipeToDismissBoxState are all still marked experimental in Material 3 1.3. Compose will not let you use them without acknowledging that.
Fix@OptIn(ExperimentalMaterial3Api::class) on the line above @Composable fun SwipeToDelete. One annotation covers every usage in the function.
e: file:///.../SwipeToDelete.kt:32:17 Unresolved reference 'rememberSwipeToDismissBoxState'.
MeansImporting SwipeToDismissBox does not bring its companion remember function with it. Every top-level function needs its own import — they are separate names in the same package, not members of a class.
FixImport it by name, or import the package as this file does: import androidx.compose.material3.*. Here the star import is not laziness — the explicit line is 64 characters, which breaks the 60-character house rule.
e: file:///.../NoteListScreen.kt:130:37 Unresolved reference 'animateItem'.
MeansanimateItem() is an extension on LazyItemScope. That scope only exists inside the lambda you pass to items { } — anywhere else in the file, including on the LazyColumn itself, the name does not exist.
FixPut it on the top-level composable inside the item block: SwipeToDelete(onDelete = ..., modifier = Modifier.animateItem()). If you apply it to NoteCard instead, the swipe wrapper moves and the card animates inside it, which is not what you want.
Deleting one row makes the wrong card vanish, or the whole list flickers — no error message
MeansThere is no key on items, so Compose identifies rows by position. Delete row 2 and every row below it changes identity, so Compose believes four rows changed contents rather than one row leaving.
Fixkey = { note -> note.id }. This has been in the project since chapter 3 for exactly this moment — if you removed it while tidying, this is what it looked like.
Undo brings the note back at the top of the list, with today's timestamp — no error message
MeansYour restore path rewrote updatedAt to now. The DAO sorts with ORDER BY updatedAt DESC, so a fresh timestamp puts the note first. It looks almost correct, which is why this bug ships.
FixSave the object exactly as it was: fun restore(note: Note) { viewModelScope.launch { repo.save(note) } }. Only the editor's save(id, title, body) should ever set a new time, because only editing is a change.
Recap
  • Delete for real, immediately, and offer a way back. That is an , and it beats "are you sure?" because the common case costs nothing and the message arrives after you can see what happened.
  • A pending delete behind a timer makes the list disagree with the database and does not survive the app being killed. This app does not do it.
  • wraps a row. is the gate: true dismisses, false springs the row back. Comparing against EndToStart means only one direction can ever delete.
  • The reveal panel needs .clip(shape) with the same shape as the card, or its square corners show past the rounded ones.
  • is a function that returns ActionPerformed or Dismissed, so it needs rememberCoroutineScope() and a launch.
  • restore passes the whole original Note, keeping its id and its updatedAt, so it returns to the position it left rather than jumping to the top.
  • only works inside an items { } lambda, and only does anything because the rows have a .
  • Next: the last chapter. A search box that filters as you type, a second empty state for "found nothing", a live word count, and the keyboard finally stops covering what you are writing.