Making reusable composables using Generics
April 29, 2022
This post was inspired by this excellent talk by @filbabic.
After making a working end-to-end example and expanding the functionality, I decided to make a short blog post showing the source code.
Sometimes it makes sense to have a reusable component that supports different model objects. This reusable component could be useful in both your app or if you decide to share it as a library. Creating a shared reusable component will help avoid future code duplication and potentially bugs associated with writing and maintaining more code.
Here are a couple of examples:
a generic delete dialog that can handle different model objects (notes, products, books, etc.)
generic spinner or a sort menu that can handle different model objects
Imagine you have two different model objects Product
and Book
which user can delete after confirming with a delete confirmation dialog.
If you did not use a reusable component for your AlertDialog
, you’d have to write two AlertDialog
s.
Something like this for deleting a Product
:
AlertDialog( onDismissRequest = { viewModel.onDeleteCancelled() }, title = { Text(stringResource(id = R.string.dialog_delete_title)) }, text = { Text( stringResource( id = R.string.dialog_delete_product_message, product.text ) ) }, confirmButton = { Button( onClick = { viewModel.onDeleteConfirmed(product) } ) { Text(stringResource(R.string.dialog_button_confirm)) } }, dismissButton = { Button( onClick = { viewModel.onDeleteCancelled() } ) { Text(stringResource(R.string.dialog_button_cancel)) } } )
And something like this for deleting a Book
:
AlertDialog( onDismissRequest = { viewModel.onDeleteCancelled() }, text = { Text( stringResource( id = R.string.dialog_delete_book_message, product.text ) ) }, confirmButton = { Button( onClick = { viewModel.onDeleteConfirmed(product) } ) { Text(stringResource(R.string.dialog_button_confirm)) } }, dismissButton = { Button( onClick = { viewModel.onDeleteCancelled() } ) { Text(stringResource(R.string.dialog_button_cancel)) } } )
Even in this basic out-of-the-box layout, there is a bit of duplicated code already which you have to maintain and keep in sync.
There is a way to create a re-usable composable instead. Given that composables are Kotlin functions, we can use Java Generics. Note the usage of the generic T
@Composable fun <T> DeleteDialog( item: T, message: String, title: @Composable (() -> Unit)? = null, onDelete: (T) -> Unit, onDismiss: () -> Unit ) { // implementation goes here }
This composable will accommodate deletion of either Product
or Book
and support optional title
property (assuming this flexibility is necessary for your needs).
Here is the complete implementation for this shared component:
@Composable fun <T> DeleteDialog( item: T, message: String, title: @Composable (() -> Unit)? = null, onDelete: (T) -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, // dismiss dialog on tapping outside or pressing back title = title, text = { Text(text = message) }, confirmButton = { Button( onClick = { onDelete(item) } ) { Text(stringResource(R.string.dialog_button_confirm)) } }, dismissButton = { Button( onClick = onDismiss ) { Text(stringResource(R.string.dialog_button_cancel)) } } ) }
We’ve wrapped the androidx.compose.material.AlertDialog
with a DeleteDialog
to avoid declaring the former multiple times. We don’t do much with the Generic parameter now other than sending it back in a lambda but we could. This becomes a lot more useful when you have more complex dialog layout, logic and/or more data models that can re-use this component.
Here is the call site for Product
items:
DeleteDialog( item = product, title = { Text(stringResource(id = R.string.dialog_delete_title)) }, message = stringResource(id = R.string.dialog_delete_product_message, product.text), onDelete = { viewModel.onDeleteConfirmed(product) }, onDismiss = { viewModel.onDeleteCancelled() } )
And here is the call site for Book
items:
DeleteDialog( item = product, title = null, message = stringResource(id = R.string.dialog_delete_book_message), onDelete = { viewModel.onDeleteConfirmed(product) }, onDismiss = { viewModel.onDeleteCancelled() } )
There is many ways you can handle state here. In case you are curious, I chose the following approach for this example:
@HiltViewModel class ProductListViewModel @Inject constructor( private val productRepo: ProductRepository ) : ViewModel() { private val _products = MutableStateFlow(emptyList<Item>()) val products = _products.asStateFlow() private val _deletePending = MutableStateFlow<Item?>(null) val deletePending = _deletePending.asStateFlow() init { viewModelScope.launch { _products.emit(productRepo.getItems()) } } fun onDeletePending(product: Item) { _deletePending.value = product } fun onDeleteConfirmed(product: Item) { viewModelScope.launch { productRepo.delete(product) } _deletePending.value = null } fun onDeleteCancelled() { _deletePending.value = null } }
Note that I chose to keep deletePending
as a state in my ViewModel
in order to keep the state for delete confirmation dialog after configuration changes. For instance, if the user was looking at the delete confirmation dialog for a given product, the user would still see the dialog for the same product after rotating the device.
Also, note that I prefer to name state and functions in my ViewModel in a UI-agnostic way. There is no indication in the view model that this state drives the delete confirmation alert dialog. I like this naming convention as it gives me freedom to update UI in a variety of ways without changing the ViewModel.
And here is how the state is observed in my composable:
class MainActivity : ComponentActivity() { private val viewModel by viewModels<ProductListViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { GenericDialogTheme { ProductListScreen(viewModel) } } } } @Composable fun ProductListScreen(viewModel: ProductListViewModel) { val myItems by viewModel.products.collectAsState() val deletePending by viewModel.deletePending.collectAsState() deletePending?.let { product -> DeleteDialog( item = product, title = { Text(stringResource(id = R.string.dialog_delete_title)) }, message = stringResource(id = R.string.dialog_delete_product_message, product.text), onDelete = { viewModel.onDeleteConfirmed(product) }, onDismiss = { viewModel.onDeleteCancelled() } ) } LazyColumn { items(items = myItems) { item -> Text(item.text, Modifier .fillMaxWidth() .padding(8.dp) .clickable { viewModel.onDeletePending(item) }) } } } @Composable fun <T> DeleteDialog( item: T, message: String, title: @Composable (() -> Unit)? = null, onDelete: (T) -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, title = title, text = { Text(text = message) }, confirmButton = { Button( onClick = { onDelete(item) } ) { Text(stringResource(R.string.dialog_button_confirm)) } }, dismissButton = { Button( onClick = onDismiss ) { Text(stringResource(R.string.dialog_button_cancel)) } } ) }
And here it is in action:
Filip brings up more examples of generic dialogs here.