Welcome to the fourth part of Understanding foldable devices. In this installment we will finally be coding 👏. I show you how to create a Jetpack Compose app that honors folds and hinges, distinguishes between portrait and landscape mode, and takes advantage of large screens. This sounds like a big task, right?
It won't be one.
Setup
To get information about folds and hinges we will be using Jetpack WindowManager. As usual, the library is added to a project as an implementation dependency.
dependencies {
...
implementation "androidx.window:window:1.1.0-alpha04"
}
As you will see shortly, just two functions, windowLayoutInfo()
and computeCurrentWindowMetrics()
, provide all data we need. Here is how they are invoked:
class FoldableDemoActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launchWhenResumed {
setContent {
val layoutInfo by WindowInfoTracker.getOrCreate(this@FoldableDemoActivity)
.windowLayoutInfo(this@FoldableDemoActivity).collectAsState(
initial = null
)
val windowMetrics = WindowMetricsCalculator.getOrCreate()
.computeCurrentWindowMetrics(this@FoldableDemoActivity)
MaterialTheme(
content = {
Scaffold(
topBar = {
TopAppBar(title = {
Text(stringResource(id = R.string.app_name))
})
}
) { padding ->
Content(
layoutInfo = layoutInfo,
windowMetrics = windowMetrics,
paddingValues = padding
)
}
},
colorScheme = if (isSystemInDarkTheme())
darkColorScheme()
else
lightColorScheme()
)
}
}
}
}
In this code snippet there seems to be quite a few stuff going on. But it's actually really straight forward. setContent { }
receives the root of our Compose UI, a MaterialTheme()
(with colors for light and dark mode) containing a Scaffold()
containing a TopAppBar()
and our Content()
. Because windowLayoutInfo()
returns a flow, we launch a coroutine with lifecycleScope.launchWhenResumed()
.
That wasn't hard, right? The next one won't be either.
@Composable
fun Content(
layoutInfo: WindowLayoutInfo?,
windowMetrics: WindowMetrics,
paddingValues: PaddingValues
) {
val foldDef = createFoldDef(layoutInfo, windowMetrics)
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = paddingValues)
) {
if (foldDef.hasFold) {
FoldableScreen(
foldDef = foldDef
)
} else if (foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
LargeScreen(
foldDef = foldDef
)
} else {
SmartphoneScreen(
foldDef = foldDef
)
}
}
}
Content()
is basically a dispatcher (not in the sense of coroutines, of course 😊). It looks at layoutInfo: WindowLayoutInfo?
and windowMetrics: WindowMetrics
to determine the device type and then calls either FoldableScreen()
, LargeScreen()
, or SmartphoneScreen()
. Please note that I wrap the invoked composable inside BoxWithConstraints()
because that function provides some information regarding constraints to its content (which you may want to take advantage of).
Before we look at the three device type screens, let's briefly turn to FoldDef
.
data class FoldDef(
val hasFold: Boolean,
val foldOrientation: FoldingFeature.Orientation?,
val foldWidth: Dp,
val foldHeight: Dp,
val widthLeftOrTop: Dp,
val heightLeftOrTop: Dp,
val widthRightOrBottom: Dp,
val heightRightOrBottom: Dp,
val isPortrait: Boolean,
val windowSizeClass: WindowSizeClass,
)
The class holds information about the fold or hinge, for example its orientation and size. And it stores the dimensions of the areas to the left and right of the gap, which is where your app UI resides.
Here's how the data for FoldDef
is obtained:
@Composable
fun createFoldDef(
layoutInfo: WindowLayoutInfo?,
windowMetrics: WindowMetrics
): FoldDef {
var foldOrientation: FoldingFeature.Orientation? = null
var widthLeftOrTop = 0
var heightLeftOrTop = 0
var widthRightOrBottom = 0
var heightRightOrBottom = 0
var foldWidth = 0
var foldHeight = 0
layoutInfo?.displayFeatures?.forEach { displayFeature ->
(displayFeature as FoldingFeature).run {
foldOrientation = orientation
if (orientation == FoldingFeature.Orientation.VERTICAL) {
widthLeftOrTop = bounds.left
heightLeftOrTop = windowMetrics.bounds.height()
widthRightOrBottom = windowMetrics.bounds.width() - bounds.right
heightRightOrBottom = heightLeftOrTop
} else if (orientation == FoldingFeature.Orientation.HORIZONTAL) {
widthLeftOrTop = windowMetrics.bounds.width()
heightLeftOrTop = bounds.top
widthRightOrBottom = windowMetrics.bounds.width()
heightRightOrBottom = windowMetrics.bounds.height() - bounds.bottom
}
foldWidth = bounds.width()
foldHeight = bounds.height()
}
}
return with(LocalDensity.current) {
FoldDef(
foldOrientation = foldOrientation,
widthLeftOrTop = widthLeftOrTop.toDp(),
heightLeftOrTop = heightLeftOrTop.toDp(),
widthRightOrBottom = widthRightOrBottom.toDp(),
heightRightOrBottom = heightRightOrBottom.toDp(),
foldWidth = foldWidth.toDp(),
foldHeight = foldHeight.toDp(),
isPortrait = windowWidthDp(windowMetrics) / windowHeightDp(windowMetrics) <= 1F,
windowSizeClass = WindowSizeClass.compute(
dpWidth = windowWidthDp(windowMetrics = windowMetrics).value,
dpHeight = windowHeightDp(windowMetrics = windowMetrics).value
),
hasFold = foldOrientation != null
)
}
}
Now, here are quite a few things going on. But you don't need to bother because I made all the calculations for you 😍. If you are curious, feel free to dig in, though. To fully understand the code you need to see two more functions:
@Composable
fun windowWidthDp(windowMetrics: WindowMetrics): Dp = with(LocalDensity.current) {
windowMetrics.bounds.width().toDp()
}
@Composable
fun windowHeightDp(windowMetrics: WindowMetrics): Dp = with(LocalDensity.current) {
windowMetrics.bounds.height().toDp()
}
They return the width and height of a window in density independent pixels.
Content based on device types
But now let's look at the device type screens. We start with traditional smartphones.
@Composable
fun SmartphoneScreen(foldDef: FoldDef) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
YellowBox()
PortraitOrLandscapeText(foldDef = foldDef)
}
}
My implementation of SmartphoneScreen()
shows a Box()
containing YellowBox()
and PortraitOrLandscapeText()
. You would be putting the main content of your UI here. The app bar has already been set in onCreate()
. Please note that your composables can take all available space (modifier = Modifier.fillMaxSize()
).
Now let's turn to large screens. What a large screen actually is can be adjusted in Content()
. My implementation just does this:
if (foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
Depending on the layout of your app (what it wants to show) you may want to add additional conditions. For example, if the device is in portrait mode you may want to look at windowHeightSizeClass
.
@Composable
fun LargeScreen(foldDef: FoldDef) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.fillMaxSize(),
) {
val localModifier = Modifier
.fillMaxHeight()
.weight(0.333F)
Box(modifier = localModifier) {
RedBox()
}
Box(modifier = localModifier) {
YellowBox()
}
Box(modifier = localModifier) {
GreenBox()
}
}
PortraitOrLandscapeText(foldDef)
}
}
My example stacks a Row()
and PortraitOrLandscapeText()
in a Box()
, so you can see how easy it is to implement multi column layouts. If you recall the previous parts of this series, I strongly suggest utilizing two columns even on large screens, so this code is more showing the flexibility of my approach. As like in SmartphoneScreen()
, your composable can take advantage of all available space (modifier = Modifier.fillMaxSize()
).
Finally, let's tackle the supreme discipline, foldable devices with or without an obstructing hinge:
@Composable
fun FoldableScreen(foldDef: FoldDef) {
val hinge = @Composable {
Spacer(
modifier = Modifier
.width(foldDef.foldWidth)
.height(foldDef.foldHeight)
)
}
val firstComposable = @Composable {
RedBox()
}
val secondComposable = @Composable {
GreenBox()
}
val container = @Composable {
if (foldDef.foldOrientation == FoldingFeature.Orientation.VERTICAL) {
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(foldDef.widthLeftOrTop)
) {
firstComposable()
}
hinge()
Box(
modifier = Modifier
.fillMaxHeight()
.width(foldDef.widthRightOrBottom)
) {
secondComposable()
}
}
} else {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1.0F)
) {
firstComposable()
}
hinge()
Box(
modifier = Modifier
.fillMaxWidth()
.height(foldDef.heightRightOrBottom)
) {
secondComposable()
}
}
}
}
container()
}
Now, this looks a little tougher, right? The good news is, you can reuse basically all code and replace only the contents of firstComposable
and secondComposable
. We'll look at them in a minute. First, let's understand what FoldableScreen()
does.
We know that we are on a foldable device. Therefore, we check if the fold or hinge runs horizontally or vertically. A vertical fold means that there are two areas to the left and to the right of it. A horizontal fold means that these areas are above and below the fold or hinge. Translated to Jetpack Compose this means either Row()
or Column()
. This composable (container
) receives three children:
firstComposable
-
hinge
(I should probably rename it tofold
😂) secondComposable
The sizes of these children are set based on the data from foldDef: FoldDef
. That's why your content can again use all available space. Take a look what my example does:
@Composable
fun RedBox() {
ColoredBox(
modifier = Modifier
.fillMaxSize(),
color = Color.Red
)
}
@Composable
fun YellowBox() {
ColoredBox(
modifier = Modifier
.fillMaxSize(),
color = Color.Yellow
)
}
@Composable
fun GreenBox() {
ColoredBox(
modifier = Modifier
.fillMaxSize(),
color = Color.Green
)
}
All three are basically the same, besides passing a different color to ColoredBox()
.
@Composable
fun ColoredBox(modifier: Modifier, color: Color) {
Box(
modifier = modifier
.background(color)
.border(1.dp, Color.White)
)
}
ColoredBox()
draws a small white border to visualize that the composable really is shown completely. I call this visual debugging 🤣. To make the code complete, here's one more composable, PortraitOrLandscapeText()
:
@Composable
fun PortraitOrLandscapeText(foldDef: FoldDef) {
Text(
text = stringResource(
id = if (foldDef.isPortrait)
R.string.portrait
else
R.string.landscape
),
style = MaterialTheme.typography.displayLarge,
color = Color.Black
)
}
This concludes the code walkthrough. You can find the project on GitHub.
Wrap up
As you have seen, supporting foldables and large screen devices is no big deal. At least, if you use my code snippets. Which I really invite you to, as they are completely free and can be used under any license you choose. I appreciate an attribution, but this is not required. So, there is literally no excuse for not properly supporting foldable and large screen devices.
In the following part, which will conclude this series, we will be looking at Canonical layouts and how they affect the code you have seen today. Please stay tuned.
Unless stated otherwise all images (c) Thomas Künneth
Top comments (0)