Building Dynamic UI Components in Android with Jetpack Compose

Abdelaziz Daoud
4 min readNov 11, 2024

--

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 with selectedOption.

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.

--

--

Abdelaziz Daoud
Abdelaziz Daoud

Written by Abdelaziz Daoud

Senior Android Developer / Software Engineer

Responses (1)