MVC/MVP/MVVM, we’re all talking about it. It’s modern, it’s cool, but besides that it’s useful. We’ll see if the MVP is dead, now that Google announced its own archtecture components, however, that’s something to deal with later. Currently MVP with Clean Architecture is doing a great job for us. And most importantly it is so easy to implement and understand. Sometimes, implementation starts to be chaotic and/or confusing and RecyclerView is such example.
Usually, when implementing RecyclerView
we’ve used to send list of some objects to its adapter, to create some ViewHolders and bind the data to it. If we’re good citizens, we’ll at least move ViewHolder
logic to its own class, instead of referencing it inside onBindViewHolder
method.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class TestAdapter(val context: Context) : RecyclerView.Adapter<TestViewHolder>(){
private var testItemsList = mutableListOf<TestItem>()
fun onDataChange(testItems: List<TestItem>){
testItems.forEach {
if(!testItemsList.contains(it)){
testItemsList.add(it)
}
}
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): TestViewHolder =
TestViewHolder(LayoutInflater.fromContext(context).inflate(R.layout.test_list_item, parent, false))
override fun onBindViewHolder(holder: TestViewHolder?, position: Int) {
var testItem = testItemsList[position]
holder?.title.text = testItem.title
holder?.description.text = testItem.description
}
override fun getItemCount = testItemsList.size()
}
and usage of this adapter in activity or fragment would look like this:
1
2
3
4
5
override fun onDataLoaded(testItems: List<TestItem>){
var testAdapter = TestAdapter(context)
recyclerView.adapter = testAdapter
testAdapter.onDataChange(testItems)
}
It’s simple, it really is. I haven’t shown the TestViewHolder
implementation, but it’s really just initialization of TextView’s. However, if we wan’t to write a unit test for adapter above, we’ll be in trouble. There is a android Context
in the constructor, we have LayoutInflater
when creating the ViewHolder and we have to mock all of it just to check if the list is loading.
We won’t, so we’ll write a presenter
for it. Adapter presenter should be:
- keeping the list of items
- providing an item per index/position
- providing the items count
- notifying adapter when the change occur.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class TestAdapterPresenter {
var view: View? = null
private var testItems = mutableListOf<TestItem>()
fun onDataChange(testItems: List<TestItem>) {
testItems.forEach {
if(!testItemsList.contains(it)){
testItemsList.add(it)
}
}
view?.notifyAdapter()
}
fun getCount(): Int = testItems.size
fun getItemAt(position: Int) = testItems[position]
infix fun itemAtPosition(position: Int): TestItem = testItems[position]
interface View {
fun notifyAdapter()
}
}
and we’ll change the adapter according to these changes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TestAdapter(val context: Context) : RecyclerView.Adapter<TestViewHolder>(), TestAdapterPresenter.View{
var testAdapterPresenter = TestAdapterPresenter()
init {
testAdapterPresenter.view = this
}
override fun notifyAdapter() {
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): TestViewHolder =
TestViewHolder(LayoutInflater.fromContext(context).inflate(R.layout.test_list_item, parent, false))
override fun onBindViewHolder(holder: TestViewHolder?, position: Int) {
var testItem = testItemsList[position]
holder?.title.text = testItem.title
holder?.description.text = testItem.description
}
override fun getItemsCount() = testAdapterPresenter.getCount()
}
In this case, adapter initialization is mostly unchanged. Except, we won’t be passing the list items to it, but to its presenter.
1
2
3
4
5
override fun onDataLoaded(testItems: List<TestItem>){
var testAdapter = TestAdapter(context)
recyclerView.adapter = testAdapter
testAdapter.testAdapterPresenter.onDataChange(testItems)
}
We have created a View
interface which TestAdapter
implemented. TestAdapterPresenter
does not know to whom is he talking to, when calling notifyAdapter()
, which is very important as we can mock it easily and test its behavior when list is received and data passed from it.
So we improved the testability of the app. But it can be better, right? If we want to test the behavior of the TestViewHolder
, our hands are tight and we have to do some changes in order to help ourself. We’ll be still following the MVP pattern and create a presenter for the TestViewHolder
. Also, we’ll move the title
and description
related calls to ViewHolder as well.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class TestViewHolderPresenter {
var testAdapterPresenter: TestAdapterPresenter? = null
var position: Int? = null
var view: View? = null
fun bind(){
if(position != null && testAdapterPresenter != null){
var testItem = testAdapterPresenter.getItemAt(position)
view?.setTitle(testItem.title)
view?.setDescription(testItem.description)
}
}
interface View {
fun setTitle(title: String)
fun setDescription(description: String)
}
}
As each ViewHolder is bound to some position in the list, it is perfectly natural to set that position
in the ViewHolder presenter. Also, in order to enable the communication between the Adapter presenter and ViewHolder presenters, each ViewHolder presenter needs to have the reference to Adapter presenter as shown above with testAdapterPresenter
variable.
Below, TestViewHolder
implements TestViewHolderPresenter.View
and it’s functions to set the title
and description
values.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TestViewHolder(val view: View) : RecyclerView.ViewHolder(view), TestViewHolderPresenter.View {
var testViewHolderPresenter = TestViewHolderPresenter()
@BindView(R.id.test_list_item_tv_title) lateinit var tvTitle: TextView
@BindView(R.id.test_list_item_tv_description) lateinit var tvDescription: TextView
init {
ButterKnife.bind(this, view)
testViewHolderPresenter.view = this
}
override fun setTitle(title: String){
tvTitle.text = title
}
override fun setDescription(description: String){
tvDesription.text = description
}
}
It is obvious that TestViewHolder
really does not know anything about the data, or its position in the adapter. Only thing ViewHolder worries about is to set received values (title
, description
) to its components. Whole logic is moved to its presenter and since we have moved ViewHolder logic to its class and Adapter implementation changes again.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TestAdapter(val context: Context) : RecyclerView.Adapter<TestViewHolder>(), TestAdapterPresenter.View{
var testAdapterPresenter = TestAdapterPresenter()
init {
testAdapterPresenter.view = this
}
override fun notifyAdapter() {
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): TestViewHolder =
TestViewHolder(LayoutInflater.fromContext(context).inflate(R.layout.test_list_item, parent, false))
override fun onBindViewHolder(holder: TestViewHolder?, position: Int) {
holder.testViewHolderPresenter.position = position
holder.testViewHolderPresenter.testAdapterPresenter = testAdapterPresenter
holder.testViewHolderPresenter.bind()
}
override fun getItemsCount() = testAdapterPresenter.getCount()
}
And after these changes, TestAdapter
also just worries about initialization of its presenter, ViewHolder and passing the current position
to it.
To make it easier to understand, here is a simple sequence diagram that shows how these presenters fits into the app.
Problem we haven’t described above (nor implemented) is callback implementation from ViewHolder through the presenter all the way to Fragment or Activity. Kaushik Gopal mentioned this problem when talking about the state of EventBus today, in Eposide 61 of Fragmented Podcast. So he decided that EventBus could find its usage here. However, in Part 2 of RecyclerView with MVP, well be addressing this implementation, without EventBus though.