The task manager works. You can add tasks, complete them, delete them, search, and filter. But it doesn’t feel polished. Screens change instantly. Deleting a task is jarring. There’s no feedback when you complete something.
This tutorial adds the polish that makes the difference between a homework project and a real app.
What We Add
| Feature | What It Does |
|---|---|
| Navigation transitions | Screens slide in/out smoothly |
| Swipe to delete | Swipe a task left to delete it |
| Animated task completion | Checkbox animates, strikethrough fades in |
| Animated list changes | Tasks slide in/out when added or removed |
| Empty state animations | Gentle fade-in when list is empty |
| Dark mode | Follows system theme |
| Snackbar with undo | “Task deleted” with undo option |
Navigation Transitions
By default, screens appear instantly. Add slide transitions:
// ui/navigation/AppNavigation.kt
NavHost(
navController = navController,
startDestination = TaskListRoute,
enterTransition = {
slideInHorizontally(initialOffsetX = { it }) + fadeIn()
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { -it }) + fadeOut()
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { -it }) + fadeIn()
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { it }) + fadeOut()
}
) {
composable<TaskListRoute> { TaskListScreen(...) }
composable<AddTaskRoute> { AddTaskScreen(...) }
composable<SettingsRoute> { SettingsScreen(...) }
}
Now when you navigate forward, the new screen slides in from the right. When you go back, it slides out to the right. Natural and smooth.
Swipe to Delete
Material 3’s SwipeToDismissBox handles the swipe gesture:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeableTaskCard(
task: Task,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
onDelete()
true
} else false
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
// Red background with delete icon — shows when swiping
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.error)
.padding(horizontal = 24.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onError
)
}
},
enableDismissFromStartToEnd = false, // Only swipe left
enableDismissFromEndToStart = true
) {
TaskCard(task = task, onToggle = onToggle, onDelete = onDelete)
}
}
Swipe left → red background appears → release → task deleted.
Animated List Changes
Make tasks animate when they are added, removed, or reordered:
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp)
) {
items(
items = state.tasks,
key = { it.id } // Keys required for animations
) { task ->
SwipeableTaskCard(
task = task,
onToggle = { viewModel.onIntent(TaskListIntent.ToggleTask(task.id)) },
onDelete = { viewModel.onIntent(TaskListIntent.DeleteTask(task.id)) },
modifier = Modifier.animateItem() // Smooth add/remove/reorder
)
}
}
Modifier.animateItem() makes tasks:
- Slide in when added
- Slide out when deleted
- Move smoothly when the list order changes
Combined with swipe-to-delete, this feels great.
Animated Task Completion
When a task is completed, animate the changes:
@Composable
fun AnimatedTaskTitle(title: String, isCompleted: Boolean) {
val textColor by animateColorAsState(
targetValue = if (isCompleted)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.onSurface,
animationSpec = tween(300),
label = "textColor"
)
Text(
text = title,
fontWeight = FontWeight.Medium,
color = textColor,
textDecoration = if (isCompleted)
TextDecoration.LineThrough else TextDecoration.None
)
}
When isCompleted changes, the text color fades smoothly and the strikethrough appears.
Snackbar with Undo
Show a snackbar when a task is deleted, with an “Undo” option:
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
TaskList(
tasks = state.tasks,
onDelete = { task ->
viewModel.onIntent(TaskListIntent.DeleteTask(task.id))
// Show snackbar with undo
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "Task deleted",
actionLabel = "Undo",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
viewModel.onIntent(TaskListIntent.UndoDelete(task))
}
}
},
modifier = Modifier.padding(padding)
)
}
}
For undo to work, the ViewModel needs to save the deleted task temporarily:
// In ViewModel
private var lastDeletedTask: Task? = null
is TaskListIntent.DeleteTask -> {
viewModelScope.launch {
lastDeletedTask = repository.getTaskById(intent.taskId)
deleteTask(intent.taskId)
}
}
is TaskListIntent.UndoDelete -> {
viewModelScope.launch {
intent.task?.let { repository.addTask(it) }
}
}
Dark Mode Support
If you set up theming correctly in Tutorial #7, dark mode already works. The key is using theme colors everywhere:
// GOOD — adapts to dark mode automatically
Surface(color = MaterialTheme.colorScheme.surfaceVariant) { ... }
Text(color = MaterialTheme.colorScheme.onSurface)
// BAD — hardcoded, breaks in dark mode
Surface(color = Color.White) { ... }
Text(color = Color.Black)
To let users toggle dark mode from settings, save the preference to DataStore:
// In SettingsScreen
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("Dark Mode", modifier = Modifier.weight(1f))
Switch(
checked = isDarkMode,
onCheckedChange = { viewModel.toggleDarkMode() }
)
}
// In your root App composable
@Composable
fun TaskManagerApp() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val isDarkMode by settingsViewModel.isDarkMode.collectAsStateWithLifecycle()
TaskManagerTheme(darkTheme = isDarkMode) {
AppNavigation()
}
}
Loading Skeleton
Instead of a spinner, show placeholder content while loading:
@Composable
fun TaskCardSkeleton() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(modifier = Modifier.padding(16.dp)) {
// Fake checkbox
Box(
modifier = Modifier
.size(24.dp)
.background(
MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
RoundedCornerShape(4.dp)
)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Fake title
Box(
modifier = Modifier
.fillMaxWidth(0.7f)
.height(16.dp)
.background(
MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
RoundedCornerShape(4.dp)
)
)
Spacer(modifier = Modifier.height(8.dp))
// Fake subtitle
Box(
modifier = Modifier
.fillMaxWidth(0.4f)
.height(12.dp)
.background(
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
RoundedCornerShape(4.dp)
)
)
}
}
}
}
// Show 5 skeletons while loading
if (state.isLoading) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp)
) {
items(5) { TaskCardSkeleton() }
}
}
Skeletons feel faster than spinners because the user sees the layout immediately.
The Polish Checklist
Before calling your app “done”, check these:
- Navigation transitions (slide, no instant switch)
- Swipe to delete with confirmation
- Animated list item changes
- Loading skeletons (not just a spinner)
- Empty state with helpful message + action button
- Error state with retry button
- Snackbar for destructive actions (with undo)
- Dark mode support
- All colors from MaterialTheme (no hardcoded)
- Edge-to-edge (enableEdgeToEdge)
- Keyboard handling (imePadding)
Source Code
Related Tutorials
- Tutorial #15: Animations — animation APIs we use here
- Tutorial #7: Theming — dark mode setup
- Tutorial #8: Navigation — navigation transitions
- Jetpack Compose Cheat Sheet — quick reference for every component and modifier.
- Full Series: Jetpack Compose Tutorial — all 25 tutorials from zero to publishing.
What’s Next?
In the final tutorial of the series, we will publish the app to Google Play — building a signed APK, creating the Play Store listing, and uploading.
See you there.