In my recent post Revisiting Jetpack WindowManager, I showed you how to integrate Jetpack WindowManager into your apps. This instalment builds on this foundation. We focus on adjusting the user interface after changes of the posture. The code snippets in this article belong to my sample app FoldableDemo. It's available on GitHub.
Foldables and tablets
When Google released Android Honeycomb in early 2011, most existing apps did not look good on the Motorola Xoom and the other tablets that followed shortly after. How could they? Developers were certainly not used to large screen sizes, and there were no best practices to rely on. We all needed to grasp new apis and concepts. While some (like fragments) became commonplace, others seem to not have gained wide-spread acceptance. Even today. Why else would so many apps not benefit from the large screen real estate a tablet offers? The main issues are:
- force the user into portrait mode (because that's how smartphones are held)
- do not work on tablets at all (in fact, I know quite a few apps that dare to show a corresponding error message)
- show just an enlarged version of the smartphone user interface
With foldables we have yet another device category to consider when designing our apps. Fortunately, quite a lot of what is true for tables is also true for foldables. And vice versa. So, if, for whatever reason, your app doesn't look good on tablets, get up to speed: make it ready for them - and for foldables.
And there's no excuse for refusing, because it is really simple.
Let's take a look...
When a tablet is held in landscape mode (there is more room on the screen horizontally than vertically), you can divide the user interface into two columns. A common pattern is to show a list on the left, and a content area to its right. This is called master-detail interface. 33 and 66 percent for both columns seem to be a widely accepted ratio.
Depending on the content to be displayed, you may wish to utilize three columns. This usually leads to a 33:33:33 distribution. While three columns work flawlessly on a tablet (that consists of one screen), they do not work well on devices with two screens being connected by a hinge:
For such devices a two column approach is better suited:
If you carefully look at the above screenshot you see that a few ui elements may still be affected by the hinge: the title in the app bar and tabs. These are more subtle glitches. I will be addressing them in a future article.
Composing the user interface
To make your user interface work best on smartphones, tablets, and foldables, your app must answer these questions and act accordingly:
- Am I running on a larger screen?
- Is there a hinge that obstructs parts of the ui?
- What's the configuration of the hinge?
So, how can we determine the screen size in a Compose app? As we will be using Jetpack WindowManager, one approach is to query currentWindowMetrics
. Here is the main activity of FoldableDemo:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
val r = windowInfoRepository()
super.onCreate(savedInstanceState)
setContent {
val m by r.currentWindowMetrics.collectAsState(null)
val i by r.windowLayoutInfo.collectAsState(null)
Scaffold(modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(title = {
Text(stringResource(id = R.string.app_name))
})
}) {
Content(
m,
i
)
}
}
}
}
We get an instance of WindowInfoRepository
by invoking windowInfoRepository()
(which is an extension function of Activity
) and then get WindowMetrics
and WindowMetrics
instances as states. As I initialize both to null
, Content()
must take care of this:
@Composable
fun Content(
windowMetrics: WindowMetrics?,
windowLayoutInfo: WindowLayoutInfo?
) {
Surface(modifier = Modifier.fillMaxSize()) {
windowMetrics?.let { wm ->
val widthDp = with(LocalDensity.current) {
wm.bounds.width().toDp()
}
if (widthDp < 600.dp) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Yellow)
)
} else {
WindowMetrics
has a bounds
property, which is good old android.graphics.Rect
. As we are dealing with raw pixels, we must convert width()
to density-independent pixels. After that we can easily check if we want a smartphone or tablet/foldable user interface. In smartphone mode, my example just shows one yellow box.
Next, let's turn to my second question, Is there a hinge that obstructs parts of the ui?. We need to know, because when using three columns, significant portions of its content might not be visible. You can determine this by checking isSeparating
. The docs say:
Calculates if a
FoldingFeature
should be thought of as splitting the window into multiple physical areas that can be seen by users as logically separate. Display panels connected by a hinge are always separated. Folds on flexible screens should be treated as separating when they are notFoldingFeature.State.FLAT
.
As we need to get the extent of the fold anyway, my code does not use isSeparating
but relies on occlusionType
instead:
Calculates the occlusion mode to determine if a
FoldingFeature
occludes a part of the window. This flag is useful for determining if UI elements need to be moved around so that the user can access them. For some devices occluded elements can not be accessed by the user at all.
The following code snippet determines the width of the gap, as well as the widths of the areas to its left and right. In some situations it may make sense to react to the hinge only when its orientation is vertical. This is shown by my code, too.
var hasGap = false
var sizeLeft = 0
var sizeRight = 0
var widthGap = 0
windowLayoutInfo?.displayFeatures?.forEach { displayFeature ->
(displayFeature as FoldingFeature).run {
hasGap = occlusionType == FoldingFeature.OcclusionType.FULL
&& orientation == FoldingFeature.Orientation.VERTICAL
sizeLeft = bounds.left
sizeRight = wm.bounds.width() - bounds.right
widthGap = bounds.width()
}
}
Now let's see what we can do with the obtained values:
Row(
modifier = Modifier.fillMaxSize(),
) {
if (hasGap) {
val width = (wm.bounds.width() - widthGap).toFloat()
val weightLeft = sizeLeft.toFloat() / width
val weightRight = sizeRight.toFloat() / width
ColouredBoxWithWeight(weightLeft, Color.Red)
Spacer(
modifier = Modifier
.requiredWidth(with(LocalDensity.current) {
widthGap.toDp()
})
.fillMaxHeight()
)
ColouredBoxWithWeight(weightRight, Color.Green)
} else {
ColouredBoxWithWeight(0.333F, Color.Red)
ColouredBoxWithWeight(0.333F, Color.Green)
ColouredBoxWithWeight(0.333F, Color.Blue)
}
}
If there is no gap, the app is in three column mode. It shows three equally sized coloured boxes.
If, however, we detected a gap, the app shows two columns. The gap is represented by a Spacer()
with a required width. The areas to its left and right are sized by providing weights. Maybe you are wondering why I laboured to accurately calculate them. Wouldn't it be easier to just use 0.5F
? Well, my algorithm works for cases where the two display halves are not equal in width. I do not know if there will ever be a device with these characteristics, but then, my app is on the safe side. 😎
Conclusion
Have you spotted the thin white borders around the coloured boxes? I put them there to check that the sizes are calculated correctly. As you have seen, making a composable ui aware of both tablets and foldables is really easy. So, what's stopping you? Go for it. And please share your thoughts in the comments.
Top comments (0)