
Mastering Dialogs in Jetpack Compose: Choosing Your Scope (Screen or Activity)
Mastering Dialogs in Jetpack Compose: Choosing Your Scope (Screen or Activity)
In Jetpack Compose, dialogs can be implemented in two primary ways, each serving different use cases depending on the scope and purpose of the dialog — either by embedding them directly within individual screens (screen-scoped dialogs) or by managing them at a higher level within the activity (activity-scoped dialogs) for more centralized control across multiple screens.
- Tying Dialogs to the Screen
Consider a scenario where you need to display a dialog for “Invalid Login Credentials.” This dialog should only appear on the Login Screen. In such cases, a suitable approach is to include the dialog composable as the last element within the screen-level composable function. The logic for controlling the visibility of the dialog is handled by the ViewModel associated with that specific screen.
This method isolates and manages the dialog’s behavior within the context of that screen, keeping it decoupled from the rest of the application.
To implement this, modify your screen-level composable from:
fun Screen(modifier: Modifier = Modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier.fillMaxSize()
) {
Button(onClick = {
}) {
Text("Screen Level dialog")
}
}
}to:
@Composable
fun Screen(modifier: Modifier = Modifier) {
var showDialog by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier.fillMaxSize()
) {
Button(onClick = {
showDialog = true
}) {
Text("Screen Level dialog")
}
}
if (showDialog)
Dialog(
onDismissRequest = {
showDialog = false
},
content = {
Column(
modifier = Modifier
.height(160.dp)
.width(360.dp)
.background(Color.White, MaterialTheme.shapes.large),
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Screen Level dialog",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
Text("Click button to dismiss")
Button(
modifier = Modifier.height(48.dp),
onClick = {
showDialog = false
// Handle button click
}
) {
Text("Dismiss")
}
}
},
)
}The dialog is shown or dismissed based on the showDialog flag.
- Managing Dialogs Globally within the Activity Scope
This approach is useful when dialogs need to be triggered from multiple locations in the codebase — potentially across different screens.
The core idea is to display the dialog within the Activity itself. To achieve this, you can define a sealed class to represent various dialog events:
sealed class Dialog(
open val title: String? = null,
open val message: String? = null,
open val logo: Int? = null,
open val okButtonText: String? = null,
open val cancelButtonText: String? = null,
open val onPositiveClick: (() -> Unit)? = null,
open val onNegativeClick: (() -> Unit)? = null
)
Each specific dialog type is then represented as a subclass:
sealed class Dialog(
open val title: String? = null,
open val message: String? = null,
open val logo: Int? = null,
open val okButtonText: String? = null,
open val cancelButtonText: String? = null,
open val onPositiveClick: (() -> Unit)? = null,
open val onNegativeClick: (() -> Unit)? = null
) {
data class LogOut(
val context: Context,
override val title: String?,
override val message: String?,
override val onPositiveClick: () -> Unit
) : Dialog(
title = title,
message = message,
okButtonText = context.getString(R.string.button_text_yes),
cancelButtonText = context.getString(R.string.button_text_no),
logo = R.drawable.icon_logout
)
data class ContactCustomerCare(
val context: Context,
override val onPositiveClick: () -> Unit
) : Dialog(
title = context.getString(R.string.title_text_contact_customer_care),
message = context.getString(R.string.message_text_contact_customer_care),
okButtonText = context.getString(R.string.button_text_ok),
logo = R.drawable.icon_alert,
)
}Next, create a reusable composable to render dialogs:
@Composable
fun CustomDialog(dialog: Dialog?) {
dialog?.let {
Dialog(
onDismissRequest = {},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.large
)
.clip(MaterialTheme.shapes.large)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {} // Prevent clicks from propagating outside
),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
it.title?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.width(IntrinsicSize.Min)
) {
it.logo?.let { id ->
Image(
painter = painterResource(id),
contentDescription = null,
modifier = Modifier.size(64.dp),
)
}
it.message?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
it.cancelButtonText?.let { cancelText ->
OutlinedButton(
onClick = { it.onNegativeClick?.invoke() },
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = MaterialTheme.shapes.large
) {
Text(text = cancelText)
}
}
it.okButtonText?.let { okText ->
Button(
onClick = { it.onPositiveClick?.invoke() },
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = MaterialTheme.shapes.large
) {
Text(text = okText)
}
}
}
}
}
}
}
}These dialog events are emitted via a StateFlow within the Activity’s ViewModel:
class MViewModel(application: Application): AndroidViewModel(application) {
private val _dialogState = MutableStateFlow<Dialog?>(null)
val dialogState = _dialogState.asStateFlow()
private fun showDialog(event: Dialog) {
_dialogState.value = event
}
private fun dismissDialog() {
_dialogState.value = null
}
// Example usage
fun triggerLogoutDialog() {
showDialog(
Dialog.LogOut(
context = getApplication<Application>().baseContext,
title = "Logout",
message = "Are you sure?",
onPositiveClick = {
logout()
dismissDialog()
}
)
)
}
fun triggerContactCareDialog() {
showDialog(
Dialog.ContactCustomerCare(
context = getApplication<Application>().baseContext,
onPositiveClick = {
// Handle contact care logic
dismissDialog()
}
)
)
}
private fun logout() {
// Implement logout logic
}
}Finally, the Activity observes the dialog state and shows the dialog when needed:
class MainActivity : ComponentActivity() {
private val viewModel: MViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DialogExampleTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Screen(
modifier = Modifier.padding(innerPadding),
onButtonClick = {
viewModel.triggerLogoutDialog()
}
)
viewModel.dialogState.collectAsStateWithLifecycle().value?.let { dialog ->
CustomDialog(dialog = dialog)
}
}
}
}
}
}Here’s the updated Screen composable:
@Composable
fun Screen(modifier: Modifier = Modifier, onButtonClick: () -> Unit) {
var showDialog by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier.fillMaxSize()
) {
Button(onClick = onButtonClick) {
Text("Global dialog")
}
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = {
showDialog = true
}) {
Text("Screen Level dialog")
}
}
if (showDialog)
Dialog(
onDismissRequest = {
showDialog = false
},
content = {
Column(
modifier = Modifier
.height(160.dp)
.width(360.dp)
.background(Color.White, MaterialTheme.shapes.large),
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Screen Level dialog",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
Text("Click button to dismiss")
Button(
modifier = Modifier.height(48.dp),
onClick = {
showDialog = false
// Handle button click
}
) {
Text("Dismiss")
}
}
},
)
}Conclusion
By managing dialogs at either the screen or activity level, you can gain fine-grained control over their behavior.
Use screen-scoped dialogs for context-specific interactions.
Opt for activity-scoped dialogs for centralized handling and global consistency.
This separation of concerns helps keep your codebase modular, testable, and scalable.