Dagger2 / Modules / Muselee

Muselee 8: Top Artists UI

Muselee is a demo app which allows the user to browse popular music artists. It is not intended to be a fully-featured user app, but a vehicle to explore good app architecture, how to implement current best-practice, and explore how the two often go hand in hand. Moreover it will be used to explore how implementing some specific patterns can help to keep our app both maintainable, and easy to extend.

Previously we looked at the Networking side of the feature module to display a list of top artists from last.fm. So we have a mechanism for obtaining this data, but no way of actually displaying it. So now we’ll turn our attention to the UI side of things.

For the UI we’re going to use a very simple form of the MVVM (Model -View-ViewModel) pattern using the Jetpack Lifecycle components to provide the ViewModel and use LiveData to trigger updates to our View (in this case the TopArtistsFragment). The Model is the Artist and TopArtistsState entities that we defined previously as part of our internal domain model. Note how the work that we did previously means that we have no knowledge here that the data is coming from last.fm – we are completely abstracted from those implementation details of the network components.

In the MVVM pattern, the ViewModel is responsible for interacting with the Model and communicating changes in the view state of the to the View itself. While it would be easy enough to pass the TopArtistsState directly to the View it provides us with a better abstraction to define a separate view state in order to completely separate the View from the Model. So we’ll first define this:

sealed class TopArtistsViewState {

    object InProgress : TopArtistsViewState()

    class ShowTopArtists(val topArtists: List) : TopArtistsViewState()

    class ShowError(val message: String) : TopArtistsViewState()
}

While this is pretty analogous to the states represented in TopArtistsState the naming makes this representative of what the appearance of the View should be and therefore makes the View itself agnostic of the loading states of the data (which is what TopArtistsState represents). The ViewModel performs the necessary conversion:

class TopArtistsViewModel @Inject constructor(
    private val topArtistsProvider: DataProvider
) : ViewModel() {

    private val mutableLiveData: MutableLiveData = MutableLiveData()
    val topArtistsViewState: LiveData
        get() = mutableLiveData

    init {
        load()
    }

    fun load() {
        topArtistsProvider.requestData { artistsState ->
            mutableLiveData.value = when (artistsState) {
                TopArtistsState.Loading -> TopArtistsViewState.InProgress
                is TopArtistsState.Error -> TopArtistsViewState.ShowError(artistsState.message)
                is TopArtistsState.Success -> TopArtistsViewState.ShowTopArtists(artistsState.artists)
            }
        }
    }
}

TopArtistsViewModel is injected with a DataProvider<TopArtistsState> instance and this is the DataProvider interface that we looked at previously. This keeps the ViewModel completely agnostic of how the data is obtained, it simply relies on the DataProvider to provide the required data. So the DataProvider is actually the interface to the Model.

Internally it uses a MutableLiveData<TopArtistsViewState> to represent the current state of the view. It exposes this as LiveData<TopArtistsViewState> via the topArtistsViewState which enables consumers to subscribe to the LiveData but the value can only be updated by the ViewModel itself.

The load() method is the main workhorse. It will asynchronously request data from the DataProvider, and then update the MutableLiveData<TopArtistsViewState> once the callback lambda is invoked. This will automatically trigger updates in the View which is subscribed to topArtistsViewState.

In the init constructor we invoke load() to begin fetching data immediately. So any consumers do not need to manually invoke load() to obtain initial data.

Being a ViewModel from the Jetpack Architecture components, we’ll get all of the lifecycle subscribe / unsubscribe automatically, and we’ll also handle device rotations gracefully as a result.

The data will be displayed in a RecyclerView and each item within the list will contain the rank (i.e. the most popular artist will have a rank of 1), the name of the artist, and an image of the artist. This gets represented through a simple `ViewHolder`:

class TopArtistsViewHolder(
    item: View,
    private val rankView: TextView = item.findViewById(R.id.rank),
    private val imageView: ImageView = item.findViewById(R.id.image),
    private val nameView: TextView = item.findViewById(R.id.name)
) : RecyclerView.ViewHolder(item) {

    fun bind(rank: String, artistName: String, artistImageUrl: String) {
        rankView.text = rank
        nameView.text = artistName
        Glide.with(imageView)
            .load(artistImageUrl)
            .transition(withCrossFade())
            .into(imageView)
    }
}

This is about as simple as it gets: We set the rank and artist name, and then use Glide to load the image. This is used by the Adapter to create and bind the individual list items:

class TopArtistsAdapter(
    private val items: MutableList = mutableListOf()
) : RecyclerView.Adapter() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TopArtistsViewHolder =
            TopArtistsViewHolder(
                    LayoutInflater.from(parent.context)
                            .inflate(R.layout.item_top_artist, parent, false)
            )

    override fun getItemCount(): Int = items.size

    override fun onBindViewHolder(holder: TopArtistsViewHolder, position: Int) {
        items[position].also { artist ->
            holder.bind(
                rank = (position + 1).toString(),
                artistName = artist.name,
                artistImageUrl = artist.images[ImageSize.MEDIUM] ?: artist.images.values.first()
            )
        }
    }

    fun replace(artists: List) {
        val difference = DiffUtil.calculateDiff(TopArtistsDiffUtil(items, artists))
        items.clear()
        items += artists
        difference.dispatchUpdatesTo(this)
    }

    private class TopArtistsDiffUtil(
        private val oldList: List,
        private val newList: List
    ) : DiffUtil.Callback() {

        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
            oldList[oldItemPosition] === newList[newItemPosition]

        override fun getOldListSize(): Int = oldList.size

        override fun getNewListSize(): Int = newList.size

        override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
            oldList[oldItemPosition].name == newList[newItemPosition].name
    }
}

Once again, this is pretty standard stuff, so doesn’t warrant too much explanation. It inflates the layouts for each item, and performs the necessary binding to the TopArtistsViewHolder. Finally we use DiffUtil to correctly manage changes to the list items. This does not have much effect in the initial implementation of this feature module because we will not be dynamically updating the list. However, it is a good practice to get in to because we’ll get some nice behaviours for minimal effort. More information about this can be found here.

This all gets bound to the ViewModel by the Fragment:

class TopArtistsFragment : DaggerFragment() {

    @Inject
    lateinit var viewModelFactory: ViewModelProvider.Factory

    private lateinit var topArtistsViewModel: TopArtistsViewModel
    private lateinit var topArtistsAdapter: TopArtistsAdapter

    private lateinit var topArtistsRecyclerView: RecyclerView
    private lateinit var retryButton: Button
    private lateinit var progress: ProgressBar
    private lateinit var errorMessage: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        topArtistsViewModel = ViewModelProviders.of(this, viewModelFactory)
            .get(TopArtistsViewModel::class.java)
        topArtistsAdapter = TopArtistsAdapter()
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
        inflater.inflate(R.layout.fragment_top_artists, container, false).also { view ->
            topArtistsRecyclerView = view.findViewById(R.id.top_artists)
            retryButton = view.findViewById(R.id.retry)
            progress = view.findViewById(R.id.progress)
            errorMessage = view.findViewById(R.id.error_message)
            topArtistsRecyclerView.apply {
                adapter = topArtistsAdapter
                layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
            }
            retryButton.setOnClickListener {
                topArtistsViewModel.load()
            }
            topArtistsViewModel.topArtistsViewState.observe(this, Observer { newState -> viewStateChanged(newState) })
        }

    private fun viewStateChanged(topArtistsViewState: TopArtistsViewState) {
        when (topArtistsViewState) {
            is TopArtistsViewState.InProgress -> setLoading()
            is TopArtistsViewState.ShowError -> setError(topArtistsViewState.message)
            is TopArtistsViewState.ShowTopArtists -> updateTopArtists(topArtistsViewState.topArtists)
        }
    }

    private fun setLoading() {
        progress.visibility = View.VISIBLE
        errorMessage.visibility = View.GONE
        retryButton.visibility = View.GONE
        topArtistsRecyclerView.visibility = View.GONE
    }

    private fun setError(message: String) {
        progress.visibility = View.GONE
        errorMessage.visibility = View.VISIBLE
        errorMessage.text = message
        retryButton.visibility = View.VISIBLE
        topArtistsRecyclerView.visibility = View.GONE
    }

    private fun updateTopArtists(topArtists: List) {
        progress.visibility = View.GONE
        errorMessage.visibility = View.GONE
        retryButton.visibility = View.GONE
        topArtistsRecyclerView.visibility = View.VISIBLE
        topArtistsAdapter.replace(topArtists)
    }
}

We get the ViewModelFactory that we defined in the Core module injected, and in onCreate() we use this to obtain an instance of TopArtistsViewModel as well as creating the TopArtistsAdapter instance for the RecyclerView.

In onCreateView() we inflate the view, perform view lookups, then assign the adapter and layout manager to the RecyclerView, add a click listener to the retry button (which will be displayed in the event of an error, and then begin observing the LiveData in our TopArtistsViewModel.

The remainder of the class is implementing the various view states that get applied from viewStateChanged() which is what will be called when the LiveData changes. The individual methods for doing this are pretty self-explanatory – they change the visibility and contents of the Views within the layout depending on the view state. For example, in updateTopArtists(), we hide the loading ProgressBar, error message TextView, and retry Button. Then we show the RecyclerView and update the list of artists in the Adapter to display the current set of top artists.

What is useful here is that there is very little logic within the Fragment other than to determine what the current view state is whenever it changes.

The only thing that is remaining for us to get this working is to add some Dagger 2 bindings for these components:

@Module(
    includes = [
        NetworkModule::class,
        BaseViewModule::class,
        LastFmTopArtistsModule::class
    ]
)
@Suppress("unused")
abstract class TopArtistsModule {

    @ContributesAndroidInjector
    abstract fun bindTopArtistsFragment(): TopArtistsFragment

    @Binds
    @IntoMap
    @ViewModelKey(TopArtistsViewModel::class)
    abstract fun bindChartsViewModel(viewModel: TopArtistsViewModel): ViewModel
}

Here we include three other modules that we defined earlier which provide all of the other dependencies that we need. bindTopArtistsFragment() enables TopArtistsFragment to be injected with the necessary dependencies, and bindChartsViewModel() makes TopArtistsViewModel available to the ViewModelFactory that we created in the Core module, and when we pass that to the ViewModelProviders.Of() factory in TopArtistsFragment, ViewModelFactory is able to construct an instance of TopArtistsViewModel thanks to this entry.

We now have something working:

So we now have a working feature module, and we have looked at how the components that we’ve added fit within the MVVM pattern. However, there is something more going on here which we’ll explore in the next article.

The source code for this article is available here.

© 2019, Mark Allison. All rights reserved.

Copyright © 2019 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.