DEV Community

Cover image for Centering top-level dialog windows in Compose Multiplatform
Thomas Künneth
Thomas Künneth

Posted on

4

Centering top-level dialog windows in Compose Multiplatform

This is the first article of a series of tips about Compose Multiplatform. The tips are based on a sample app I have written. The app code is not set in stone, but will evolve. Just like Compose Multiplatform is evolving. You can always find the latest version of CMP Unit Converter on GitHub. The app runs on Android, iOS, and the Desktop. As its name suggests, you can convert between various units. While this may provide some value, the main goal is to show how to use Compose Multiplatform and a couple of other multiplatform libraries. My sample has a strong focus on platform integration. For example, on the Desktop, there's menu bar support including access to settings and the About dialog.

Let's start this series of tips with a seemingly unimportant niche topic: centering top-level dialog windows. Unlike mobile platforms, Desktop operating systems have offered support for multiple windows for ages. Typically, not only documents are shown in their own window, but also dialogs, like Settings and About. Their windows are usually centered within the bounds of the parent window. How can we do this? Before I show you, allow me to acquaint you with some other important parts of the sample app first.

Dialogs or Sheets?

Here's the app entry function on the Desktop:

fun main() = application {
  initKoin {}
  Window(
    onCloseRequest = ::exitApplication,
    title = stringResource(Res.string.app_name),
    icon = painterResource(Res.drawable.app_icon),
  ) {
    App { viewModel ->
      
      val uiState by viewModel.uiState.collectAsStateWithLifecycle()
      CMPUnitConverterMenuBar(
        currentDestination = uiState.currentDestination,
        exit = ::exitApplication,
        showAbout = { viewModel.setShouldShowAbout(true) },
        showSettings = { viewModel.setShouldShowSettings(true) },
        navigateToTemperature = {
          viewModel.setCurrentDestination(AppDestinations.Temperature)
        },
        navigateToDistance = { 
          viewModel.setCurrentDestination(AppDestinations.Distance)
        },
      )
      AboutWindow(visible = uiState.aboutVisibility ==
          AboutVisibility.Window) {
        viewModel.setShouldShowAbout(
          false
        )
      }
      SettingsWindow(visible = uiState.settingsVisibility ==
          SettingsVisibility.Window) {
        viewModel.setShouldShowSettings(
          false
        )
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The App() composable is located in commonMain. Here's its signature:

fun App(platformContent: @Composable (AppViewModel) -> Unit = {})
Enter fullscreen mode Exit fullscreen mode

It will invoke platformContent and pass an instance of AppViewModel. As you can see in the code block above, on the Desktop, the implementation of platformContent sets up the menu bar by calling CMPUnitConverterMenuBar() and shows both Settings and About when needed (for example, visible = uiState.aboutVisibility == AboutVisibility.Window).

Confession: I consider my invention of AboutVisibility.Window pretty cool stuff. To understand why, let me show you how the commonMain App() composable handles Settings and About.

@Composable
@Preview
fun App(platformContent: @Composable (AppViewModel) -> Unit = {}) {
  KoinContext {
    val viewModel: AppViewModel = koinViewModel()
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    MaterialTheme(
      colorScheme = defaultColorScheme(uiState.colorSchemeMode)
    ) {
      platformContent(viewModel)
      CMPUnitConverter(viewModel)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

So, CMPUnitConverter() represents the common user interface. It looks like this:

fun CMPUnitConverter(appViewModel: AppViewModel) {
  val uiState by appViewModel.uiState.collectAsStateWithLifecycle()
  
  ScaffoldWithBackArrow(  ) { paddingValues, scrollBehavior ->
    NavigationSuiteScaffold(
      modifier = Modifier
        .background(color = MaterialTheme.colorScheme.surface)
        .padding(paddingValues),
      navigationSuiteItems = {  }) {
      
      NavHost(
        navController = navController,
        startDestination = uiState.currentDestination.name
      ) {
        composable(route = AppDestinations.Temperature.name) {
          TemperatureConverterScreen(  )
        }
        composable(route = AppDestinations.Distance.name) {
          DistanceConverterScreen(  )
        }
      }
      
      AboutBottomSheet(visible = uiState.aboutVisibility ==
            AboutVisibility.Sheet) {
        appViewModel.setShouldShowAbout(
          false
        )
      }
      SettingsBottomSheet(visible = uiState.settingsVisibility == 
            SettingsVisibility.Sheet) {
        appViewModel.setShouldShowSettings(
          false
        )
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we show AboutBottomSheet() and SettingsBottomSheet(), when certain conditions are met, for example uiState.aboutVisibility == AboutVisibility.Sheet. This is how the UI state looks like:

data class UiState(
  val currentDestination: AppDestinations,
  val aboutVisibility: AboutVisibility,
  val settingsVisibility: SettingsVisibility,
  val colorSchemeMode: ColorSchemeMode,
)
Enter fullscreen mode Exit fullscreen mode

AboutVisibility is a humble enum:

enum class AboutVisibility {
    Hidden, Sheet, Window
}
Enter fullscreen mode Exit fullscreen mode

To show About or Settings, we just look at the platform the app is running on and set the value accordingly:

fun setShouldShowAbout(shouldShowAbout: Boolean) {
  _uiState.update { state ->
    state.copy(
      aboutVisibility = when (shouldShowAbout) {
        false -> AboutVisibility.Hidden
        true -> if (shouldShowAboutInSeparateWindow()) 
                      AboutVisibility.Window
                else
                      AboutVisibility.Sheet
      }
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

That's cool, isn't it? Now that you know how to show About on the Desktop, let's center it inside the bounds of the parent window.

Centering top-level dialog windows

Let me remind you how About is shown or hidden:

AboutWindow(visible = uiState.aboutVisibility == AboutVisibility.Window) {
  viewModel.setShouldShowAbout(
    false
  )
}
Enter fullscreen mode Exit fullscreen mode

AboutWindow() is an extension function of FrameWindowScope. Here, we make use of the fact that it's called from Window(), whose content is @Composable FrameWindowScope.() -> Unit.

fun FrameWindowScope.AboutWindow(
      visible: Boolean,
      onCloseRequest: () -> Unit
) {
  if (visible) DialogWindow(
    state = rememberDialogState(
      position = getCenteredPosition()
    ),
    onCloseRequest = onCloseRequest,
    icon = painterResource(Res.drawable.app_icon),
    resizable = false,
    title = stringResource(Res.string.about_short)
  ) {
    About(modifier = Modifier
                       .fillMaxSize()
                       .background(MaterialTheme.colorScheme.surface))
  }
}
Enter fullscreen mode Exit fullscreen mode

About() is the content composable, which will also be shown in the bottom sheet on mobile. The important part here is rememberDialogState(), which receives a position that is provided by getCenteredPosition().

@Composable
fun FrameWindowScope.getCenteredPosition(): WindowPosition =
    window.locationOnScreen.let { locationOnScreen ->
  with(LocalDensity.current) {
    window.size.let { size ->
      val (width, height) = Pair(size.width.toDp(), size.height.toDp())
      val (offsetX, offsetY) = Pair(
        (width - 400.dp) / 2, (height - 300.dp) / 2
      )
      WindowPosition.Absolute(
        x = (locationOnScreen.x.toDp() + offsetX),
        y = (locationOnScreen.y.toDp() + offsetY)
      )
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The FrameWindowScope allows us to get the location of the main window on screen (window.locationOnScreen), as well as its size (window.size). The size is used to calculate the offset of the dialog window, which is then added to the location on screen. You may be wondering why I have hardcoded values of 400.dp and 300.dp. These values are currently also hardcoded in Compose Multiplatform:

Source code of rememberDialogState

I hope that at some point those values are made available through some Defaults object.

That has been really simple, right? There is only one important thing to remember: window.locationOnScreen is available only once the window has been made visible. That's why AboutWindow() starts with if (visible) DialogWindow(.

Wrap-up

This concludes my tip Centering top-level dialog windows in Compose Multiplatform. I hope you enjoyed it. Please don't forget to check out the sample on GitHub.

Top comments (0)