Introduction to Jubako

Jubako is a new open-source offering from Just Eat to create rich content screens (ala wall of carousels) similar to our Android app home screen, Netflix or Google Play.

At the time of writing Jubako just made a 0.5 alpha release. Although it is only an alpha we have been using some rendition of the approach for several years.

Back in April 2019 we added a new feature to the home screen that offers recommended restaurants and personalized cuisines. Our home screen was to become a wall of content where each content section could be anything, though mostly – carousels.

Just-Eat Jubako Usage

Carousels are widgets that allow the user to swipe through a horizontal list of content. Content is represented as tiles and could contain information on products, restaurants or cuisines.

The back-end APIs that serve up this content are defined in multiple ways. Some API’s return a list of lists (where each list item represented a carousel) or an API could return a list of links to other lists, or even just return a single list, the possibilities of how data is returned from APIs could never be defined in a fixed manner, so Jubako had to be flexible on how it sources content to be displayed.

Given that Jubako was designed to be flexible on how it handles asynchronous data, LiveData was used as the data interface.

Hello Jubako

To achieve Hello World status with Jubako we can use one of many convenience functions that aims to take out the boilerplate for simple scenarios that do not require the complexity of enterprise patterns.

class HelloJubakoActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_jubako_recycler)

        recyclerView.withJubako(this).load {
            for (i in 0..100) {
                addView { textView("Hello Jubako!") }
                addView { textView("こんにちはジュバコ") }
            }
        }
    }

    private fun textView(text: String): TextView {
        return TextView(this).apply {
            setText(text)
            setTextSize(TypedValue.COMPLEX_UNIT_DIP, 48f)
        }
    }
}

In this example we use the convenience function withJubako to setup a JubakoRecyclerView as a target in which to load data into. Following up with a call to load { } passing a lambda where we can use the convenience function addView { } to simply add android views. We loop a 100 times adding both english Hello Jubako and Japanese (a respectful nod to the origins of the Jubako name!) こんにちはジュバコ

Running this example produces the following result, a list of 100 hellos in both English and Japanese.

Fig. 1
image3

Hello Jubako (advanced version)

Under Jubakos convenience extensions load { } and addView { } there is a much more verbose way to get the same result, and even though it is more verbose and could be considered boilerplate in common scenarios, the verbose way has many more opportunities for advanced work especially if you want close contact with RecyclerView internals such as a ViewHolder.

The following example shows an expanded version of Hello World example (Ex. 2)

recyclerView.withJubako(this).load(SimpleJubakoAssembler {
    for (i in 0..100) {
        add(object : ContentDescriptionProvider<Any> {
            override fun createDescription(): ContentDescription<Any> {
                return ContentDescription(
                    viewHolderFactory = viewHolderFactory {
                        object : JubakoViewHolder<Any>(textView("Hello Jubako!")) {
                            override fun bind(data: Any?) {}
                        }
                    },
                    data = EmptyLiveData()
                )
            }

        })

        add(object : ContentDescriptionProvider<Any> {
            override fun createDescription(): ContentDescription<Any> {
                return ContentDescription(
                    viewHolderFactory = viewHolderFactory {
                        object : JubakoViewHolder<Any>(textView("こんにちはジュバコ")) {
                            override fun bind(data: Any?) {}
                        }
                    },
                    data = EmptyLiveData()
                )
            }

        })
    }
})

The example above produces exactly the same result as in the screenshot (see Fig. 1), here are some key differences:-

We pass a SimpleJubakoAssembler to load(...). The responsibility of an assembler is to assemble a list of rows to render in Jubako where each row is a ContentDescriptionProvider. The SimpleJubakoAssembler is really just a convenience implementation of the interface JubakoAssembler which provides even more lower level customisation power of Jubako’s content loading method.
Instead of calling addView { } we add ContentDescriptionProviders instead via SimpleJubakoAssembler::add function.
A ContentDescriptionProvider is your chance to construct and return a ContentDescription that effectively describes the content and provides an interface into the contents data source via LiveData, in this example we don’t have any data since its a static “Hello Jubako” directly set on the text view, for that reason Jubako requires that we set it to EmptyLiveData (although this does not need to be done explicitly as the example illustrates, you can leave it out if you like).
viewHolderFactory { } allows the construction of a custom view holder for a ContentDescription which must extend JubakoViewHolder giving you access to anything view holder related which could be necessary in advanced implementations.

Simple Carousels

In this next example we lean once again on the convenient Jubako extension functions to build a wall of carousels. Also in this example to add some reality into the mix we will have a makeshift repository that will provide the content we wish to bind and a 500ms delay to show Jubakos loading indicator (see Fig. 2) where the spinner moves down underneath each new carousel loaded until the screen is filled with carousels.

Fig. 2
image4

To achieve this requires little code with Jubako calls to addRecyclerView convenience function during load { ... } like in Ex. 3.

class SimpleCarouselsActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_jubako_recycler)

        // Set page size to 1 so we can see it loading (descriptions are delayed by 500ms)
        recyclerView.withJubako(this, pageSize(1)).load {
            (0 until 100).forEach { i ->
                addRecyclerView(
                    //
                    // Inflate a view for our carousel
                    //
                    view = { inflater, parent ->
                        inflater.inflate(R.layout.simple_carousel, parent, false)
                    },
                    //
                    // Provide a lambda that will create our carousel item view holder
                    //
                    itemViewHolder = { inflater, parent, _ ->
                        SimpleCarouselItemViewHolder(inflater, parent)
                    },
                    //
                    // Specify the data that will be loaded into the carousel
                    //
                    data = when {
                        i % 2 == 0 -> getNumbersEnglish()
                        else -> getNumbersJapanese()
                    },
                    //
                    // Provide a lambda that will fetch carousel item data by position
                    //
                    itemData = { data, position -> data[position] },
                    //
                    // Specify a lambda that will provide the count of item data in our carousel
                    //
                    itemCount = { data -> data.size },
                    //
                    // Specify a viewBinder that will allow binding between data and item holder
                    //
                    itemBinder = { holder, data ->
                        holder.itemView.findViewById<TextView>(R.id.text).text = data
                    }
                )
            }
        }
    }

    inner class SimpleCarouselItemViewHolder(inflater: LayoutInflater, parent: ViewGroup) :
        RecyclerView.ViewHolder(inflater.inflate(R.layout.simple_carousel_item_text, parent, false))

    companion object {
        fun getNumbersEnglish(): LiveData<List<String>> {
            return object : LiveData<List<String>>() {
                override fun onActive() {
                    GlobalScope.launch(Dispatchers.IO) {
                        delay(500)
                        postValue(listOf("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"))
                    }
                }
            }
        }

        fun getNumbersJapanese(): LiveData<List<String>> {
            return object : LiveData<List<String>>() {
                override fun onActive() {
                    GlobalScope.launch(Dispatchers.IO) {
                        delay(500)
                        postValue(listOf("ひとつ", "ふたつ", "みっつ", "よっつ", "いつつ", "むっつ", "ななつ", "やっつ"))
                    }
                }
            }
        }
    }
}

Since this example is quite large, let us break it down to see what is going on, firstly the companion object provides two functions that produce a LiveData<List> where these functions getNumbersEnglish() and getNumbersJapanese() mimic what might be the interface of some data layer (See Ex. 4).

companion object {
    fun getNumbersEnglish(): LiveData<List<String>> {
        return object : LiveData<List<String>>() {
            override fun onActive() {
                GlobalScope.launch(Dispatchers.IO) {
                    delay(500)
                    postValue(listOf("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"))
                }
            }
        }
    }

    fun getNumbersJapanese(): LiveData<List<String>> {
        return object : LiveData<List<String>>() {
            override fun onActive() {
                GlobalScope.launch(Dispatchers.IO) {
                    delay(500)
                    postValue(listOf("ひとつ", "ふたつ", "みっつ", "よっつ", "いつつ", "むっつ", "ななつ", "やっつ"))
                }
            }
        }
    }
}

Both functions are equivalent with the only obvious difference that one returns numbers in Japanese rather than English. Both functions employ a 500ms delay and both use coroutines. What is important here is that we override LiveData::onActive() which is essentially our hook to load data – this is where we want to make our API calls or interact with our data layer and postValue(...) with our retrieved data as the payload. In the example our data is of type List.

Next up in Ex.5, like we saw in the Hello World example we attach Jubako to a recycler using withJubako(...) though this time, the second argument we specify the page size using pageSize(1) function which effectively sets up loading to load 1 page at a time. What this actually does is instruct Jubako loading to populate the recyclerview one content description at a time until either the screen is filled vertically with items, the end of the datasource is reached or if the user scrolls down on an already populated RecyclerView then one item will be loaded until the user scrolls again. Setting the page size to one only helps us show a 500ms delay between each ContentDescription that is loaded from the assembled descriptions (assembling using JubakoAssembler is covered later).

// Set page size to 1 so we can see it loading (descriptions are delayed by 500ms)
recyclerView.withJubako(this, pageSize(1)).load {
    (0 until 100).forEach { i ->
        addRecyclerView(
            //
            // Inflate a view for our carousel
            //
            view = { inflater, parent ->
                inflater.inflate(R.layout.simple_carousel, parent, false)
            },
            //
            // Provide a lambda that will create our carousel item view holder
            //
            itemViewHolder = { inflater, parent, _ ->
                SimpleCarouselItemViewHolder(inflater, parent)
            },
            //
            // Specify the data that will be loaded into the carousel
            //
            data = when {
                i % 2 == 0 -> getNumbersEnglish()
                else -> getNumbersJapanese()
            },
            //
            // Provide a lambda that will fetch carousel item data by position
            //
            itemData = { data, position -> data[position] },
            //
            // Specify a lambda that will provide the count of item data in our carousel
            //
            itemCount = { data -> data.size },
            //
            // Specify a viewBinder that will allow binding between data and item holder
            //
            itemBinder = { holder, data ->
                holder.itemView.findViewById<TextView>(R.id.text).text = data
            }
        )
    }
}

In Ex. 5 using the addRecyclerView extension function we can simply add a number carousel rows (content descriptions), in this case we add 0 until 100 of them.

The convenient addRecyclerView function allows us to declaratively specify many aspects of our content such as a view our content should be where we get an opportunity to inflate our custom view (with carousels you must have a JubakoCarouselRecyclerView present in your inflated layout), as well as:-

  • itemViewHolder – where you can specify a ViewHolder to represent an item
  • data – the LiveData we want to provide data for the carousel, in this case we delegate to those companion object functions from earlier, getNumbersEnglish() or getNumbersJapanese() alternating respectively.
  • itemData – helps Jubako know how to get an item from the data, specially useful if your data source is not a list
  • itemCount – helps Jubako know how many items are in the data, specially useful if data is not a list again!
  • itemBinder – where you can bind the data to the holder given as arguments in the lambda

You can see here that most of these things deal with how our data looks, how it is bound and what view we want to represent our data for each content description.

Just like the Hello Jubako Example this example can be expanded with a JubakoAssembler which provides more capabilities like paginated loading as well as finer control over the creation of content descriptions.

Paginated Asynchronous loading and error handling
The very latest version of Jubako introduces the ability to assemble content with pagination.

What this means is if you have a paginated API, or you want to load and show content in batches you can just implement ContentAssembler::hasMore().

When you give Jubako your ContentAssembler implementation it will call ContentAssembler::assemble() as long as hasMore() returns true whenever Jubako is ready to load the next batch of content (when the user scrolls to near the bottom) into the attached recyclerview.
Infinite Hello Jubako
To illustrate this we can refer to the Infinite Hello Jubako sample where we return pages of 10 “Hello Jubako” greetings infinitely by simply always returning true from hasMore().

class InfiniteHelloJubakoActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_jubako_recycler)

        recyclerView.withJubako(this).load(HelloAssembler())
    }

    class HelloAssembler : SimpleJubakoAssembler() {
        var counter = 0
        //
        // Always has more == infinite!
        // Normally you should calculate this value, for instance
        // start returning false when you get to the end
        // of your data source
        //
        override fun hasMore() = true

        override suspend fun onAssemble(list: MutableList<ContentDescriptionProvider<Any>>) {
            // pages of ten
            for (i in 0 until 10) {
                val num = ++counter
                list.addView { textView(it, "Hello Jubako! $num") }
            }
        }

        private fun textView(parent: ViewGroup, text: String): TextView {
            return TextView(parent.context).apply {
                setText(text)
                setTextSize(TypedValue.COMPLEX_UNIT_DIP, 48f)
            }
        }
    }
}

Although there may be no practical reason to infinitely display a greeting message it would be trivial, given some condition to return false from hasMore() such as when there is no more data left from whichever datasource we are sourcing our data from.

As our data loads a loading indicator will be displayed and if an exception is thrown from ::assemble(), Jubako will display a retry button both of which states can be customized by providing your ViewHolder that implements a ProgressView. By default Jubako will use DefaultProgressViewHolder
Carousel data pagination
So far we have only covered the pagination feature during content assembly. For Content received as lists, we can also display as a carousel with pagination, although the implementation is slightly different.

To explain how this works, in Fig. 3 we have a mockup of a carousel where the blue arrow indicates the loading direction. With carousel pagination when a carousel is fetching the next page to load Jubako will display a loading indicator, and if an error occurs a retry button.

Fig. 3
image2

To make use of carousel pagination, where ContentDescription provides data as LiveData we can assign a special type of live data PaginatedLiveData<T> where T represents the type of item and they are fairly easy to implement for a given carousel.

To create an implementation of PaginatedLiveData is fairly straightforward and has only two lambdas that you need to assign, the first one is hasMore which tells Jubako if the paginated live data has more items to load and should return true of false respectively, Ex. 7 shows a simple example where hasMore will be true of the loaded data size is less than 100 items. nextPage returns a list with a single item “Hello”.

val data = PaginatedLiveData<String> {
    hasMore = { loaded.size < 100 }
    nextPage = {
        listOf("Hello")
    }
}

To see this in action we can modify the simple carousels example to add another carousel assigning this live data (Ex. 8)

addRecyclerView(
    view = { inflater, parent ->
        inflater.inflate(R.layout.simple_carousel, parent, false)
    },
    data = PaginatedLiveData<String> {
        hasMore = { loaded.size < 100 }
        nextPage = {
            delay(2000)
            listOf("Hello")
        }
    },
    itemViewHolder = { inflater, parent, _ ->
        SimpleCarouselItemViewHolder(inflater, parent)
    },
    itemBinder = { holder, data:String? ->
        holder.itemView.findViewById<TextView>(R.id.text).text = data
    }
)

In Ex. 8 we use the extension function addRecyclerView inside a load { } block to add a carousel assigning our simple PaginatedLiveData that will produce items of “Hello” until hasMore returns false.

In Fig. 4 we see this where the top carousel has loaded two tiles and shows a spinner for the next loading tiles until the screen is filled horizontally the user would be required to scroll to the end before they load more.

Fig. 4
image1

Conclusion

This article gave a brief introduction to Jubako and its main features. If you are building an app with a wall of carousels Jubako could help you achieve it, with less headaches due to dealing with asynchronous loading of paginated content. It takes the precise interactions of dealing with the RecyclerView API updates, letting you concentrate on the UI and plugging in your data sources. Although it still gives you the ability to get into the nuts and bolts of the RecyclerView API if you need to.

There are many examples in the open source project that show the features covered here and many other features such as row updates. We would encourage you to read them before making a decision about whether Jubako is right for your project.

References

Source: Just Eat