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
PreferenceFragmentCompat
was 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
PreferenceFragmentCompat
and want to switch to usingDataStore
instead ofSharedPreferences
without changing the appearance and behavior of the screen. - If you are already using
DataStore
in 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 use
runBlocking()
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
SharedPreferences
toDataStore
withSharedPreferencesMigration.
- 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 fromDataStore
to 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
PreferencesDataStore
that are available forDataStore
. For example,Double
andByteArray
. - 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