
I recently decided to migrate the Settings screen from SharedPreferences to DataStore for one of my projects. This screen was built with PreferenceFragmentCompat, which is quite handy and allows you to easily organize settings. While it may not be the most modern way to build a Settings screen in 2024, it remains the recommended approach according to the official documentation.
The problem is that
PreferenceFragmentCompatwas designed to work with SharedPreferences, which only provides a synchronous way of managing data
In contrast, DataStore focuses on an asynchronous approach. Since DataStore was released and became the preferred way to handle simple key-value data instead of SharedPreferences, PreferenceFragmentCompat has not received any built-in support for it.
I had been looking for a way to solve this problem and found two possible solutions. The first solution is to keep PreferenceFragmentCompat and make it work with data from DataStore. The second solution is to thank PreferenceFragmentCompat for its many years of service, finally retire it, and replace it with a modern settings screen based on Compose, emulating the appearance of PreferenceFragmentCompat with Material Components. As is often the case, both approaches have their pros and cons. In this article, let’s focus on the first solution.
Who might find this post useful
- If you already have a Settings screen built with
PreferenceFragmentCompatand want to switch to usingDataStoreinstead ofSharedPreferenceswithout changing the appearance and behavior of the screen. - If you are already using
DataStorein your app and want to create a Settings screen to access these settings.
All the code referenced in this article is available on Github.
Preferences library opportunities
Let’s start from the Preferences library. There are several components that we usually use for building a Settings screen, such as ListPreference, SwitchPreference, and more. Under the hood, these components save data into SharedPreferences if PreferenceDataStore is not defined. Don’t confuse this with DataStore from androidx.datastore, which we will use later. Let’s take a look at the official documentation for PreferenceDataStore:
In most cases you want to use
android.content.SharedPreferences as it is automatically backed up and migrated to new devices. However, providing custom data store to preferences can be useful if your app stores its preferences in a local database, cloud, or they are device specific like “Developer settings”.Once a put method is called it is the full responsibility of the data store implementation to safely store the given values. Time expensive operations need to be done in the background to prevent from blocking the UI.
This seems to fit our case. We can define our own PreferenceDataStore with DataStore under the hood and use it instead of SharedPreferences:
| class UserPreferencesDataStore(val dataStore: DataStore<Preferences>): PreferenceDataStore() { | |
| override fun putString(key: String?, value: String?) { ... } | |
| override fun getString(key: String?, defValue: String?): String? { ... } | |
| // Put and get methods for other types: Int, Long, etc. | |
| } |
In PreferenceDataStore, every get method returns a default value and every put method throws an exception by default:
| public void putString(String key, @Nullable String value) { | |
| throw new UnsupportedOperationException("Not implemented on this data store"); | |
| } | |
| @Nullable | |
| public String getString(String key, @Nullable String defValue) { | |
| return defValue; | |
| } |
So, don’t forget to override all PreferenceDataStore methods in UserPreferencesDataStore to avoid exceptions and unpredictable behavior.
DataStore library opportunities
We have found a way to work with PreferencesFragment using a custom PreferenceDataStore. Now, let’s check the official documentation of DataStore to set it up properly. It says:
This might be the case if you’re working with an existing codebase that uses synchronous disk I/O or if you have a dependency that doesn’t provide an asynchronous API.
Kotlin coroutines provide the
runBlocking() coroutine builder to help bridge the gap between synchronous and asynchronous code. You can userunBlocking()to read data from DataStore synchronously.
val exampleData = runBlocking { context.dataStore.data.first() }
Considering that the Preferences library is designed for synchronous work, using runBlocking is the only way to make Preferences and DataStore work together.
Make them work together
Let’s use DataStore to provide our preferences through UserPreferencesDataStore:
| class UserPreferencesDataStore( | |
| private val dataStore: DataStore<Preferences>, | |
| private val coroutineScope: CoroutineScope | |
| ): PreferenceDataStore() { | |
| override fun putString(key: String?, value: String?) { | |
| if (key != null) { | |
| coroutineScope.launch { | |
| dataStore.edit { preferences -> | |
| if (value != null) preferences[stringPreferencesKey(key)] = value | |
| else preferences.remove(stringPreferencesKey(key)) | |
| } | |
| } | |
| } | |
| } | |
| override fun getString(key: String?, defValue: String?): String? = key?.let { | |
| runBlocking { dataStore.data.first()[stringPreferencesKey(it)] ?: defValue } | |
| } ?: defValue | |
| // Put and get methods for other types: Int, Long, etc. | |
| } |
Now we can set this custom PreferenceDataStore to PreferencesManager in the fragment. I’m using Hilt to provide dependencies:
| @AndroidEntryPoint | |
| class UserPreferencesFragment : PreferenceFragmentCompat() { | |
| @Inject lateinit var dataStore: DataStore<Preferences> | |
| override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { | |
| // Set custom PreferenceDataStore to work with DataStore | |
| preferenceManager.preferenceDataStore = UserPreferencesDataStore( | |
| dataStore = dataStore, | |
| coroutineScope = lifecycleScope | |
| ) | |
| // The rest of the code can remain unchanged | |
| // Example of setting up preferences programmatically, | |
| // I don't recommend using XML here because it is very limited in terms of types | |
| val booleanSection = PreferenceCategory(context).apply { | |
| key = PreferenceKey.KEY_BOOLEAN_SECTION | |
| title = "Boolean section" | |
| } | |
| screen.addPreference(booleanSection) | |
| booleanSection.addPreference( | |
| SwitchPreference(context).apply { | |
| key = PreferenceKey.KEY_BOOLEAN_PREFERENCE | |
| title = "Select boolean" | |
| } | |
| ) | |
| } | |
| } |
I added another fragment to simulate a real case, where we set some preferences on a settings screen and expect to get this data updated in the rest of our app. In a real app, we also don’t work directly with DataStore, so I added UserPreferencesRepo to provide a code structure that is closer to real apps for this example:
| class UserPreferencesRepo( | |
| dataStore: DataStore<Preferences>, | |
| ) { | |
| val userPreferencesFlow: Flow<UserPreferences> = dataStore.data | |
| .catch { exception -> | |
| if (exception is IOException) { | |
| emit(emptyPreferences()) | |
| } else { | |
| throw exception | |
| } | |
| }.map { preferences -> | |
| val booleanPref = preferences[booleanPreferencesKey(PreferenceKey.KEY_BOOLEAN_PREFERENCE)] ?: false | |
| val intPref = preferences[intPreferencesKey(PreferenceKey.KEY_INT_PREFERENCE)] ?: 0 | |
| // get other prefs here | |
| UserPreferences(booleanPref, intPref, ...) | |
| } | |
| } |
Job Offers
Read data from this repo with ViewModel for the second fragment and display it in UI. Let’s take a look at the final result of working of both fragments with the same DataStore:

Things to note
You probably noticed that I added different data types on the screen. Here is another catch: if you want to keep the type of your data from DataStore consistent with the Settings screen, you also need to do extra things.
Out of the box, the Preferences library provides several types of UI elements for selecting preferences: EditTextPreference, ListPreference, SwitchPreferenceCompat, and so on. I’m willing to bet that the most popular ones are Switch and List! In the case of SwitchPreference, it’s expected that it persists a value of Boolean type, but ListPreference works only with String arrays.
Imagine that you have a video app where the user can choose the playback speed for a video. You have a predefined set of values for this feature. For example:
val playbackSpeed = mapOf(
"0,5x" to 0.5f,
"1x" to 1.0f,
"1,5x" to 1.5f,
"2x" to 2.0f
)
The playback speed is represented with a float value because the video player works with this type. You have already used DataStore to persist the last used playback speed, but you want to allow users to choose their preferred playback speed from your Settings screen. If you built it using the Preferences library, you have a problem because you can’t use the common ListPreference for this. You’ll just get a ClassCastException.
Of course, you can keep all your preferences as strings and cast them to the type you need; it’s up to you. But there is another way. We can delegate this work to a custom ListPreference that handles all these tasks under the hood and persists values in the specified type. To represent all possible data types, I created an abstract class to reduce the amount of code for each of its inheritors:
| abstract class TypedListPreference<T> : ListPreference { | |
| constructor(context: Context) : super(context) | |
| // other common view constructors | |
| private var defaultValue: T? = null | |
| abstract val fromStringToType: (String) -> T? | |
| abstract val persistType: (T) -> Boolean | |
| abstract val getPersistedType: (T) -> T | |
| // ListPreference calls persistString for the chosen value by default, but | |
| // if we have a specified type and type converter, we can safely convert | |
| // the string to the specified type and persist the value with this type | |
| override fun persistString(value: String?): Boolean { | |
| val typeValue = value?.let { fromStringToType(it) } ?: return false | |
| return persistType(typeValue) | |
| } | |
| // To show the value in the UI, ListPreference needs to get a string. | |
| // This is the easy part - just get the specified type and convert it | |
| // to a string | |
| override fun getPersistedString(defaultReturnValue: String?): String { | |
| return defaultValue?.let { | |
| getPersistedType(it).toString() | |
| } ?: "" | |
| } | |
| // Set entry values by transforming our typed values to the String type | |
| fun setTypedValues(typedValues: Collection<T>, defaultValue: T?) { | |
| this.defaultValue = defaultValue | |
| val entryValues = typedValues.map { it.toString() }.toTypedArray() | |
| super.setEntryValues(entryValues) | |
| } | |
| } |
And here is an example of a custom ListPreference for float data:
| class FloatListPreference: TypedListPreference<Float> { | |
| // common view constructors | |
| override val fromStringToType: (String) -> Float? = String::toFloatOrNull | |
| override val persistType: (Float) -> Boolean = ::persistFloat | |
| override val getPersistedType: (Float) -> Float = ::getPersistedFloat | |
| } |
Its usage in UserPreferencesFragment:
| override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { | |
| // setup of datastore and other sections... | |
| val floatSection = PreferenceCategory(context).apply { | |
| key = PreferenceKey.KEY_FLOAT_SECTION | |
| title = "Float section" | |
| } | |
| val playbackSpeedDefault = playbackSpeed["1x"] | |
| floatSection.addPreference( | |
| FloatListPreference(context).apply { | |
| key = PreferenceKey.KEY_PLAYBACK_SPEED | |
| entries = playbackSpeed.keys.toTypedArray() | |
| setTypedValues(playbackSpeed.values, playbackSpeedDefault) | |
| } | |
| ) | |
| } |
Pros
- We don’t change the appearance or behavior of the Settings screen; it still looks and works as usual. We only change the way we handle the data that this screen manages.
- The Preference library is still a quick and handy way to build the Settings screen for an app, and we use it here.
- We use only official APIs without third-party libraries.
- There is an option to move existing data from
SharedPreferencestoDataStorewithSharedPreferencesMigration. - It is possible to support most data types with a custom
ListPreference. - It is almost effortless to set up (a bit more work if you want to use a custom
ListPreference).
Cons
- Fully synchronous code on the Settings screen, which negates all the benefits of asynchronous work with
DataStore. - Blocks the main thread to read data from
DataStore. I haven’t compared the performance yet, but the official docs suggest preloading data fromDataStoreto speed up reading from it. Perhaps it could offset this disadvantage. - Relies on the internal behavior of some classes, such as
ListPreference. - Can’t support some data types for
PreferencesDataStorethat are available forDataStore. For example,DoubleandByteArray. - Can’t support most data types when building settings with XML.
Conclusion
The aim of this article was not to prescribe a specific solution but to explore the feasibility of integrating two official libraries recommended for handling the UI of preferences and key-value data, respectively. Despite their official status, these libraries lack built-in support for one another. In the second part of this series, I will explore how to implement a modern settings screen with Compose and DataStore, without using Preference UI. You can find the full example of the sample app for this article on GitHub. Thank you for reading, and feel free to leave a comment!
Resources
- https://developer.android.com/reference/kotlin/androidx/preference/PreferenceDataStore
- https://developer.android.com/topic/libraries/architecture/datastore
- https://developer.android.com/develop/ui/views/components/settings/components-and-attributes
- https://developer.android.com/reference/kotlin/androidx/datastore/migrations/SharedPreferencesMigration
This article is previously published on proandroiddev.com



