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
)
}
}
}
}
The App()
composable is located in commonMain. Here's its signature:
fun App(platformContent: @Composable (AppViewModel) -> Unit = {})
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)
}
}
}
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
)
}
}
}
}
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,
)
AboutVisibility
is a humble enum
:
enum class AboutVisibility {
Hidden, Sheet, Window
}
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
}
)
}
}
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
)
}
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))
}
}
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)
)
}
}
}
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:
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)