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.