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 AlertDialogs.
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.

 
Previous
Previous

Using MotionLayout in Compose

Next
Next

Getting started with Canvas in Compose