Compose / Foldables / Jetpack

Compose – List / Detail: Foldables

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.

Previously we saw how we can get implement a split or two-page layout. Then how we can dynamically apply them based on the screen width. While that achieved the basic list /detail UI it doesn’t do as much as we get from SlidingPaneLayout which also supports foldables. Specifically, it can align the split point with the hinge or fold line of the device. This can lead to a much nicer user experience on those devices because the fold line is a natural dividing point on the screen. In this second article we’ll explore how we ca achieve that.

WindowManager

The main enabler for doing this is the relatively new Jetpack WindowManager library. This library allows us to subscribe to changes indicating changes to the device state. Currently, this focuses on foldables, and we get callbacks when the fold state changes. When this happens we’ll receive a WindowLayoutInfo object which describes the current state. This contains a list of DisplayFeature instances each describing a separate feature such as a fold point. Many current foldable devices only have a single fold point, so this list would only contain a single item. However, this API allows for a variety of different form factors which may contain multiple features.

Currently the only concrete implementation of the DisplayFeature interface is FoldingFeature.

Registering with WindowManager

We register for WindowManager callbacks in our Activity:

class MainActivity : ComponentActivity() {

    private lateinit var windowStateJob: Job

    @OptIn(ExperimentalCoroutinesApi::class)
    fun windowStateFlow(): Flow =
        callbackFlow {
            val windowManager = WindowManager(this@MainActivity)
            val consumer = Consumer { newLayoutInfo ->
                sendBlocking(newLayoutInfo)
            }
            windowManager.registerLayoutChangeCallback(
                executor = ContextCompat.getMainExecutor(this@MainActivity),
                callback = consumer
            )
            awaitClose {
                windowManager.unregisterLayoutChangeCallback(consumer)
            }
        }

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

        var windowState by mutableStateOf(WindowLayoutInfo.Builder().build())

        windowStateJob = lifecycleScope.launchWhenStarted {
            windowStateFlow()
                .collect { windowLayoutInfo ->
                    windowState = windowLayoutInfo
                }
        }

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

    override fun onStop() {
        windowStateJob.cancel()
        super.onStop()
    }
}

Here we are using a callbackFlow wrapper which will emit WindowLayoutInfo instances whenever a callback is received.

In onCreate() we create a mutable state of WindowLayoutInfo, then start collecting the flow inside a launchWhenStarted block. This will start collecting the flow only once the Activity enters STARTED state. We cancel this job on the OnStop() lifecycle method. Whenever the flow emits a new WindowLayoutInfo object, then it updates the mutable state that we created earlier.

While we could do a collectAsState() on the flow, this is impractical because the flow and UI are synchronised with the Activity lifecycle differently. The UI gets initialised once the Activity is created, but we do not register for WindowManager callback until it is started. We get around this by having windowState created in onCreate() then the flow can update it even though we won’t start collecting on it until later in the lifecycle. Thus we can bind the UI to windowState even though it won’t be updated until later in the Activity lifecycle.

We only care about the fold position when we’re in SplitLayout mode, so we only need to pass it down to that component:

@Composable
@Suppress("MagicNumber")
fun ListDetailLayout(
    list: List,
    configuration: Configuration,
    windowLayoutInfo: WindowLayoutInfo,
    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, windowLayoutInfo, selected) { selection ->
                    navController.navigate(route = NavGraph.Route.Detail.navigateRoute(selection)) {
                        popUpTo(NavGraph.Route.Detail.route) {
                            inclusive = true
                        }
                    }
                }
            }
        }
    }
}

ViewModel

Some people might think that we should move the windowStateFlow to a ViewModel. This could cause problems because of the way that WindowManager works. It’s constructor takes a Context argument, but that Context must host a Window. So we can’t use an Application context here.

With a ViewModel, problems will arise when the Android framework is responsible for handling configuration changes. Typically, what might happen is: An Activity is running, the device is rotated, the framework destroys the current Activity and replaces it with a new one with the new configuration. The ViewModel will survive this change of Activity. Unless we re-create the WindowManager instance along with the new Activity then we will not get the window state correctly emitted. The WindowManager is linked to the Window hosted by the current Activity, so it is sensible to scope this within the Activity itself. We can easily get ourselves into trouble if we scope this to something with a different lifecycle, such as a ViewModel.

SplitLayout

In SplitLayout we need to apply different logic depending on various things. If there is a vertical fold visible, then we want to position the split between the list and detail components at the fold. But if there is no fold, then we want to apply the weighted split that we implemented in the previous article. We create a function named displayFeatureOffsetDp():

@Composable
private fun SplitLayout(
    twoPaneScope: TwoPaneScopeImpl,
    windowLayoutInfo: WindowLayoutInfo,
    selected: String?,
    onSelectionChange: (String) -> Unit
) {
    Row(Modifier.fillMaxWidth()) {
        val displayFeatureOffset = displayFeatureOffsetDp(LocalDensity.current, windowLayoutInfo)
        Box(
            modifier = if (displayFeatureOffset != null) {
                Modifier.width(displayFeatureOffset)
            } else {
                Modifier.weight(1f)
            }
        ) {
            twoPaneScope.list(twoPaneScope.items, onSelectionChange)
        }
        Box(modifier = Modifier.weight(1f)) {
            twoPaneScope.detail(selected ?: "Nothing selected")
        }
    }
}

private fun displayFeatureOffsetDp(
    density: Density,
    windowLayoutInfo: WindowLayoutInfo
): Dp? {
    val displayFeatureOffset: Int? = windowLayoutInfo.displayFeatures
        .filter { (it as? FoldingFeature)?.orientation == FoldingFeature.ORIENTATION_VERTICAL }
        .map { it.bounds.left }
        .firstOrNull()
    return density.run {
        displayFeatureOffset?.toDp()
    }
}

displayFeatureOffsetDp() returns the position of the first vertical fold, or null if there isn’t one. For this, we first filter out an non-vertical folds, then we map it to the left edge of the folding feature. Finally, we use firstOrNull() to return either the first item or null if there aren’t any.

Next, we get the offset of the fold in pixels by using the current screen density to convert from DP to pixels. This will return to null if displayFeatureOffset is null.

If the offset non-null, then it means a vertical fold exists. In this case, we use a width modifier to specify a fixed width. Otherwise, we use a weight modifier instead.

The qualification of a vertical fold is important because of the orientation of the layout. The List and Detail components will be positioned side-by-side. If the fold runs vertically, then we want to align the split point to the fold. But a horizontal fold line does not affect things.

Adding this dynamic modifier based upon the current WindoLayoutInfo does the necessary. We’ll get the correct alignment with the vertical fold when one exists.

FoldingFeature

It is worth mentioning that FoldingFeature can also provide details of specific poses that the device is in. For example, when a device is fully open, then the pose will be STATE_FLAT. However, sometimes the user may partially unfold the device, and rest it on a table – similar to how a laptop would be used. For some apps, it may be nice to display the keyboard on the flat section, and another component on the vertical section. The FoldingFeature contains the necessary details for pose and fold position.

Another possibility is that there may be a visible hinge with two distinct physical screen either side. This may appear as a single logical display to apps, but the FoldingFeature.isSeparating() value will indicate this.

Conclusion

We now have very similar behaviour to SlidingPaneLayout which will also align the split point to a vertical fold. It’s not completely identical, but it’s reasonably close. And can easily be tweaked!

It’s worth noting that SlidingPaneLayout is currently 1975 lines of code, not including the list and detail implementations. This entire Compose implementation of similar logic is around 250 lines of code, including the list and detail implementations, plus the TwoPaneScope DSL definition. This isn’t a criticism of SlidingPaneLayout, far from it – I really like SlidingPaneLayout. But it shows how much more complex it is to implement this kind of logic for View-based UIs.

One thing that has disappointed me slightly is that I haven’t been able to show any screen captures of this working. That’s not because it doesn’t work, but because a screen capture doesn’t show where the fold line is. Moreover, the fold line is actually in the centre of the screen on my test device. So it’s actually identical to the weighted UI. So how do I know that it’s actually working? I added some tests to validate it. In the concluding article in this series, we’ll look at how we can test our UI easily because of Compose.

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.