Tomáš Repčík - 27. 3. 2023

Making Extensible Settings Screen in Jetpack Compose from Scratch

How to create simple extensible settings screen in your app

In previous implementations, you can find out the XML definition of the settings. The XML file is inflated by the appropriate fragment, which creates a visual representation of the settings. The handling of preferences comes out of the box and saves tons of time.

Photo by Mika Baumeister on Unsplash

With the arrival of the Jetpack Compose, you could still wrap the preference fragment with the Android View composable. But what if the requirement is to make it entirely with Jetpack Compose? Currently, there is no official adaptation, but nothing restrains us from doing it by ourselves with endless possibilities for customisation.

Of course, there are some open-source projects which solve the whole topic. However, you never know how long these projects will be maintained or if it settles your requirements.

This article will cover only the UI point of view for preference handling If you want to know more about async DataStore implementation, you can read my other article about it here.

Settings up the main screen

In Jetpack Compose, everything is composable. The screen will be composable too, which contains a scaffold with a simple app bar and scrollable column.

@Composable  
fun SettingsScreen(  
    vm: SettingsViewModel = hiltViewModel()  
) {  
    Scaffold(  
        topBar = {  
            TopAppBar(  
                title = {  
                      Text(  
                          text = stringResource(id = R.string.settings),  
                          style = MaterialTheme.typography.titleMedium  
                      )  
                },  
            )  
        }  
    ) {  
        Column(  
            modifier = Modifier  
                .verticalScroll(rememberScrollState())  
                .padding(it)  
                .padding(16.dp)  
        ) {}  
    }  
}

To cover the background logic, we can intervene in the UI with the ViewModel injected by the hilt. Feel free to pick the permanent storage option, like DataStore or SharedPreferences. StateFlows are used for state handling.

StateFlow is one of the mechanisms to preserve the actual state of the Android application and control the UI representation. In other words, the StateFlow is stream of events which are emitted based on users actions and logic in the app. The StateFlow is hot stream — it means that it always contains last emitted value.

@HiltViewModel  
class SettingsViewModel @Inject constructor() : ViewModel() {  
  
    private val _isSwitchOn: MutableStateFlow<Boolean> = MutableStateFlow(false)  
    var isSwitchOn = _isSwitchOn.asStateFlow()  
      
    private val _textPreference: MutableStateFlow<String> = MutableStateFlow("")  
    var textPreference = _textPreference.asStateFlow()  
  
    private val _intPreference: MutableStateFlow<Int> = MutableStateFlow(0)  
    var intPreference = _intPreference.asStateFlow()  
  
  
    fun toggleSwitch(){  
        _isSwitchOn.value = _isSwitchOn.value.not()  
        // here is place for permanent storage handling - switch  
    }  
  
    fun saveText(finalText: String) {  
      _textPreference.value = finalText  
      // place to store text  
    }  
  
    // just checking, if it is not empty - but you can check anything  
    fun checkTextInput(text: String) = text.isNotEmpty()   
  
    companion object {  
        const val TAG = "SettingsViewModel"  
    }  
  
}

MutableStateFlow is changeable — mutable at any time during the usage of the app. The StateFlow creates an unchangeable — immutable version of the MutableStateFlow, which is used to recreate actual UI.

Preference UI representation

Clickable preference

If you need to click on the preference and move to another screen or do some custom action, this is the right place to do anything.

@Composable  
fun SettingsClickableComp(  
    @DrawableRes icon: Int,  
    @StringRes iconDesc: Int,  
    @StringRes name: Int,  
    onClick: () -> Unit  
) {  
    Surface(  
        color = Color.Transparent,  
        modifier = Modifier  
            .fillMaxWidth()  
            .padding(16.dp),  
        onClick = onClick,  
    ) {  
        Column {  
            Row(  
                verticalAlignment = Alignment.CenterVertically,  
                horizontalArrangement = Arrangement.SpaceBetween  
            ) {  
                Row(verticalAlignment = Alignment.CenterVertically) {  
                    Icon(  
                        painterResource(id = icon),  
                        contentDescription = stringResource(id = iconDesc),  
                        modifier = Modifier  
                            .size(24.dp)  
                    )  
                    Spacer(modifier = Modifier.width(8.dp))  
                    Text(  
                        text = stringResource(id = name),  
                        style = MaterialTheme.typography.bodyMedium.copy(  
                            color = MaterialTheme.colorScheme.surfaceTint  
                        ),  
                        modifier = Modifier  
                            .padding(16.dp),  
                        textAlign = TextAlign.Start,  
                        overflow = TextOverflow.Ellipsis,  
                    )  
                }  
                Spacer(modifier = Modifier.weight(1.0f))  
                Icon(  
                    Icons.Rounded.KeyboardArrowRight,  
                    tint = MaterialTheme.colorScheme.surfaceTint,  
                    contentDescription = stringResource(id = R.string.ic_arrow_forward)  
                )  
            }  
            Divider()  
        }  
  
    }  
}

The preference is a UI placeholder for the click action, which can result in anything you want to do.

@Composable  
fun SettingsScreen(  
    vm: SettingsViewModel = hiltViewModel()  
) {  
    Scaffold(...) {  
        Column(  
            modifier = Modifier  
                .verticalScroll(rememberScrollState())  
                .padding(it)  
                .padding(16.dp)  
        ) {  
          SettingsClickableComp(  
             name = R.string.title,  
             icon = R.drawable.ic_icon,  
             iconDesc = R.string.icon_description,  
          ) {  
             // here you can do anything - navigate - open other settings, ...  
          }  
        }  
    }  
}

Switch Preference

The switch setting is composed of one icon, text description and the switch itself. It is sufficient to track the event of changing the state, not the actual value. The value is stored already in the ViewModel, which can be toggled.

@Composable  
fun SettingsSwitchComp(  
    @DrawableRes icon: Int,  
    @StringRes iconDesc: Int,  
    @StringRes name: Int,  
    state: State<Boolean>,  
    onClick: () -> Unit  
) {  
    Surface(  
        color = Color.Transparent,  
        modifier = Modifier  
            .fillMaxWidth()  
            .padding(16.dp),  
        onClick = onClick,  
    ) {  
        Column {  
            Row(  
                verticalAlignment = Alignment.CenterVertically,  
                horizontalArrangement = Arrangement.SpaceBetween  
            ) {  
                Row(verticalAlignment = Alignment.CenterVertically) {  
                    Icon(  
                        painterResource(id = icon),  
                        contentDescription = stringResource(id = iconDesc),  
                        modifier = Modifier.size(24.dp)  
                    )  
                    Spacer(modifier = Modifier.width(8.dp))  
                    Text(  
                        text = stringResource(id = name),  
                        modifier = Modifier.padding(16.dp),  
                        style = MaterialTheme.typography.bodyMedium,  
                        textAlign = TextAlign.Start,  
                    )  
                }  
                Spacer(modifier = Modifier.weight(1f))  
                Switch(  
                    checked = state.value,  
                    onCheckedChange = { onClick() }  
                )  
            }  
            Divider()  
        }  
    }  
}

The drawable and string ids are passed to be reusable. The onClick the method is a launcher to toggle the actual value at the StateFlow in the ViewModel.

@Composable  
fun SettingsScreen(  
    vm: SettingsViewModel = hiltViewModel()  
) {  
    Scaffold(...) {  
        Column(  
            modifier = Modifier  
                .verticalScroll(rememberScrollState())  
                .padding(it)  
                .padding(16.dp)  
        ) {  
           // the switch composable  
           SettingsSwitchComp(  
                name = R.string.switch_text,  
                icon = R.drawable.ic_switch,  
                iconDesc = R.string.ic_switch_explanation,  
                // value is collected from StateFlow - updates the UI on change  
                state = vm.isSwitchOn.collectAsState()              
           ) {  
                // call ViewModel to toggle the value  
                vm.toggleSwitch()  
           }  
        }  
    }  
}

That is it! For permanent storage, you would pass the new value to the SharedPreferences or DataStore in ViewModel method.

Text preference

The structure of the text preference is more complicated as it contains logic for handling dialog window with a text field. Otherwise, the UI should not contain any business logic.

@Composable  
fun SettingsTextComp(  
    @DrawableRes icon: Int,  
    @StringRes iconDesc: Int,  
    @StringRes name: Int,  
    state: State<String>, // current value  
    onSave: (String) -> Unit, // method to save the new value  
    onCheck: (String) -> Boolean // check if new value is valid to save  
) {  
      
    // if the dialog is visible  
    var isDialogShown by remember {  
        mutableStateOf(false)  
    }  
      
    // conditional visibility in dependence to state  
    if (isDialogShown) {  
        Dialog(onDismissRequest = {   
                // dismiss the dialog on touch outside  
                isDialogShown = false   
            }) {  
            TextEditDialog(name, state, onSave, onCheck) {  
                // to dismiss dialog from within  
                isDialogShown = false  
            }  
        }  
    }  
  
    Surface(  
        modifier = Modifier  
            .fillMaxWidth()  
            .padding(16.dp),  
        onClick = {  
            // clicking on the preference, will show the dialog  
            isDialogShown = true  
        },  
    ) {  
        Column {  
            Row(  
                verticalAlignment = Alignment.CenterVertically,  
                horizontalArrangement = Arrangement.Start  
            ) {  
                Icon(  
                    painterResource(id = icon),  
                    contentDescription = stringResource(id = iconDesc),  
                    modifier = Modifier.size(24.dp)  
                )  
                Spacer(modifier = Modifier.width(16.dp))  
                Column(modifier = Modifier.padding(8.dp)) {  
                    // setting text title  
                    Text(  
                        text = stringResource(id = name),  
                        style = MaterialTheme.typography.bodyMedium,  
                        textAlign = TextAlign.Start,  
                    )  
                    Spacer(modifier = Modifier.height(8.dp))  
                    // current value shown  
                    Text(  
                        text = state.value,  
                        style = MaterialTheme.typography.bodySmall,  
                        textAlign = TextAlign.Start,  
                    )  
                }  
            }  
            Divider()  
        }  
    }  
}

The dialog contains the textfield with no restrictions and a button, which is enabled or disabled depending on the outcome of the onCheck method. If the user input is valid, the user can save the new value with the button. The dialog will be dismissed.

@Composable  
private fun TextEditDialog(  
    @StringRes name: Int,  
    storedValue: State<String>,  
    onSave: (String) -> Unit,  
    onCheck: (String) -> Boolean,  
    onDismiss: () -> Unit // internal method to dismiss dialog from within  
) {  
      
    // storage for new input  
    var currentInput by remember {  
        mutableStateOf(TextFieldValue(storedValue.value))  
    }  
      
    // if the input is valid - run the method for current value  
    var isValid by remember {  
        mutableStateOf(onCheck(storedValue.value))  
    }  
  
    Surface(  
        color = MaterialTheme.colorScheme.surfaceTint  
    ) {  
  
        Column(  
            modifier = Modifier  
                .wrapContentHeight()  
                .fillMaxWidth()  
                .padding(16.dp)  
        ) {  
            Text(stringResource(id = name))  
            Spacer(modifier = Modifier.height(8.dp))  
            TextField(currentInput, onValueChange = {  
                // check on change, if the value is valid  
                isValid = onCheck(it.text)  
                currentInput = it  
            })  
            Row {  
                Spacer(modifier = Modifier.weight(1f))  
                Button(onClick = {  
                    // save and dismiss the dialog  
                    onSave(currentInput.text)  
                    onDismiss()  
                // disable / enable the button  
                }, enabled = isValid) {  
                    Text(stringResource(id = R.string.next))  
                }  
            }  
        }  
    }  
}

In the screen, we define the connection of the compose UI to the ViewModel.

It is tempting to put parsing logic right into the dialog. However, this will cause that the dialog might not be reusable for other preferences. Moreover, it will prevent you to create unit tests for parsing.

@Composable  
fun SettingsScreen(  
    vm: SettingsViewModel = hiltViewModel()  
) {  
    Scaffold(...) {  
        Column(  
            modifier = Modifier  
                .verticalScroll(rememberScrollState())  
                .padding(it)  
                .padding(16.dp)  
        ) {  
            SettingsTextComp(  
                name = R.string.title,  
                icon = R.drawable.ic_icon,  
                iconDesc = R.string.ic_icon_description,  
                state = vm.textPreference.collectAsState(),  
                onSave = { finalText -> vm.saveText(finalText) },  
                onCheck = { text -> vm.checkTextInput(text) },  
            )  
        }  
    }  
}

Number preference

The numbers get tricky because the Jetpack Compose does not contain number pickers at the time of writing this article. Luckily, text preference can be modified to save the correct numbers only.

The textfield can invoke only numbers keyboard with:

TextField(currentInput,  
   keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),  
   onValueChange = {...}  
})

Unfortunately, the textfield can still accept any letter. We have to filter the inputs by the textfield value method. This is achieved by passing another callback function to the settings preference filterInput , which checks if the input composes of numbers only or contains some decimal separator. The UI should work only with strings as the Jetpack Compose is not adapted for handling the numbers.

It would be possible to adapt the view from XML files for number picker or to find third party library.

@Composable  
fun SettingsNumberComp(  
    @DrawableRes icon: Int,  
    @StringRes iconDesc: Int,  
    @StringRes name: Int,  
    state: State<String>,  
    onSave: (String) -> Unit,  
    inputFilter: (String) -> String, // input filter for the preference  
    onCheck: (String) -> Boolean  
) {  
  
    var isDialogShown by remember {  
        mutableStateOf(false)  
    }  
  
    if (isDialogShown) {  
        Dialog(onDismissRequest = { isDialogShown = isDialogShown.not() }) {  
            TextEditDialog(name, state, inputFilter, onSave, onCheck) {  
                isDialogShown = isDialogShown.not()  
            }  
        }  
    }  
  
    Surface(  
        modifier = Modifier  
            .fillMaxWidth()  
            .padding(16.dp),  
        onClick = {  
            isDialogShown = isDialogShown.not()  
        },  
    ) {  
        Column {  
            Row(  
                verticalAlignment = Alignment.CenterVertically,  
                horizontalArrangement = Arrangement.Start  
            ) {  
                Icon(  
                    painterResource(id = icon),  
                    contentDescription = stringResource(id = iconDesc),  
                    modifier = Modifier.size(24.dp)  
                )  
                Spacer(modifier = Modifier.width(16.dp))  
                Column(modifier = Modifier.padding(8.dp)) {  
                    Text(  
                        text = stringResource(id = name),  
                        style = MaterialTheme.typography.bodyMedium,  
                        textAlign = TextAlign.Start,  
                    )  
                    Spacer(modifier = Modifier.height(8.dp))  
                    Text(  
                        text = state.value,  
                        style = MaterialTheme.typography.bodySmall,  
                        textAlign = TextAlign.Start,  
                    )  
                }  
            }  
            Divider()  
        }  
    }  
}  

@Composable  
private fun TextEditNumberDialog(  
    @StringRes name: Int,  
    storedValue: State<String>,  
    inputFilter: (String) -> String, // filters out not needed letters   
    onSave: (String) -> Unit,  
    onCheck: (String) -> Boolean,  
    onDismiss: () -> Unit  
) {  
  
    var currentInput by remember {  
        mutableStateOf(TextFieldValue(storedValue.value))  
    }  
  
    var isValid by remember {  
        mutableStateOf(onCheck(storedValue.value))  
    }  
  
    Surface(  
        color = MaterialTheme.colorScheme.surfaceTint  
    ) {  
  
        Column(  
            modifier = Modifier  
                .wrapContentHeight()  
                .fillMaxWidth()  
                .padding(16.dp)  
        ) {  
            Text(stringResource(id = name))  
            Spacer(modifier = Modifier.height(8.dp))  
            TextField(currentInput,  
                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),  
                onValueChange = {  
                // filters the input and removes redundant numbers  
                val filteredText = inputFilter(it.text)  
                isValid = onCheck(filteredText)  
                currentInput = TextFieldValue(filteredText)  
            })  
            Row {  
                Spacer(modifier = Modifier.weight(1f))  
                Button(onClick = {  
                    onSave(currentInput.text)  
                    onDismiss()  
                }, enabled = isValid) {  
                    Text(stringResource(id = R.string.next))  
                }  
            }  
        }  
    }  
}

From ViewModel perspective, the app needs to filter the input, check if it is valid and save the number.

@HiltViewModel  
class SettingsViewModel @Inject constructor() : ViewModel() {  
  
    ...  
      
    // to get separator for the locale  
    private val separatorChar = DecimalFormatSymbols.getInstance(Locale.ENGLISH).decimalSeparator  
      
    // filtering only numbers and decimal separator  
    fun filterNumbers(text: String): String = text.filter { it.isDigit() || it == separatorChar}  
  
    // someone can still put more decimal points into the textfield  
    // we should always try to convert text to number  
    fun checkNumber(text: String): Boolean {  
        val value = text.toDoubleOrNull() ?: return false  
        return value < 0  
    }  
      
    // saving the number / show error if something goes wrong   
    fun saveNumber(text: String) {  
        val value = text.toDoubleOrNull() ?: 0 // default value / handle the error in some way - show toast or something  
          
    }  
  
    ...  
  
}

@Composable  
fun SettingsScreen(  
    vm: SettingsViewModel = hiltViewModel()  
) {  
    Scaffold(...) {  
        Column(  
            modifier = Modifier  
                .verticalScroll(rememberScrollState())  
                .padding(it)  
                .padding(16.dp)  
        ) {  
            SettingsNumberComp(  
                name = R.string.title,  
                icon = R.drawable.ic_icon,  
                iconDesc = R.string.ic_icon_description,  
                state = vm.textPreference.collectAsState(),  
                inputFiler = {text -> filterNumbers(text)}  
                onSave = { finalText -> vm.saveNumber(finalText) },  
                onCheck = { text -> vm.checkNumber(text) },  
            )  
        }  
    }  
}

Grouping preferences together

In the settings, the preferences are usually grouped by some category. To make that, we can wrap them into another composable to show the category with some background.

@Composable  
fun SettingsGroup(  
    @StringRes name: Int,  
    // to accept only composables compatible with column  
    content: @Composable ColumnScope.() -> Unit ){  
    Column(modifier = Modifier.padding(vertical = 8.dp)) {  
        Text(stringResource(id = name))  
        Spacer(modifier = Modifier.height(8.dp))  
        Surface(  
            color = MaterialTheme.colorScheme.onSurface,  
            modifier = Modifier.fillMaxWidth(),  
            shape = RoundedCornerShape(4),  
        ) {  
            Column {  
                content()  
            }  
        }  
    }  
}

Example of the settings screen.

@Composable  
fun SettingsScreen(  
    vm: SettingsViewModel = hiltViewModel()  
) {  
    Scaffold(  
        topBar = {  
            TopAppBar(  
                title = {  
                      Text(  
                          text = stringResource(id = R.string.settings),  
                          style = MaterialTheme.typography.titleMedium  
                      )  
                },  
            )  
        }  
    ) {  
        Column(  
            modifier = Modifier  
                .verticalScroll(rememberScrollState())  
                .padding(it)  
                .padding(16.dp)  
        ) {  
            SettingsGroup(name = R.string.settings_first_category) {  
                SettingsSwitchComp(  
                    name = R.string.settings_switch,  
                    icon = R.drawable.ic_icon,  
                    iconDesc = R.string.ic_icon_description,  
                    state = vm.isSwitchOn.collectAsState()  
                ) {  
                    vm.toggleSwitch()  
                }  
                SettingsTextComp(  
                  name = R.string.title,  
                  icon = R.drawable.ic_icon,  
                  iconDesc = R.string.ic_icon_description,  
                  state = vm.textPreference.collectAsState(),  
                  onSave = { finalText -> vm.saveText(finalText) },  
                  onCheck = { text -> vm.checkTextInput(text) },  
                )  
            }  
  
            SettingsGroup(name = R.string.settings_second_category) {  
                SettingsNumberComp(  
                  name = R.string.title,  
                  icon = R.drawable.ic_icon,  
                  iconDesc = R.string.ic_icon_description,  
                  state = vm.textPreference.collectAsState(),  
                  inputFiler = {text -> filterNumbers(text)}  
                  onSave = { finalText -> vm.saveNumber(finalText) },  
                  onCheck = { text -> vm.checkNumber(text) },  
                )  
            }  
        }  
    }  
}

After some styling and adding colours, something like this can be achieved with the implementation.

Image from my current project

Conclusion

The Jetpack Compose does not provide any UI implementation for the preferences. The ViewModel contains all the logic and acts as a separation layer for the UI. Moreover, you have total control over app implementation.

Subscribe for more
LinkedIn GitHub Mail Medium