Building Dynamic UI Components in Android with Jetpack Compose
In modern mobile applications, especially those involving complex forms or user-input driven data, flexibility is crucial. Imagine a scenario where the components in your app are driven by data retrieved from an API and tailored to user-specific needs. This approach enables you to create dynamic, reusable components that can be updated without modifying the core app code. Here, I’ll walk you through a concept where UI components are dynamically generated using specifications fetched from an API. I’ll provide practical code examples to help you implement this in your own Jetpack Compose projects.
Why Dynamic Components?
Dynamic components streamline your app’s UI and allow for:
- Flexibility: UI components can be generated based on backend data, which can be updated independently of the app.
- Efficiency: Reduce the need for hard-coded UI, making the app less prone to errors and more maintainable.
- Customization: End-users experience a personalized interface based on the data relevant to them.
Key Components in Our Implementation
We’ll build several types of input components, including:
- Checkboxes
- Dropdown menus
- Radio buttons
- Text fields
All these components will be rendered dynamically based on the specifications received from the API, and they will seamlessly integrate with the ViewModel layer to capture user inputs.
Code Walkthrough
Let’s go over each component and how it functions within this dynamic architecture.
1. Checkbox Component
The CheckboxComponent
renders a list of checkboxes based on a specification and updates user input when checked.
@Composable
fun CheckboxComponent(
spec: Specification,
userInputs: Map<Int, String>,
onInputChange: (Int, String) -> Unit
) {
if (!spec.isActive) return
Column(modifier = Modifier.fillMaxWidth()) {
spec.subSpecifications.forEach { subSpec ->
val isChecked = remember { mutableStateOf(userInputs[subSpec.id]?.toBoolean() ?: false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isChecked.value,
onCheckedChange = {
isChecked.value = it
onInputChange(subSpec.id, it.toString())
}
)
Text(text = subSpec.nameEn)
}
}
}
}
This composable:
- Displays checkboxes for each sub-specification in
spec
. - Uses
remember
to maintain the checked state. - Updates user input through the
onInputChange
function.
2. Dropdown Menu Component
The DropdownMenuComponent
uses Jetpack Compose’s ExposedDropdownMenuBox
to create a dropdown selection UI.
@Composable
fun DropdownMenuComponent(
spec: Specification,
userInputs: Map<Int, String>,
onInputChange: (Int, String) -> Unit
) {
if (!spec.isActive) return
var expanded by remember { mutableStateOf(false) }
var selectedOption by remember { mutableStateOf(userInputs[spec.id] ?: "") }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
TextField(
readOnly = true,
value = selectedOption,
onValueChange = {},
label = { Text(text = spec.placeHolder) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().clickable { expanded = true },
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
spec.subSpecifications.forEach { option ->
if (option.isActive) {
DropdownMenuItem(
onClick = {
selectedOption = option.nameEn
expanded = false
onInputChange(spec.id, selectedOption)
},
text = { Text(option.nameEn) }
)
}
}
}
}
}
The dropdown component:
- Dynamically loads options from the sub-specifications in
spec
. - Updates selection in
userInputs
and maintains UI state withselectedOption
.
3. Radio Button Component
The RadioButtonComponent
allows users to select a single option.
@Composable
fun RadioButtonComponent(
spec: Specification,
userInputs: Map<Int, String>,
onInputChange: (Int, String) -> Unit
) {
if (!spec.isActive) return
val selectedOption = remember { mutableStateOf(userInputs[spec.id] ?: "") }
Column {
spec.subSpecifications.forEach { option ->
if (option.isActive) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedOption.value == option.nameEn,
onClick = {
selectedOption.value = option.nameEn
onInputChange(spec.id, selectedOption.value)
}
)
Text(text = option.nameEn)
}
}
}
}
}
4. Text Field Component
The TextFieldComponent
is a versatile text input field that adapts its keyboard type based on the specification.
@Composable
fun TextFieldComponent(
spec: Specification,
userInputs: Map<Int, String>,
onInputChange: (Int, String) -> Unit
) {
if (!spec.isActive) return
val inputValue = remember { mutableStateOf(userInputs[spec.id] ?: "") }
val keyboardType = when (spec.keyboardType) {
1 -> KeyboardType.Text
3 -> KeyboardType.Number
// Add other types as needed
else -> KeyboardType.Text
}
OutlinedTextField(
value = inputValue.value,
onValueChange = {
inputValue.value = it
onInputChange(spec.id, it)
},
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
label = { Text(text = spec.placeHolder) },
modifier = Modifier.fillMaxWidth()
)
}
Integrating Components in the Screen
We tie all components together in the DynamicComponentsScreen
, observing data from the ViewModel and handling form submission.
@Composable
fun DynamicComponentsScreen(dynamicComponentsViewModel: DynamicComponentsViewModel = viewModel()) {
val specifications by dynamicComponentsViewModel.specifications.collectAsState()
val userInputs by dynamicComponentsViewModel.userInputs.collectAsState()
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
specifications.forEach { spec ->
SpecificationComponent(spec, userInputs, dynamicComponentsViewModel::updateUserInput)
}
Button(
onClick = {
if (dynamicComponentsViewModel.isFormValid()) {
dynamicComponentsViewModel.submitInputs()
}
},
modifier = Modifier.padding(top = 16.dp)
) {
Text(text = "Submit")
}
}
}
ViewModel and Data Management
The DynamicComponentsViewModel
manages specifications and user inputs.
class DynamicComponentsViewModel : ViewModel() {
private val _specifications = MutableStateFlow<List<Specification>>(emptyList())
val specifications: StateFlow<List<Specification>> = _specifications
private val _userInputs = MutableStateFlow(mutableMapOf<Int, String>())
val userInputs: StateFlow<Map<Int, String>> = _userInputs
fun updateUserInput(specId: Int, inputValue: String) {
_userInputs.value = _userInputs.value.toMutableMap().apply { put(specId, inputValue) }
}
fun isFormValid(): Boolean = specifications.value.all { spec ->
!spec.isRequired || !userInputs.value[spec.id].isNullOrBlank()
}
fun submitInputs() {
// Handle form submission logic
}
}
Conclusion
This approach simplifies dynamic component management in Jetpack Compose applications, enabling developers to render forms or interactive UI elements based on server-provided specifications. This pattern significantly enhances maintainability, scalability, and customization in mobile applications. I hope this article provides a foundation for your own projects and inspires you to take a dynamic approach to UI design with Jetpack Compose.