Hacking the hinge
Tags: android, jetpackcompose, foldable
Published: 2021-11-26 15:22:15

In a [previous post](https://dev.to/tkuenneth/folding-composables-12go) 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](https://github.com/tkuenneth/android-demos/tree/master/compose/HingeDemo). Here's how the app looks on the Surface Duo 2 Emulator using one screen:

![HingeDemo using one screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r5fih4uzpqe6c3tn80p1.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/12a8qz9rj81zipbwedyr.png)

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.

```kotlin
@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:

```kotlin
data class HingeDef(
  val hasGap: Boolean,
  val sizeLeft: Dp,
  val sizeRight: Dp,
  val widthGap: Dp
)
```

And here's how it is instantiated:

```kotlin
@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.

```kotlin
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:

```kotlin
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:

```kotlin
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:

```kotlin
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:

![Hinge-aware version of HingeDemo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/odoeoo0m6kn0qrbmp4lf.png)

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.

```kotlin
@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:

```kotlin
@Composable
fun BottomBar(hasGap: Boolean) {
  if (!hasGap)
    BottomNavigation() {
        …
```

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

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