GridLayoutManager / Muselee / RecyclerView

Muselee 10: GridLayout

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.

Now that we have the basic TopArtists feature working, and have explored the architecture, but now let’s turn our attention to the UI itself. We display the list of top music artists that we retrieve from last.fm in a simple, vertical RecyclerView. While this is functional, it isn’t very visually exciting, so let’s go about improving that. One problem with the simple list that we have is that the artist image is quite small and difficult to see, and that image would make a good visual focal point. Whereas list items are the width of the display, with a relatively small height, we could make our items square and display them within a grid rather than a simple list. Furthermore, this is effectively a chart of the most popular artists, so it makes sense to give higher prominence to those artists in the higher ranks. So we could make the number 1 artist the full width of the display, the next 10 displayed in rows of two items, and the remainder in rows of three items.

This all sounds pretty complex, but is actually not that difficult thanks to RecyclerView‘s GridLayoutManager. GridLayoutManager has a span count which represents the number of logical items which will appear in each row (assuming a horizontal orientation; or column if the orientation is vertical), and will created a flowed grid of items so that items will be displayed in a horizontal row until the span count is reached, then it will wrap to the next row and start adding items sequentially to that.

So if we were to create a GridLayoutManager thus:

GridLayoutManager(context, 3, RecyclerView.HORIZONTAL, false)

The spanCount of 3 and orientation of RecyclerView.HORIZONTAL means that the items would be laid out in a grid with the first item at the top left of the grid, the second item immediately to the left of it , the third to the left of that, and at the right edge, it would then wrap so that the fourth item would be immediately below the first … and so on. If the total number rows requires more that the height of the RecyclerView then it would be scrollable, much like how LinearLayoutManager works.

To get the dynamic row sizing requires a little more work, but still isn’t that hard. The number of items in each row is either going to be either 1 (for the very first row representing the most popular artist), 2 (for the next 5 rows representing artists ranked 2-11), or 3 (for the remaining artists). The span size for GridLayoutManager needs to be a number which has factors of 1, 2, & 3; and 6 is the smallest number which does so. We encapsulate this logic in a simple calculator class:

internal class GridPositionCalculator : GridLayoutManager.SpanSizeLookup() {

    companion object {

        private val doubleItems: IntRange = (1..10)
        const val fullSpanSize = 6
        private const val doubleSpanCount = 2
        private const val tripleSpanCount = 3
        private const val doubleSpanSize: Int = fullSpanSize / doubleSpanCount
        private const val tripleSpanSize: Int = fullSpanSize / tripleSpanCount
    }

    override fun getSpanSize(position: Int): Int =
        when (position) {
            0 -> fullSpanSize
            in doubleItems -> doubleSpanSize
            else -> tripleSpanSize
        }
}

So the artist in rank 1 will get a spanSize of 6, the artists in ranks 2 to 11 inclusive, will get a spanSize of 3, and all remaining artists will get a span size of 2.

One thing worth noting here is that GridPositionCalculator actually extends an abstract class GridLayoutManager.SpanSizeLookup. This requires us to override a single method named getSpanSize() and you can probably guess that we can use this with GridLayoutManager to dynamically determine the span size for any given item position:

class TopArtistsFragment : DaggerFragment() {

    @Inject
    lateinit var viewModelFactory: ViewModelProvider.Factory

    private lateinit var topArtistsViewModel: TopArtistsViewModel
    private lateinit var topArtistsAdapter: TopArtistsAdapter
    private val calculator = GridPositionCalculator(0)

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

    private val spanCount: Int = GridPositionCalculator.fullSpanSize

    @RecyclerView.Orientation
    private var orientation = RecyclerView.VERTICAL

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

    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)
            orientation = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
                RecyclerView.HORIZONTAL
            else
                RecyclerView.VERTICAL
            topArtistsRecyclerView.apply {
                adapter = topArtistsAdapter
                layoutManager = GridLayoutManager(context, spanCount, orientation, false).apply {
                    spanSizeLookup = calculator
                }
            }
            retryButton.setOnClickListener {
                topArtistsViewModel.load()
            }
            topArtistsViewModel.topArtistsViewState.observe(this, Observer { newState -> viewStateChanged(newState) })
        }
    .
    .
    .
}

However, we need to do a little more than that because, while this handles the dynamic sizing as far as GridLayoutManager is concerned, the individual layouts that we inflate for each different item size will beed to be subtlety different because we’ll need to use different font sizes depending on when the item consumes the entire width, half of it, or one third of it. We can represent these through a simple enum:

internal enum class ViewSize {
    FULL,
    DOUBLE,
    TRIPLE
}

We now need to add some further logic to our calculator to provide the necessary lookup logic:

internal class GridPositionCalculator : GridLayoutManager.SpanSizeLookup() {

    companion object {

        private val doubleItems: IntRange = (1..10)
        const val fullSpanSize = 6
        private const val doubleSpanCount = 2
        private const val tripleSpanCount = 3
        private const val doubleSpanSize: Int = fullSpanSize / doubleSpanCount
        private const val tripleSpanSize: Int = fullSpanSize / tripleSpanCount
    }

    override fun getSpanSize(position: Int): Int =
        when (position) {
            0 -> fullSpanSize
            in doubleItems -> doubleSpanSize
            else -> tripleSpanSize
        }

    fun getViewSize(position: Int): ViewSize =
        when (position) {
            0 -> ViewSize.FULL
            in doubleItems -> ViewSize.DOUBLE
            else -> ViewSize.TRIPLE
        }
}

We can now update our adapter:

internal class TopArtistsAdapter(
    private val calculator: GridPositionCalculator,
    private val items: MutableList = mutableListOf()
) : RecyclerView.Adapter() {

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

    override fun getItemViewType(position: Int): Int =
        calculator.getViewSize(position).ordinal

    private fun getLayoutId(viewType: Int) =
        when(viewType) {
            ViewSize.FULL.ordinal -> R.layout.item_chart_artist_full
            ViewSize.DOUBLE.ordinal -> R.layout.item_chart_artist_medium
            else -> R.layout.item_chart_artist_small
        }

    override fun getItemCount(): Int = items.size

    override fun onBindViewHolder(holder: TopArtistsViewHolder, position: Int) {
        items[position].also { artist ->
            val imageSize: ImageSize = when (calculator.getViewSize(position)) {
                ViewSize.FULL, ViewSize.DOUBLE -> ImageSize.EXTRA_LARGE
                ViewSize.TRIPLE -> ImageSize.LARGE
            }
            holder.bind(
                rank = (position + 1).toString(),
                artistName = artist.name,
                artistImageUrl = artist.images[imageSize] ?: artist.images.values.first()
            )
        }
    }

    fun replace(artists: List) {
        val difference = DiffUtil.calculateDiff(TopArtistsDiffUtil(items, artists))
        items.clear()
        items += artists
        calculator.itemCount = items.size
        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
    }
}

As well as using the calculator to determine which specific layout to use for any given position in the list, we also vary the size of the image that we’ll use. Obviously for a full width layout we need a higher resolution image that for one that is one third the width.

If we run this we can see that the UI is much nicer:

However, when I showed this to Sebastiano Poggi he pointed out that some of the images merge together a bit, and it is sometimes difficult to see where one ends and the next begins. He suggested adding some simple separators. On the face of it this seems really easy, but it becomes a little more complex when we consider that we only want to add separators where the edge of an item touches another item, and not where it touches the edge of the parent. We need to add the logic to determine this to our calculator (which has responsibility for knowing the rules for each item position within the layout).

internal class GridPositionCalculator(var itemCount: Int) : GridLayoutManager.SpanSizeLookup() {

    companion object {

        private val doubleItems: IntRange = (1..10)
        const val fullSpanSize = 6
        private const val doubleSpanCount = 2
        private const val tripleSpanCount = 3
        private const val doubleSpanSize: Int = fullSpanSize / doubleSpanCount
        private const val tripleSpanSize: Int = fullSpanSize / tripleSpanCount
    }

    override fun getSpanSize(position: Int): Int =
        when (position) {
            0 -> fullSpanSize
            in doubleItems -> doubleSpanSize
            else -> tripleSpanSize
        }

    fun getViewSize(position: Int): ViewSize =
        when (position) {
            0 -> ViewSize.FULL
            in doubleItems -> ViewSize.DOUBLE
            else -> ViewSize.TRIPLE
        }

    fun isEndItem(position: Int): Boolean =
        when (position) {
            0 -> true
            in doubleItems -> (position - doubleItems.start).rem(doubleSpanCount) != 0
            else -> (position - doubleItems.last).rem(tripleSpanCount) == 0
        }

    fun isInFinalBank(position: Int): Boolean =
            position >= itemCount - tripleSpanCount
}

We can now create an ItemDecoration to apply offsets in the correct positions based upon these calculations:

internal class TopArtistsItemDecoraton(
    @RecyclerView.Orientation val orientation: Int,
    private val itemSpacing: Int,
    private val calculator: GridPositionCalculator
) : RecyclerView.ItemDecoration() {

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val position = parent.getChildAdapterPosition(view)
        if (orientation == RecyclerView.HORIZONTAL) {
            getHorizontalOffsets(outRect, position)
        } else {
            getVerticalOffsets(outRect, position)
        }
    }

    private fun getHorizontalOffsets(outRect: Rect, position: Int) {
        outRect.bottom = if (calculator.isEndItem(position)) 0 else itemSpacing
        outRect.right = if (calculator.isInFinalBank(position)) 0 else itemSpacing
    }

    private fun getVerticalOffsets(outRect: Rect, position: Int) {
        outRect.right = if (calculator.isEndItem(position)) 0 else itemSpacing
        outRect.bottom = if (calculator.isInFinalBank(position)) 0 else itemSpacing
    }
}

Finally we need to hook this up to the RecyclerView:

class TopArtistsFragment : DaggerFragment() {

    @Inject
    lateinit var viewModelFactory: ViewModelProvider.Factory

    private lateinit var topArtistsViewModel: TopArtistsViewModel
    private lateinit var topArtistsAdapter: TopArtistsAdapter
    private val calculator = GridPositionCalculator(0)

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

    private var itemSpacing: Int = 0
    private val spanCount: Int = GridPositionCalculator.fullSpanSize

    @RecyclerView.Orientation
    private var orientation = RecyclerView.VERTICAL

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        topArtistsViewModel = ViewModelProviders.of(this, viewModelFactory)
            .get(TopArtistsViewModel::class.java)
        topArtistsAdapter = TopArtistsAdapter(calculator)
        itemSpacing = resources.getDimension(R.dimen.item_spacing).toInt()
    }

    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)
            orientation = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
                RecyclerView.HORIZONTAL
            else
                RecyclerView.VERTICAL
            topArtistsRecyclerView.apply {
                adapter = topArtistsAdapter
                layoutManager = GridLayoutManager(context, spanCount, orientation, false).apply {
                    spanSizeLookup = calculator
                }
                addItemDecoration(TopArtistsItemDecoraton(orientation, itemSpacing, calculator))
            }
            retryButton.setOnClickListener {
                topArtistsViewModel.load()
            }
            topArtistsViewModel.topArtistsViewState.observe(this, Observer { newState -> viewStateChanged(newState) })
        }
    .
    .
    .
}

We now have some nice spacing between all of the items, but no decorations at the edges:

The more observant may have noticed that throughout the code we’ve added here, there is support for both horizontal and vertical orientation, and this gets determined by the orientation state of the device. By adding duplicates of our layouts in to res/layout-land each with some small changes to display correctly in landscape orientation we also get a landscape version of this grid layout:

So with a few modifications to the layout manager that we use in RecyclerView we can actually get a much more visually pleasing UI with the images becoming the prominent feature of each item, with a nice change to the organisation to items depending on their ranking within the list of top artists.

In the next article we’ll look at the overall User Experience of this feature module and look at how we can improve it.

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.