Lists

In this post, I will talk about Lists: how to display a list of items, handle interactions like swipe to dismiss, drag to reorder.

ListView

First I set out to display a hardcoded list. The guide about List View on Android Developers pages shows a long example with asynchronous loading of data, some kind of progress indicator. It uses the concept of Loaders and Cursors. The page on Layout View gives a much more concise synchronous example:

ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
        android.R.layout.simple_list_item_1, myStringArray);
ListView listView = (ListView) findViewById(R.id.listview);
listView.setAdapter(adapter);

However in Kotlin, you don't need to use findViewById and cast! As described here, it looks like this instead:

import kotlinx.android.synthetic.main.activity_main.*
...
val adapter = ArrayAdapter<String>(this,
        android.R.layout.simple_list_item_1, reminders)
listview.setAdapter(adapter)

Handling Interactions

This blog post explains it really well. According to the author, the simplest way to handle interactions like swipe to dismiss or drag to reorder is to use a RecyclerView and its associated ItemTouchHelper. So I replaced my ListView implementation to a RecyclerView.
Btw, I also found this video by a Android Developer advocate from 2013 that explains how to do it by extending ListView. So it is technically doable, but the resulting code is horribly complicated. They have to handle bitmaps manually. Maybe this was the only way to do it prior to the ItemTouchHelper?

From ListView to RecyclerView

I used the documentation on lists and cards. One difference in Kotlin is that instead of using an ArrayList, we can use Kotlin's MutableList interface.

val dataset = mutableListOf("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454", "454")

recyclerview.setHasFixedSize(true)
recyclerview.layoutManager = LinearLayoutManager(this)
recyclerview.adapter = MyAdapter(dataset)

Here's what MyAdapter looks like:

class MyAdapter(private val dataset: MutableList<String>): RecyclerView.Adapter<MyAdapter.ViewHolder>(), ItemTouchHelperAdapter {
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.textView.text = dataset[position]
    }

    override fun getItemCount(): Int {
        return dataset.size
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val textView = LayoutInflater.from(parent.context).inflate(R.layout.simple_list_item_1, parent, false) as TextView
        return ViewHolder(textView)
    }

    class ViewHolder(val textView: TextView) : RecyclerView.ViewHolder(textView) {
    }
}

Instead of using the inflater, we could have used a simple TextView(parent.context), but doing so would not extend it to the full RecyclerView's width. Inflating makes the TextView from simple_list_item_1 expand to the parent's bounds. For this to work correctly, simple_list_item_1 has to have those settings:

android:layout_width="match_parent"
android:layout_height="wrap_content"

Interaction

The blog post gives clear explanation, code snippets and even a screencast of the feature in action. To make it even simpler, I've made a sequence diagram that summarizes what happens:

I used the same interface as him:

interface ItemTouchHelperAdapter {
    fun onItemMove(fromPosition: Int, toPosition: Int)
    fun onItemDismiss(position: Int)
}

My custom ItemTouchHelperCallback passes configures the SimpleCallback and calls the adapter:

private val dragDirs = ItemTouchHelper.UP or ItemTouchHelper.DOWN
private val swipeDirs = ItemTouchHelper.START or ItemTouchHelper.END

class MyCallback(val adapter : ItemTouchHelperAdapter) : ItemTouchHelper.SimpleCallback(dragDirs, swipeDirs) {
    override fun isLongPressDragEnabled(): Boolean {
        return true
    }

    override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
        adapter.onItemMove(viewHolder.adapterPosition, target.adapterPosition)
        return true
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        adapter.onItemDismiss(viewHolder.adapterPosition)
    }
}

Returning true in isLongPressDragEnabled is required. Without it, a drag without a long press results in a scroll. Then I implement ItemTouchHelperAdapter's methods in MyAdapter. When things change, the adapter calls notifyItemMoved/Removed to re-render the view:

override fun onItemMove(fromPosition: Int, toPosition: Int) {
    val fromValue = dataset[fromPosition]
    dataset[fromPosition] = dataset[toPosition]
    dataset[toPosition] = fromValue
    notifyItemMoved(fromPosition, toPosition)
}

override fun onItemDismiss(position: Int) {
    dataset.removeAt(position)
    notifyItemRemoved(position)
}

To finish, I connect the touch helper to the RecyclerView:

// Pre-existing code
val dataset = arrayListOf(...)
...
val adapter = MyAdapter(dataset)
recyclerview.adapter = adapter

// New code
val callback = MyCallback(adapter)
val touchHelper = ItemTouchHelper(callback)
touchHelper.attachToRecyclerView(recyclerview)

Here is how it drag to reorder and swipe to dismiss looks like:

I should also test it heavily to make sure it works even if Firebase calls fail or take a while to save. I will do that another time. Next we want to remind the user to check out their reminders. We will do so with Notifications.