I'm just starting to explore ballast and was wonde...
# ballast
d
I'm just starting to explore ballast and was wondering how do you handle LazyColumns and pagination and actions for the lists in the items (sometimes these actions can be different per item) in ballast?
c
A general principle is that Ballast is basically used in the exact same way as
mutableStateOf()
variables in Compose (because they’re both based on the principle of Unidirectional Data Flow), but just lifted to the root of the screen so you can easily describe the contract for the entire state and possible interactions of the screen in one place. As documented in the Basic Workflow, it’s easiest to start by defining your Contract, and then applying that contract to the UI becomes a very straightforward matter. With pagination, it will be useful to create the State with variables in its constructor that you need to directly change, and then set all the computed properties into the class body so they are recomputed automatically with each change. Here’s an example of how a screen with pagination might look:
Copy code
object ListScreenContract {
    data class State(
        val loading: Boolean = false,
        val listItems: List<ListItem> = emptyList(),
        val page: Int = 0,
        val itemsPerPage: Int = 25,
    ) {
        // please double-check all the math here, I haven't actually run this code and my math isn't always very good 
        val numberOfItems: Int = listItems.size
        val numberOfPages: Int = numberOfItems / itemsPerPage
        val startingPageIndex: Int = page * itemsPerPage
        val endingPageIndex: Int = min((page + 1) * itemsPerPage, numberOfItems)
        val currentPageItems: List<ListItem> = listItems.subList(startingPageIndex, endingPageIndex)
        
        val canMoveForward: Boolean = endingPageIndex != numberOfItems 
        val canMoveBackward: Boolean = startingPageIndex != 0 
    }

    sealed class Inputs {
        object RequestLoadingListItems : Inputs()
        
        data class SetItemsPerPage(val itemsPerPage: Int) : Inputs()
        
        object GoToNextPage : Inputs()
        object GoToPreviousPage : Inputs()
        data class GoToPage(val page: Int) : Inputs()
        
        data class ListItemAction1(val selectedListItem: ListItem) : Inputs()
        data class ListItemAction2(val selectedListItem: ListItem) : Inputs()
        data class ListItemAction3(val selectedListItem: ListItem) : Inputs()
    }

    sealed class Events { }
}

class ListScreenInputHandler(
    private val listItemRepository: ListItemRepository,
) : InputHandler<ListScreenContract.Inputs, ListScreenContract.Events, ListScreenContract.State> {
    override suspend fun InputHandlerScope<ListScreenContract.Inputs, ListScreenContract.Events, ListScreenContract.State>.handleInput(
        input: ListScreenContract.Inputs
    ) = when (input) {
        is ListScreenContract.Inputs.RequestLoadingListItems -> { 
            updateState { it.copy(loading = true) }
            val listItems = listItemRepository.fetchAllListItems()
            updateState { it.copy(loading = false, listItems = listItems) }
        }
        is ListScreenContract.Inputs.SetItemsPerPage -> { 
            updateState { it.copy(itemsPerPage = input.itemsPerPage) }
        }
        is ListScreenContract.Inputs.GoToNextPage -> { 
            val currentState = getCurrentState()
            if(currentState.canMoveForward) {
                updateState { it.copy(page = it.page + 1) }
            } else {
                noOp()
            }
        }
        is ListScreenContract.Inputs.GoToPreviousPage -> {
            val currentState = getCurrentState()
            if(currentState.canMoveBackward) {
                updateState { it.copy(page = it.page - 1) }
            } else {
                noOp()
            }
        }
        is ListScreenContract.Inputs.GoToPage -> {
            val currentState = getCurrentState()
            if(input.page < currentState.numberOfPages) {
                updateState { it.copy(page = input.page) }
            } else {
                noOp()
            }
        }
        is ListScreenContract.Inputs.ListItemAction1 -> { 
            // do something with the item that was clicked
        }
        is ListScreenContract.Inputs.ListItemAction2 -> { 
            // do something with the item that was clicked
        }
        is ListScreenContract.Inputs.ListItemAction3 -> { 
            // do something with the item that was clicked
        }
    }
}

@Composable
fun PagedListItems(
    state: ListScreenContract.State,
    postInput: (ListScreenContract.Inputs)->Unit,
) {
    // just read the values in the state and apply them as you would any other Compose variables
}
This example assumes you’re loading the entire list up-front and then pagination is all just in the front-end. In reality, you may want to only load 1 page of list items at a time from the repository to conserve memory, which will change the logic here a bit. The main change is re-querying the repository after changing page
d
I see, that's a very interesting way to do it! And so I couldn't really continue working with Android's Paging3 library then?
(I don't particularly like it... but I have a bunch of code already using it...)
c
I’ve never actually used that library, but my gut reactions is no; the Paging library is basically just doing state management/caching for you, which is also the job of Ballast. That said, looking at the diagram for the Paging library, it looks like it basically boils down to emitting a
Flow<PagingData>
, which could be collected into Ballast instead of directly into the UI, but I’m not sure how useful that would be. But you don’t necessarily need to exclusively use one library or the other. For example, you could use Paging to fetch and display the data in the UI, and then Ballast for handling the list item actions as shown in the above snippet
d
LazyColumns's
items { }
has one form that takes paging3's output and manages pagination for you... But I need to control the contents of that initial list and manage the states that Pager returns (whether loading error or loaded)... pagination is such a common use-case that it would be a headache to force each user to manage by themselves... It's not really page1, page2, etc... it's just dynamically loading the continuation of the list as the user scrolls down...
c
So, so after poking around the Paging library’s internals, it looks like there’s really not all that much provided by the
paging-compose
artifact, and what is there is not able to be used outside of Compose (so it cannot be used directly in Ballast). However, I think it’s possible to basically reverse-engineer the
paging-compose
library into a Ballast workflow, since it does effectively boil down to collecting from
Flow<PagingData<ListItem>>
. You’d use this workflow as a replacement for the
paging-compose
library. Here’s something I threw together that might help get you started (I basically took this article) and just moved around the logic from the paging-compose library’s source into a Ballast MVI workflow). It’s missing a few pieces, but might help you wrap your head around how Ballast work with things like this
d
Thanks for posting that! I could give it a try, but right now I needed to pause the Android app to take care of a few things on the api side... when I get back to Android, I'll probably consider Ballast!
c
You’d definitely want to give that snippet a closer look and find out how to make it more applicable to broader usage with generics, or something like that. Mostly, I just moved stuff around from what I saw in the
paging-compose
library’s sources, but there might be better ways of handling it if you know more about the Paging APIs and how they’re supposed to be used
d
I just took a look at Ballast again, and I was thinking that pagination is really more UI related... like you state here: https://copper-leaf.github.io/ballast/wiki/mental-model/#what-not-to-put-in-a-ui-contract ... shouldn't there be a way to abstract it out of the contract? Especially since it'll clutter quite a few of them (a lot of lists in our app are paginated...).
@Casey Brooks
c
Yeah, if you’re loading the entire list up-front (as shown in my original example) then you can basically move all that logic directly into the UI. This principle is true more generally, too: in a real-world application you almost certainly don’t want every variable lifted all the way up to the ViewModel, because it will bloat it and make it difficult to understand, so part of designing your UI is in determining which properties to manage purely in the UI, and which to manage in your screen’s ViewModel. I recently came across this article from the Android docs, State Holders and UI State, which does a pretty good job of helping you understand cases where you might leave some values directly in the Compose UI using
remember { mutableStateOf() }
, and when to lift it to the ViewModel. For this case of pagination, you can absolutely load all the data into your root ViewModel, then create a UI component to implement front-end paging, and this is probably what I would do here, too. You definitely can implement this fully in the Compose UI state mechanisms, but I would probably make a dedicated Ballast ViewModel scoped to that UI component (not the screen root) to help manage the complexity, since paging has a lot of edge cases that could be tricky to solve properly in normal Compose state management