SENG 440: Lecture 9 – RecyclerView
Dear students,
On Friday afternoon, I left my office to go pick up my children from school. They went into lockdown right as I arrived. We weren’t sure what to do, so most of us milled about awkwardly for three hours. It didn’t seem like a good idea to congregrate outside the school gates, but being alone and away from our children didn’t seem like a good idea either, so we stayed. I came away from that waiting with a couple of observations:
- Several times I heard people say, “What are we? America?” or “What’s happening to Christchurch?” We can’t measure Christchurch or any place by its absence of evil, but by its response to evil. From what I have seen in the community and from your government, this place is most certainly not the America I come from. America is where your uncle says, “If there were more guns, that never would have happened.” And he says this out loud in a diner, and a bunch of people at the other tables nod in agreement.
- Most of us stood around constantly checking our phones for news. On the one hand, I am thankful for these devices, the subject of this class. They help us navigate emergencies. On the other hand, I am sickened when I think about how a device like this, a GoPro camera, and Facebook Live fed into this killer’s depravity. If you find yourself providing a social network to your users, please don’t take a hands-off approach to content curation. Deplatform evil. It works.
Last time we started working on Lately, an app for showing the latest headlines from a news source. Today we show take the data that we fetched from the News API web service and show it in a list. Helping us today is Android’s RecyclerView
. We’ll continue this example in the next lecture, where we’ll use fragments to show multiple “screens” at a time.
There’s no TODO list for next time.
Let’s continue making our app.
RecyclerView
We’ve seen one way of showing a list: create a ListActivity
and wire up an adapter that will create a view for each item in a collection. The problem with this solution is that the list fills the entire view. If we want to show other widgets, we need to something lighter weight. Android historically offered ListView
as a widget that could be embedded inside an arbitrary layout. It was very easy to use and handled click events out of the box. But it has been classified as legacy.
These days we are directed to RecyclerView
, which is more flexible, promotes better design, and turns more of the burden back on the developer. (This is a common evolution of technologies: 1) exist as an ad hoc solution cobbled together by a lonely programmer, 2) congeal into a helpful but monolithic abstraction, 3) decouple into a comprehensive framework that barely lifts a finger to help you get the job done.)
Why is it called RecyclerView
? We use it to show views for a collection. If that collection is a list of Bob Dylan albums, it will have to be very long—with at least 38 items for his studio albums. Populating such a view hierarchy would be computationally expensive, and maintaining it would consume a lot of memory, especially if we included album images. A smarter solution would be to keep just enough views around to fill the list’s visible space. When the user scrolls and a view moves off screen, it is recycled (reused, actually) to show any new items that appear.
There are three major steps to display a custom list:
- Create a view holder that models an instantiated view.
- Create a custom adapter that wraps around a collection and knows how to produce a view for an item, and define methods for creating a new view holder and populating an existing view holder with an item’s data.
- Set up the
RecyclerView
to use the adapter, and give it a layout manager that knows how to arrange the views in a list or grid.
Let’s complete these one by one.
HeadlineViewHolder
We need a layout for each item in the list. Let’s create our own this time, rather than use one of the builtin layouts. Even though we’re only going to show the article’s headline, some of you will be showing images, checkboxes, and so on, and you will need a custom layout. Here’s the XML:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/headlineText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Headline"
android:textSize="24sp" />
</LinearLayout>
Our holder will simply grab a reference to all of the widgets that will need to get touched when we display an item:
class HeadlineViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val headlineText: TextView = view.findViewById(R.id.headlineText)
}
With Activity
, we couldn’t call findViewById
until onCreate
. But it’s our adapter that will be making instances of these, so it’s safe to call this method in the constructor.
HeadlinesAdapter
Our wrapper for the list of headlines extends RecyclerView
adapter and must implement three methods: getItemCount
to report the collection size, onCreateViewHolder
to create a new view holder, and onBindViewHolder
to show element i
in an existing holder. Our adapter looks like this:
class HeadlineAdapter(val context: Context,
val headlines: List<Headline>): RecyclerView.Adapter<HeadlineViewHolder>() {
override fun getItemCount(): Int = headlines.size
override fun onCreateViewHolder(parent: ViewGroup, p1: Int): HeadlineViewHolder {
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(R.layout.headline_item, parent, false)
val holder = HeadlineViewHolder(view)
return holder
}
override fun onBindViewHolder(holder: HeadlineViewHolder, i: Int) {
holder.headlineText.text = headlines[i].text
}
}
RecyclerView
will do the work of figuring out when these methods should be called. In general, onCreateViewHolder
will get called a few times when the list first appears, but then only onBindViewHolder
will get called as views are recycled.
RecyclerView
Let’s make our UI with RecyclerView
for the list of headlines. We’ll also include a Spinner
for choosing the news source, but we’ll get to that later today. Here’s the XML:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Spinner
android:id="@+id/sourcesPicker"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<android.support.v7.widget.RecyclerView
android:id="@+id/headlinesPicker"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/sourcesPicker" />
</android.support.constraint.ConstraintLayout>
We must give this RecyclerView
a layout manager in our onCreate
method. We’ve got LinearLayoutManager
and GridLayoutManager
to choose from. Let’s go with LinearLayoutManager
:
class MainActivity : Activity() {
private lateinit var sourcesPicker: Spinner
private lateinit var headlinesPicker: RecyclerView
override fun onCreate(savedInstanceState: Bundle?) {
...
headlinesPicker = findViewById(R.id.headlinesPicker)
val layoutManager = LinearLayoutManager(this)
headlinesPicker.layoutManager = layoutManager
}
}
When our background task finishes, we want to set up this list’s adapter. Let’s create a property for our headlines in our main activity:
var headlines: List<Headline> = listOf()
set(value) {
field = value
headlinesPicker.adapter = HeadlineAdapter(this, field)
}
And then we can set this property in onPostExecute
in our AsyncTask
:
override fun onPostExecute(headlines: List<Headline>) {
super.onPostExecute(headlines)
context.get()?.headlines = headlines
}
It should populate our list! But it’s not very user friendly. For one, there’s no separation. We can fix that with a divider:
val decoration = DividerItemDecoration(this, layoutManager.orientation)
headlinesPicker.addItemDecoration(decoration)
For two, it doesn’t handle click events. Let’s fix that in the adapter by triggering a callback that accepts a Headline
. We accept this callback in the constructor, and register it in onCreateViewHolder
:
class HeadlineAdapter(val context: Context,
val headlines: List<Headline>,
val clickListener: (Headline) -> Unit): RecyclerView.Adapter<HeadlineViewHolder>() {
...
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeadlineViewHolder {
...
view.setOnClickListener {
clickListener(headlines[holder.adapterPosition])
}
...
}
}
If you look on StackOverflow for adapter examples, you may found folks binding callbacks on onBindViewHolder
and indexing into their collection with i
in their lambdas. This isn’t a good idea. If an item gets removed, the view will move to a different position in the list, but onBindViewHolder
will not get called again on the views that simply move up to fill the void. The lambdas will have closed over the wrong index. Instead, query for the holder’s index dynamically with adapterPosition
.
Furthermore, consider binding callbacks just once per view in onCreateViewHolder
. For many situations, the behavior is derived entirely from the index, but this can be determined entirely within the callback.
Back in the MainActivity
, we send along our callback that triggers an ACTION_VIEW
intent:
var headlines: List<Headline> = listOf()
set(value) {
field = value
headlinesPicker.adapter = HeadlineAdapter(this, field) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.url))
startActivity(intent)
}
}
Next lecture we’ll show the article within a view right next to our list.
Highlighting Selection
There’s a slight usability issue with our list. We can’t tell what we just clicked on. It’d be useful to highlight the active headline. To accomplish this, we’ll need to track which index is selected and apply a background color accordingly. Let’s first add a property to our view holder:
class HeadlineViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val headlineText: TextView = view.findViewById(R.id.headlineText)
var isActive: Boolean = false
set(value) {
field = value
itemView.setBackgroundColor(if (field) Color.LTGRAY else Color.TRANSPARENT)
}
}
Then we update this property in onBindViewHolder
in our adapter:
private var selectedIndex = RecyclerView.NO_POSITION
...
override fun onBindViewHolder(holder: HeadlineViewHolder, i: Int) {
holder.headlineText.text = headlines[i].text
holder.isActive = selectedIndex == i
}
How then do we update the selected index? In our lambda in onCreateViewHolder
, like this:
view.setOnClickListener {
clickListener(headlines[holder.adapterPosition])
selectedIndex = holder.adapterPosition
}
But the article’s not highlighted. Clicking on an item doesn’t cause onBindViewHolder
to get called. We trigger a fake update event to force that:
view.setOnClickListener {
clickListener(headlines[holder.adapterPosition])
selectedIndex = holder.adapterPosition
notifyItemChanged(selectedIndex)
}
We see our highlight! But clicking on a second item doesn’t clear the first. We must also deactivate the previously selected item:
view.setOnClickListener {
clickListener(headlines[holder.adapterPosition])
val oldSelectedIndex = selectedIndex
selectedIndex = holder.adapterPosition
notifyItemChanged(selectedIndex)
notifyItemChanged(oldSelectedIndex)
}
Grid Layout
Let’s see how easy it easy to switch to a grid layout. All we need to is switch the layout manager, and maybe add more dividers:
headlinesPicker.layoutManager = GridLayoutManager(this, 2)
headlinesPicker.addItemDecoration(DividerItemDecoration(this, GridLayoutManager.HORIZONTAL))
headlinesPicker.addItemDecoration(DividerItemDecoration(this, GridLayoutManager.VERTICAL))
There we go!
Source Spinner
If we have time, we’ll add a spinner to allow the user to pick the news source. We model each Source
as a name and unique identifier:
data class Source(val id: String, val name: String) {
override fun toString() = name
}
Then, instead of immediately fetching the headlines from the BBC at the end of onCreate
, we fetch a list of the sources. Grabbing a list of sources is a lot like grabbing a list of headlines. The URL endpoint is different, and we add a language
parameter:
val parameters = mapOf("language" to "en", "apiKey" to KEY)
val url = parameterizeUrl("https://newsapi.org/v2/sources", parameters)
SourcesDownloader(this).execute(url)
We only grab the headlines once a source has been selected from the spinner. We register this listener onCreate
to do that:
sourcesPicker.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(p0: AdapterView<*>?) {
}
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, i: Int, p3: Long) {
val parameters = mapOf("sources" to sources[i].id, "apiKey" to KEY)
val url = parameterizeUrl("https://newsapi.org/v2/top-headlines", parameters)
HeadlinesDownloader(this@MainActivity).execute(url)
}
}
The AsyncTask
for the sources follows the exact same pattern as the one for headlines:
class SourcesDownloader(activity: MainActivity) : AsyncTask<URL, Void, List<Source>>() {
private val context = WeakReference(activity)
override fun doInBackground(vararg urls: URL): List<Source> {
val result = getJson(urls[0])
val sourcesJson = result.getJSONArray("sources")
val sources = (0 until sourcesJson.length()).map { i ->
val source = sourcesJson.getJSONObject(i)
Source(source.getString("id"), source.getString("name"))
}
return sources
}
override fun onPostExecute(sources: List<Source>) {
super.onPostExecute(sources)
context.get()?.sources = sources
}
}
The last step is to add a property to MainActivity
for the sources that sets up the spinner’s adapter:
var sources: List<Source> = listOf()
set(value) {
field = value
sourcesPicker.adapter = ArrayAdapter<Source>(this, android.R.layout.simple_spinner_dropdown_item, field)
}
That’s enough for today. See you next time!
P.S. It’s time for a haiku!
Trees fall in the woods
Because you are not looking
They need time forest