TODO app — Delete multiple items with undo timer.

Akshay Kale
4 min readApr 20, 2021

We will work on the same android architecture component app. You can get the code from here. https://github.com/android/architecture-samples

As of now, the app is able to record todos and mark them as complete. We will add one more piece of functionality, deletion. But not just any delete feature, we want users to have some time to undo their request before we delete it.

We will add the following features to the application.

  • User can can inline delete a task.
  • When the user clicks the delete button, they should see a countdown on the item from 3 to 0, along with an inline undo option.
  • Hitting the undo option should cancel the timer and leave the item as is.
  • If the timer reaches 0, the item will then be deleted.

We will use android’s Countdown Timer for this. We need to create a timer instance in the view holder and manipulate it based on the delete state of the task.

var timer: CountDownTimer? = null        
var isTicking: Boolean = false

The view holder class will look like this

class ViewHolder private constructor(val binding: TaskItemBinding) :
RecyclerView.ViewHolder(binding.root) {
var timer: CountDownTimer? = null
var isTicking: Boolean = false
fun bind(viewModel: TasksViewModel, item: Task) { binding.viewmodel = viewModel
binding.task = item
binding.executePendingBindings()
}
companion object {
fun from(parent: ViewGroup): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = TaskItemBinding.inflate(layoutInflater, parent, false)
return ViewHolder(binding)
}
}
}

Let’s edit the layout file. We need to add the delete button on each row and also an undo button and some way to display the timer.

We will use progress bar to display the 3 seconds timer and a normal Button for the undo button.

Instead of a delete button in each row we can also add swipe the row to delete functionality. But that’s not the part of this post. It can certainly be easy to implement with this code.

The layout for task_item.xml will be changed like this.

<?xml version="1.0" encoding="utf-8"?>

<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tool="http://schemas.android.com/tools">

<data>

<import type="android.widget.CompoundButton" />

<variable
name="task"
type="com.example.android.architecture.blueprints.todoapp.data.Task" />

<variable
name="viewmodel"
type="com.example.android.architecture.blueprints.todoapp.tasks.TasksViewModel" />
</data>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="?android:attr/listPreferredItemHeight"
android:onClick="@{() -> viewmodel.openTask(task.id)}"
android:orientation="horizontal"
android:paddingBottom="@dimen/list_item_padding"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/list_item_padding">

<CheckBox
android:id="@+id/complete_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:checked="@{task.completed}"
android:onClick="@{(view) -> viewmodel.completeTask(task, ((CompoundButton)view).isChecked())}" />

<TextView
android:id="@+id/title_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:layout_marginLeft="@dimen/activity_horizontal_margin"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_weight="1"
android:text="@{task.titleForList}"
android:textAppearance="@style/TextAppearance.AppCompat.Title"
app:completedTask="@{task.completed}" />

<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
>

<ImageView
android:id="@+id/deleteBt"
android:layout_width="30dp"
android:layout_height="30dp"
android:tint="@android:color/holo_red_light"
android:src="@drawable/ic_delete_black_24dp"
/>

<ProgressBar
android:id="@+id/progressBar"
android:layout_width="30dp"
android:layout_height="30dp"
android:indeterminate="false"
android:progressDrawable="@drawable/circular_progress_bar"
android:background="@drawable/circle_shape"
style="?android:attr/progressBarStyleHorizontal"
android:max="100"
android:visibility="gone"
android:progress="0" />

</FrameLayout>
</LinearLayout>

<Button
android:id="@+id/undoButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_gravity="end"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:text="Undo"
android:layout_marginRight="@dimen/activity_horizontal_margin" />
</LinearLayout>
</layout>

To make the progress bar circular with progress we need to add progress drawable.

create file drawable/circular_progress_bar.xml

<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:innerRadiusRatio="2.5"
android:shape="ring"
android:thickness="3dp"
android:useLevel="true">

<gradient
android:angle="0"
android:endColor="#007DD6"
android:startColor="#007DD6"
android:type="sweep"
android:useLevel="false" />
</shape>
</rotate>

And a circle shape drawable/circle_shape.xml as a background of the progress bar.

<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="ring"
android:innerRadiusRatio="2.5"
android:thickness="3.5dp"
android:useLevel="false">

<solid android:color="#CCC" />

</shape>

Add the static const for the timer duration.

companion object {
const val UNDO_TIMER: Long = 3000
}

Now, back to the Recycler view adapter. Let’s add handle the delete button click and the undo action.

The onBindHolder(…) method will be like this

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)

holder.apply {

if (timer != null) {
timer?.cancel()
}

timer = object : CountDownTimer(TasksFragment.UNDO_TIMER, 100) {
override fun onTick(millisUntilFinished: Long) {
val per = (millisUntilFinished.toFloat().div(TasksFragment.UNDO_TIMER.toFloat())).times(100)
binding.progressBar.progress = (100 - per).toInt()
}

override fun onFinish() {
if (!isTicking) return //its not ticking (may be undo has been pressed)
processDelete(item, position)

}
}

binding.deleteBt.setOnClickListener {
isTicking = true // start the timer
timer?.start()
viewStateDeleting()
}

binding.undoButton.setOnClickListener {
if (isTicking) {
isTicking = false
timer?.cancel()
}
viewStateReadyToDelete()
}

bind(viewModel, item)
}
}

We will need to add the isTicking boolean variable because calling timer.cancel() won’t prevent a onFinish() call on timer finished. Event after calling the cancel function the timer will still go in the onFinish() block. To prevent that we will add the isTicking variable.

Let’s also add some helper extension functions to show or hide views based on the state.

private fun ViewHolder.viewStateDeleting() {
binding.deleteBt.gone()
binding.progressBar.visible()
binding.undoButton.visible()
}
private fun ViewHolder.viewStateReadyToDelete() {
binding.undoButton.gone()
binding.deleteBt.visible()
binding.progressBar.gone()
}
private fun ViewHolder.processDelete(item: Task, position: Int) {
viewModel.deleteTask(item)
viewStateReadyToDelete()
isTicking = false
}
private fun View.visible() {
visibility = View.VISIBLE
}

private fun View.gone() {
visibility = View.GONE
}

And finally add the function in view model which will actually delete the task.

fun deleteTask(task: Task) = viewModelScope.launch {
tasksRepository.deleteTask(task.id)
}

The final app should look like this:

Demo of the application.

--

--