Compose / Jetpack

Compose – List / Detail: Basics

In a recent post on Styling Android we looked SlidingPanelLayout. This can simplify the implementation of a List / Detail UI. It handles the logic of whether to show a side-by-side layout or a two-page layout depending on the screen size. Currently, there is no equivalent for this in Jetpack Compose. In this article, we’ll see how Compose makes this relatively easy.

Before we dive into this it’s worth pointing out the word “currently” in the opening paragraph. This is an area of Compose that is under active development. So keep an eye out for an official implementation of this kind of functionality. Even if this is only a temporary solution there are still some interesting techniques that we’ll cover.

It’s also worth mentioning that, we’ll have a working solution by the end of this article. This may be fine for many cases. But we’ll add further functionality in the second article. This will make the behaviour even better on foldable devices.

List / Detail

The basic behaviour that we’re after is when the UI has a list of items. When the user taps an item in the list then we display the details in the detail view. On larger screens, there may be sufficient space to display both the list and detail view side-by-side. However, on smaller devices tapping an item may replace the list view with the detail view, and hitting back will return.

We can do this on older View-based UIs by having different layouts for different screen sizes. More recently, SlidingPaneLayout can handle the heavy lifting.

To do this with Compose, let’s first look at the List and Detail composables:

@Composable
fun MyList(list: List, onSelectionChange: (String) -> Unit) {
    LazyColumn {
        for (entry in list) {
            item {
                Row(
                    Modifier
                        .fillMaxWidth()
                        .clickable { onSelectionChange(entry) }
                        .padding(horizontal = 16.dp, vertical = 8.dp)
                ) {
                    Text(text = entry)
                }
            }
        }
    }
}

@Composable
fun Detail(text: String) {
    Text(text = text)
}

I have deliberately kept these as simple as possible. This is to keep the code easy to understand.

MyList() uses a LazyColumn to display a list of items provided as an argument, and the selection action is handled by a listener argument. Detail() simply displays a piece of text – this will be inlined later on.

Create a simple DSL

To make things easier later, let’s create a simple DSL that will provide a fluent way of declaring both the list and detail UIs:

@Immutable
interface TwoPaneScope {
    val list: @Composable (List, (T) -> Unit) -> Unit
    val detail: @Composable (T) -> Unit

    @Composable
    fun List(newList: @Composable (List, (T) -> Unit) -> Unit)

    @Composable
    fun Detail(newDetail: @Composable (T) -> Unit)
}

private class TwoPaneScopeImpl(
    val items: List
) : TwoPaneScope {
    override var list: @Composable (List, (T) -> Unit) -> Unit = { _, _ -> }
        private set

    override var detail: @Composable (T) -> Unit = {}
        private set

    @Composable
    override fun List(newList: @Composable (List, (T) -> Unit) -> Unit) {
        list = newList
    }

    @Composable
    override fun Detail(newDetail: @Composable (T) -> Unit) {
        detail = newDetail
    }
}

This provides a scope within which we can declare the list and detail UIs. We’ll go in to the implementation of this later on. For now, all we need to know is that we can declare the UI like this:

ListDetailLayout(
    (1..10).map { index -> "Item $index" },
    LocalConfiguration.current
) {
    List { list, onSelectionChange ->
        MyList(list, onSelectionChange)
    }
    Detail { text ->
        Text(text = text)
    }
}

We’ll see how we can access the UI definitions for the List and Detail blocks shortly.

Split Layout

The split layout (i.e. the side-by-side one) is the relatively easy to implement:

@Composable
private fun SplitLayout(
    twoPaneScope: TwoPaneScopeImpl,
    selected: String?,
    onSelectionChange: (String) -> Unit
) {
    Row(Modifier.fillMaxWidth()) {
        Box(modifier = Modifier.weight(1f)) {
            twoPaneScope.list(twoPaneScope.items, onSelectionChange)
        }
        Box(modifier = Modifier.weight(1f)) {
            twoPaneScope.detail(selected ?: "Nothing selected")
        }
    }
}

Here we have the selected state passed in as an argument and a lambda to handle selection changes. Keeping our composables stateless is good practice as it can make them less error-prone, and easier to test.

We obtain the list and detail UIs from twoPaneScope.

For the list UI we specify the list as the first argument and the selection change handler as the second.

For the detail UI we set the text to the value of the selected argument.

We display the List and Detail components side-by-side using a Row. I have used equal weights here to divide the screen in half. But it would be trivial to change that to meet differing requirements.

When the user taps on a list item, the text is displayed in the right hand pane:

Two Page Layout

Implementing the two-page layout is slightly different because we need to display either the list UI or the detail UI depending on whether we have a selected item:.

@Composable
private fun TwoPageLayout(
    twoPaneScope: TwoPaneScopeImpl,
    selected: String?,
    onSelectionChange: (String) -> Unit
) {
    Box(modifier = Modifier.fillMaxWidth()) {
        if (selected == null) {
            twoPaneScope.list(twoPaneScope.items, onSelectionChange)
        } else {
            twoPaneScope.detail(selected)
        }
    }
}

Once again we objtain the UI details from twoPaneScope and hook them up to selection and onSelectionChange as before. The real difference here is that we display either the list UI or the detail UI depending on whether selection is null.

This gives the basic behahviour that we’re after:

To keep the code simple and easier to understand I haven’t included any navigation animations. But that is certainly something I’d look to add when using this for real.

Navigation

The selection of different detail items will be handled by the Navigation Compose library. In this simple example the NavGraph will have a single route:

private object NavGraph {
    sealed class Route(val route: String) {
        object Detail : Route("detail/{selected}") {
            fun navigateRoute(selected: String?) = "detail/$selected"
        }
    }
}

This route takes an argument of the string that will be displayed in the detail UI. Navigating to this will provide the appropriate argument.

The components that we’ve looked at so far are totally agnostic of the use of Navigation Compose. This is quite deliberate. It means that SplitLayout and TwoPageLayout or solely focused on the distinct layout types. Having them stateless will also help when it comes to handling configuration changes. Hoisting the navigation logic to a higher level will assist in this

ListDetailLayout

Now that we’ve implemented the basic behaviour patterns, we need to add the logic of when to use each. This is actually really easy in Compose:

@Composable
@Suppress("MagicNumber")
fun ListDetailLayout(
    list: List,
    configuration: Configuration,
    scope: @Composable TwoPaneScope.() -> Unit
) {
    val isSmallScreen = configuration.smallestScreenWidthDp < 580
    val navController = rememberNavController()
    val twoPaneScope = TwoPaneScopeImpl(list).apply { scope() }

    NavHost(navController = navController, startDestination = NavGraph.Route.Detail.route) {
        composable(NavGraph.Route.Detail.route) { navBackStackEntry ->
            val selected = navBackStackEntry.arguments?.getString("selected")
            if (isSmallScreen) {
                TwoPageLayout(twoPaneScope, selected) { selection ->
                    navController.navigate(route = NavGraph.Route.Detail.navigateRoute(selection)) {
                        popUpTo(NavGraph.Route.Detail.navigateRoute(null)) {
                            inclusive = true
                        }
                    }
                }
                BackHandler(true) {
                    navController.popBackStack()
                }
            } else {
                SplitLayout(twoPaneScope, selected) { selection ->
                    navController.navigate(route = NavGraph.Route.Detail.navigateRoute(selection)) {
                        popUpTo(NavGraph.Route.Detail.route) {
                            inclusive = true
                        }
                    }
                }
            }
        }
    }
}

A Configuration instance is passed in as an argument, and we apply some simple logic to determine whether we are running on a small screen.

The Configuration object is not specific to Compose, it is what is used by the resource management framework to provide alternate resources. So we can leverage it in much the same way we. In this case, we’re applying the same logic as when we put a layout in res/layout/sw580. We’ll look at where this comes from in a moment.

We invoke the scope lambda within the concrete implementation of TwoPaneScope which gives us a TwoPaneScopeImpl that is initialsed with the UI declared within the scope. This instance gets passed down to TwoPageLayout and SplitLayout. We then obtain a NavController instance to handle navigation.

Next we construct a NavHost which contain the entire list detail UI regardless of whether it is a two-page layout or a split layout. This has a single destination – a Detail route which takes a nullable argument of the current selection text.

Within the composable destination, we emit TwoPageLayout if we’re running on a small screen otherwise we emit SplitLayout. The navigation logic is subtly different for the two UIs because they use the back stack slightly differently.

Whenever we navigate to a new destination or get a configuration change, this will be recomposed. Having the navigation wrap the entire UI in this way will maintain both UI and navigation state following configuration changes.

Putting It All Together

We can now call DynamicLayout with three arguments: The list of strings to display, the Configuration instance, and a lambda scoped to TwoPaneScope<String>:

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            ComposeListDetailTheme {
                Surface(color = MaterialTheme.colors.background) {
                    @Suppress("MagicNumber")
                    ListDetailLayout(
                        (1..10).map { index -> "Item $index" },
                        LocalConfiguration.current
                    ) {
                        List { list, onSelectionChange ->
                            MyList(list, onSelectionChange)
                        }
                        Detail { text ->
                            Text(text = text)
                        }
                    }
                }
            }
        }
    }
}

We obtain the current Configuration by calling LocalConfiguration.current.

The user will now see different UIs depending on the window size. This also works in multi-window – as the window size crosses the 580dp width boundary the UI switches automatically.

Conclusion

None of the individual composables here are particularly complex. That is very much by design. Keeping the composables small and focused makes them much easier to combine to create the desired UI. For example DyanmicLayout is solely about the logic for which UI to emit. TwoPageLayout and SplitLayout are solely responsible for their own specific behaviour

While this may seem like we have the behaviour that we want now, this doesn’t quite match the functionality of SlidingPaneLayout. In the next article, we’ll look at how we can get this playing even nicer with foldables.

Many thanks to Ian Lake for providing some valuable feedback on my bad use of navigation in the original version of this post. Not only has that removed some potential state handling bugs, but it has also cleaned up the entire implementation. Thanks, Ian!

The source code for this article is available here.

© 2021, Mark Allison. All rights reserved.

Copyright © 2021 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.