How to use DataStore with StateFlow and Jetpack Compose?

4.1k Views Asked by At

I try to give the user the choice whether to use the UI Mode 'light', 'dark' or the 'system' settings. I would like to save the selection as DataStore.

The drop down menu for the user selection is not loading the value from DataStore. It is always showing the initialvalue from stateIn() when loading the screen.

SettingsManager:

val Context.dataStoreUiSettings: DataStore<Preferences> by preferencesDataStore(name = DATA_STORE_UI_NAME)

object PreferencesKeys {
    val UI_MODE: Preferences.Key<Int> = intPreferencesKey("ui_mode")
}

class SettingsManager @Inject constructor(private val context: Context) { //private val dataStore: DataStore<Preferences>
    private val TAG: String = "UserPreferencesRepo"

    // Configuration.UI_MODE_NIGHT_UNDEFINED, Configuration.UI_MODE_NIGHT_YES, Configuration.UI_MODE_NIGHT_NO
    suspend fun setUiMode(uiMode: Int) {
        context.dataStoreUiSettings.edit { preferences ->
            preferences[UI_MODE] = uiMode
        }
    }
    fun getUiMode(key: Preferences.Key<Int>, default: Int): Flow<Int> {
        return context.dataStoreUiSettings.data
            .catch { exception ->
                if (exception is IOException) {
                    Timber.i("Error reading preferences: $exception")
                    emit(emptyPreferences())
                } else {
                    throw exception
                }
            }
            .map { preference ->
                preference[key] ?: default
            }
    }

    fun <T> getDataStore(key: Preferences.Key<T>, default: Any): Flow<Any> {
        return context.dataStoreUiSettings.data
            .catch { exception ->
                if (exception is IOException) {
                    Timber.i("Error reading preferences: $exception")
                    emit(emptyPreferences())
                } else {
                    throw exception
                }
            }
            .map { preference ->
                preference[key] ?: default
            }
    }

    suspend fun clearDataStore() {
        context.dataStoreUiSettings.edit { preferences ->
            preferences.clear()
        }
    }

    suspend fun removeKeyFromDataStore(key: Preferences.Key<Any>) {
        context.dataStoreUiSettings.edit { preference ->
            preference.remove(key)
        }
    }
}

ViewModel:

@HiltViewModel
class SettingsViewModel @Inject constructor(
    private val settingsUseCases: SettingsUseCases,
    private val settingsManager: SettingsManager,
) : ViewModel() {
    private val _selectableUiModes = mapOf(
        UI_MODE_NIGHT_UNDEFINED to "System",
        UI_MODE_NIGHT_NO to "Light",
        UI_MODE_NIGHT_YES to "Dark"
    )
    val selectableUiModes = _selectableUiModes

    val currentUiMode: StateFlow<Int?> = settingsManager.getUiMode(UI_MODE, UI_MODE_NIGHT_UNDEFINED).stateIn(
        scope = viewModelScope,
        started = WhileSubscribed(5000),
        initialValue = null,
        )

    init {
        Timber.i("SettingsViewModel created")
    }

    fun setUiMode(uiModeKey: Int) {
        viewModelScope.launch(Dispatchers.IO) {
            settingsManager.setUiMode(uiModeKey)
        }
    }
}

Compose:

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun OutlinedDropDown(
    modifier: Modifier = Modifier,
    readOnly: Boolean = true,
    isEnabled: Boolean = true,
    isError: Boolean = false,
    settingsViewModel: SettingsViewModel = hiltViewModel(),
) {
    val items: Map<Int, String> = settingsViewModel.selectableUiModes
    var expanded by remember { mutableStateOf(false) }

    val selectedItemIndex = settingsViewModel.currentUiMode
    var selectedText by remember { mutableStateOf(if (selectedItemIndex.value == null) "" else items[selectedItemIndex.value]) }
    val optionList by remember { mutableStateOf(items) }


    Column {
        ExposedDropdownMenuBox(
            expanded = expanded,
            onExpandedChange = {
                expanded = !expanded
            }
        ) {
            OutlinedTextField(
                isError = isError,
                enabled = isEnabled,
                modifier = modifier,
                readOnly = readOnly,
                value = selectedText!!,
                onValueChange = {
                    selectedText = it
                },
                trailingIcon = {
                    ExposedDropdownMenuDefaults.TrailingIcon(
                        expanded = expanded
                    )
                }
            )
            ExposedDropdownMenu(
                expanded = expanded,
                onDismissRequest = {
                    expanded = false
                }
            ) {
                optionList.forEach { selectionOption ->
                    DropdownMenuItem(
                        onClick = {
                            selectedText = selectionOption.value
                            settingsViewModel.setUiMode(selectionOption.key)
                            expanded = false
                        }
                    ) {
                        Text(text = selectionOption.value)
                    }
                }
            }
        }
    }
}

Why is the value not getting updated for currentUiMode? I don't want to use LiveData for it.

1

There are 1 best solutions below

2
On BEST ANSWER

If anyone found this answer when looking for the Compose DataStore wrapper, check out this answer.


The only thing that can cause recomposition in Compose is changing the State object.

Simply emitting to a flow will not do that. You can collect the flow using collectAsState, which is a mapper from Flow to State. With Flow you need a default value, as it doesn't have current value, but with StateFlow you don't need that.

Another problem in your code is that with remember { mutableStateOf... only the first value is remembered, selectedText will not be updated with selectedItemIndex. Generally, you could pass it as a parameter to remember, or use derivedStateOf, but in this particular case there is no need to use remember&mutableStateOf at all, as in the case of optionList, since these are static values and selectedItemIndex is not gonna be updated any often.

remember&mutableStateOf should only be used when you need to change some value with a side effect, such as a button click. See this answer for how that works. You can also use remember without mutableStateOf if you don't want to repeat calculations of medium severity - don't do really heavy calculations without side effects or background thread.

So the following should work for you:

var expanded by remember { mutableStateOf(false) }

val selectedItemIndex by settingsViewModel.currentUiMode.collectAsState()
var selectedText = if (selectedItemIndex == null) "" else items[selectedItemIndex]
val optionList: Map<Int, String> = settingsViewModel.selectableUiModes