DEV Community

Cover image for Hacking the hinge
Thomas Künneth
Thomas Künneth

Posted on • Updated on

Hacking the hinge

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 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.

HingeDemo 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),
      )
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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()
    )
  }
  
Enter fullscreen mode Exit fullscreen mode

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)
          )
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}"
      )
  },
  
Enter fullscreen mode Exit fullscreen mode

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:

Hinge-aware version of HingeDemo

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) {
    
Enter fullscreen mode Exit fullscreen mode

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() {
        
Enter fullscreen mode Exit fullscreen mode

BottomBar() is added to Scaffold() like this:

Scaffold(topBar = {
  TopBar(hingeDef = hingeDef)
},
  bottomBar = {
    BottomBar(hingeDef.hasGap)
  }) {
  Content(
    modifier = Modifier.padding(it),
    hingeDef
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)