Re-gaining orientation #1

tkuenneth

Thomas Künneth

Posted on March 14, 2021

Re-gaining orientation #1

Welcome back to a new episode of my loose series about Jetpack Compose. As the slightly exaggerated title suggests, we will take a look at screen orientation and rotation. This is important for traditional smartphones and tablets, but vital for foldables.

While an app in portrait mode might look like this

An app in portrait mode

it probably should look like in the following screenshot when the phone is rotated into landscape mode:

The same app in landscape mode

So we have either one or two columns. In two-column-mode the left part is smaller:

  • the list does not need half of the horizontal screen size
  • the content, however, should have as much space as possible

On a foldable device this might be different. Consider the Surface Duo from Microsoft. It has two equally-sized screens, so we may want to distribute the two columns equally.

Now, how do we organize our layout based on the device configuration? In the old View-based world this was rather easy to achieve with alternative layouts. However, the (starting with Android 3) suggested use of Fragments did add some complexity. Allow me to explain.

The app shown above uses fragments for both module selection (the list) and modules (for example, the About screen). In portrait mode the main activity hosts just one fragment: the list. If a list item is clicked, a new activity (which shows the selected module) is started. Starting a new activity is not mandatory as you could just add the new fragment to the main activity. But having a new activity has its charms, for example you get the back arrow in the action bar (almost) for free. But let's not concentrate on this. The point is: in landscape mode both fragments are visible at the same time. Clicking on list items just swaps module fragments. The list itself remains at its location.

Now let us turn to Jetpack Compose. We want to

  • determine device configuration in order to decide if the app should be in two-column-mode
  • organize our composables accordingly

There seems to be a tendency of getting rid of fragments when using Compose, so I will not utilize them. Still, you should be aware that, while adding some degree of complexity, fragments also structure your app, so you may need to consider alternative means for keeping your app uncluttered.

In two-column-mode we certainly remain in one activity. If we decide to show only one column, we may, just like in the old world, fire up a new activity. Or we can remain in a single activity. In this episode I will stick to the latter approach. You will see shortly, why.

So, let's see how to detect the current orientation first.

private var isTwoColumnMode by mutableStateOf(false)

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      ScreenSizeDemo()
    }
  }
}

@Composable
@Preview
fun ScreenSizeDemo() {
  ScreenSizeDemoTheme {
    Scaffold {
      isTwoColumnMode = (LocalConfiguration.current.orientation
          == Configuration.ORIENTATION_LANDSCAPE)
      if (isTwoColumnMode)
        Landscape(module)
      else
        Portrait(module)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

My approach here is pretty minimalistic. I distinguish between portrait and landscape only. This works perfectly for ordinary smartphones and tablets. If the preferred orientation of a foldable remains portrait when it is opened, my simple if will, however, not deliver the expected (two column) result. We'll see how to fix this in a follow-up episode.

First, let's populate a list.

data class Module(
    val title: String,
    val description: String
)

private val modules = listOf(
    Module(
        "Title #1",
        "description #1"
    ),
    Module(
        "Title #2",
        "description #2"
    ),
    Module(
        "Title #3",
        "description #3"
    )
)

private val module: MutableState<Module?> = mutableStateOf(null)
Enter fullscreen mode Exit fullscreen mode

The Module data class defines a module. To keep things simple, I have just added a title and a description.

The currently selected module is passed to my composables Portrait() and Landscape(). Let's take a look at them.

@Composable
fun Portrait(module: MutableState<Module?>) {
  module.value?.let {
    Module(it)
  } ?: ModuleSelection(module = module)
}

@Composable
fun Landscape(module: MutableState<Module?>) {
  Row(modifier = Modifier.fillMaxSize()) {
    ModuleSelection(
      module = module,
      modifier = Modifier.weight(weight = 0.3f)
    )
    module.value?.let {
      Module(
        module = it,
        modifier = Modifier.weight(0.7f)
      )
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In Portrait() I check if a module has been selected. If so, it is shown. Otherwise the module selection is displayed. Landscape() uses a Row() to show my ModuleSelection() and if a module has been selected this Module(). Did you see that the horizontal distribution of the composables is controlled through weight()? Currently the values 0.3 and 0.7 are hardcoded. We need to change that if we want to support an equal distribution of the composables on foldables.

Now let's look at the remaining code.

@Composable
fun ModuleSelection(module: MutableState<Module?>, modifier: Modifier = Modifier) {
  ModuleSelectionList(
    modules,
    callback = {
      module.value = it
    },
    modifier = modifier
  )
}

@Composable
fun ModuleSelectionList(
  modules: List<Module>,
  callback: (module: Module) -> Unit,
  modifier: Modifier = Modifier
) {
  LazyColumn(
    modifier.fillMaxSize(),
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
  ) {
    items(modules) { module ->
      ModuleRow(module, callback)
    }
  }
}

@Composable
fun ModuleRow(module: Module, callback: (module: Module) -> Unit) {
  Column(
    modifier = Modifier
      .clickable(onClick = {
        callback(module)
      })
      .fillMaxWidth()
  ) {
    Text(
      text = module.title,
      style = MaterialTheme.typography.subtitle1
    )
    Text(
      text = module.description,
      style = MaterialTheme.typography.body2
    )
  }
}

@Composable
fun Module(module: Module, modifier: Modifier = Modifier) {
  Text(
    text = module.title,
    style = MaterialTheme.typography.h1,
    modifier = modifier.fillMaxSize()
  )
}
Enter fullscreen mode Exit fullscreen mode

My ModuleSelection() composable invokes ModuleSelectionList() with a callback, which is used when switching modules. The list is based upon LazyColumn(). Each item is represented by a ModuleRow(). It passes the callback to a clickable modifier. You may be wondering why I put two Text()s in a Column(). Well, there is ListItem(), but it is marked as experimental.

Until now I have omitted one last piece of code. Remember that I said this approach remains in a single activity. So, what happens after the user has selected a module and is finished dealing with it? Currently, pressing the back button leaves the app. To fix this we can add something like this:

override fun onBackPressed() {
  if (isTwoColumnMode)
    super.onBackPressed()
  else {
    if (module.value == null)
      super.onBackPressed()
    else
      module.value = null
  }
}
Enter fullscreen mode Exit fullscreen mode

In two-column-mode we proceed as planned, but if there is only one column, we check if we have a selected module. By executing module.value = null we trigger a recomposition which will re-show the module selection.

But this looks weird. This looks hacky.

So what can we do? In the old world we would have launched a new activity. This still works, and, frankly, this looks architecturally clean. But what if we must (want to) pursue a single-activity approach? Are Jetpack Compose and the back stack somehow connected?

Stay tuned for the next episode.


source

💖 💪 🙅 🚩
tkuenneth
Thomas Künneth

Posted on March 14, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related