Hacking the hinge
Thomas Künneth
Posted on November 26, 2021
In a previous post I showed you how to optimize a two column layout for devices with a hinge. The idea is to
- get the configuration of the hinge using Jetpack WindowManager
- place a third column with the dimensions of the hinge between the left and right one
- set the sizes of the two content columns based on the screen size and the configuration of the hinge
This way, no content is obstructed by the hinge.
Almost no content, that is. Unfortunately, some UI elements may not be completely visible. This blog post is based on a sample app called HingeDemo. You can find it on GitHub. Here's how the app looks on the Surface Duo 2 Emulator using one screen:
HingeDemo consists of an app bar, three tabs, and a bottom navigation. The app bar usually contains a title, which is ridiculously long by intention. To see why this may be a problem, let's look at the app using two screens.
The hinge makes parts of three UI elements unreadable. They are:
- the title
- one tab
- one navigation item
While we can make the main content area hinge-aware easily, our options are rather limited regarding built-in composables, right?
No. 😎
Let's start with the title.
Tuning the title
Clearly, such a long title makes no sense. To make it shorter, we could show just n characters and add an ellipsis at the end. But what value should n be? This depends on the screen size, and the distribution of letters. Obviously, W or M need more space than l or i. Fortunately, Jetpack Compose can help us in this regard.
@Composable
fun TopBar(hingeDef: HingeDef) {
TopAppBar(title = {
if (hingeDef.hasGap) {
Text(
modifier = Modifier.width(hingeDef.sizeLeft - 32.dp),
text = stringResource(id = R.string.title),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Text(
text = stringResource(id = R.string.title),
)
}
})
}
So, the required steps are:
- set the maximum number of lines to 1
- set an overflow text
- limit the width of the text
HingeDef
is a small data class. It looks like this:
data class HingeDef(
val hasGap: Boolean,
val sizeLeft: Dp,
val sizeRight: Dp,
val widthGap: Dp
)
And here's how it is instantiated:
@Composable
fun HingeDemo(
layoutInfo: WindowLayoutInfo?,
windowMetrics: WindowMetrics
) {
var hasGap = false
var sizeLeft = 0
var sizeRight = 0
var widthGap = 0
layoutInfo?.displayFeatures?.forEach { displayFeature ->
(displayFeature as FoldingFeature).run {
hasGap = occlusionType == FoldingFeature.OcclusionType.FULL
&& orientation == FoldingFeature.Orientation.VERTICAL
sizeLeft = bounds.left
sizeRight = windowMetrics.bounds.width() - bounds.right
widthGap = bounds.width()
}
}
val hingeDef = with(LocalDensity.current) {
HingeDef(
hasGap,
sizeLeft.toDp(),
sizeRight.toDp(),
widthGap.toDp()
)
}
…
Finally, let's see how HingeDemo()
is invoked.
class HingeDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launchWhenResumed {
setContent {
val layoutInfo by WindowInfoTracker.getOrCreate(this@HingeDemoActivity)
.windowLayoutInfo(this@HingeDemoActivity).collectAsState(
initial = null
)
HingeDemoTheme {
HingeDemo(
layoutInfo,
WindowMetricsCalculator.getOrCreate()
.computeCurrentWindowMetrics(this@HingeDemoActivity)
)
}
}
}
}
}
To use Jetpack WindowManager, please make sure to add an implementation dependency to androidx.window:window:1.0.0-beta04
or later in your module-level build.gradle file.
Let's return to limiting the width of the text. Remember, the modifier looked like this:
Modifier.width(hingeDef.sizeLeft - 32.dp)
sizeLeft
is the size of the left screen in density-independent pixels. But why am I subtracting 32.dp
? The Text()
appears inside an app bar, which may have paddings and other elements that appear before the title. However, their widths cannot be determined easily, so 32 is an educated guess. In fact, if the app does not set a navigation icon, Compose currently adds a Spacer()
with this modifier:
Modifier.width(16.dp - AppBarHorizontalPadding)
AppBarHorizontalPadding
is set to 4.dp
. Therefore, my 32.dp
appear generous.
You may be wondering if setting the width of the text still works if the app bar also contains actions, which appear to the right of the title. It does, because width()
is a preferred value. If there is less room, the text is truncated earlier.
Now let's turn to tabs.
Aligning tabs
Jetpack Compose uses TabRow()
and Tab()
to implement tabs. Out of the box, the label is centred. Here's how to change this behaviour:
Tab(selected = i == selectedIndex,
text = {
if (hingeDef.hasGap)
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left,
text = "Tab #${i + 1}"
)
else
Text(
text = "Tab #${i + 1}"
)
},
…
The idea is to align the text to the left. To make it work we also need to make the text as wide as possible (Modifier.fillMaxWidth()
). Here's how HingeDemo looks with these modifications:
The screenshot also shows a solution to the third issue. We will be covering this in the next section. Regarding the alignment of the tab text, we need to be aware that this is a clever hack at best. Depending on screen size, number of tabs, and the tab texts, one text may be obstructed by the hinge nonetheless. A true solution would need to make Text()
hinge-aware. I'll return to this in the conclusion.
Using a navigation rail
The third issue of HingeDemo is that one navigation item is partially obstructed by the hinge. To solve this, we need not look for clever hacks. Instead, we can use the navigation rail, a user interaction pattern that is particularly suited for large screens.
@Composable
fun Content(
modifier: Modifier = Modifier,
hingeDef: HingeDef
) {
Row(modifier = Modifier.fillMaxSize()) {
if (hingeDef.hasGap) {
var selected by remember { mutableStateOf(0) }
NavigationRail {
for (i in 0..4) {
NavigationRailItem(selected = i == selected,
onClick = {
selected = i
},
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_android_black_24dp),
contentDescription = null
)
},
label = {
Text(text = "#${i + 1}")
})
}
}
}
Column(modifier = modifier.fillMaxSize()) {
var selectedIndex by remember { mutableStateOf(0) }
TabRow(selectedTabIndex = selectedIndex) {
…
The idea is to add NavigationRail()
as the first composable in a Row()
if we detect that the hinge runs vertically. The actual content (in my case, a Column()
) is added anyway. Finally, if your app shows a navigation rail, it should not show other primary navigations, for example BottomNavigation()
. Here's how HingeDemo handles this:
@Composable
fun BottomBar(hasGap: Boolean) {
if (!hasGap)
BottomNavigation() {
…
BottomBar()
is added to Scaffold()
like this:
Scaffold(topBar = {
TopBar(hingeDef = hingeDef)
},
bottomBar = {
BottomBar(hingeDef.hasGap)
}) {
Content(
modifier = Modifier.padding(it),
hingeDef
)
}
To use NavigationRail()
you must add an implementation dependency to androidx.compose.material3:material3:1.0.0-alpha01
or later in your module-level build.gradle file. The Jetpack compose version of Material3 is not yet stable, so I have not yet migrated other composables to the new implementation of Material Design. Mixing two versions of a design language in an app is certainly no good idea, so in the long run the app should be migrated to Material3 once its Compose lib is stable.
Conclusion
Using the techniques shown in this post you can minimize the number of UI elements being obstructed by a hinge. However, in some situations the visual representation of the text may need to be altered. For example, it could be split into two halves, one half being laid out to the left of the hinge, the second one to the right. It may be possible to achieve this by implementing a hypothetical modifier makeHingeAware()
. If this would be an implementation of the LayoutModifier
interface, or something different, is subject to further investigations.
Also, tweaking the appearance of common user interface elements may feel awkward to the users. On Android, the title of tabls is expected to be centered. A left-aligned title can feel strange.
What are your thoughts on this? Please share your impressions in the comments.
Posted on November 26, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.