Making Extensible Settings Screen in Jetpack Compose from Scratch

How to create simple extensible settings screen in your app

Tomáš Repčík
10 min readMar 27, 2023

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

Thanks for reading, and if you liked the article, do not forget to clap in the range of 1 to 50!

For more posts about Android, go here:

Android development

21 stories

--

--

Tomáš Repčík

https://tomasrepcik.dev/ - Flutter app developer with experience in native app development and degree in biomedical engineering.