Blog Infos
Author
Published
Topics
, , , ,
Published

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 SharedPreferencesPreferenceFragmentCompat 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 using DataStore instead of SharedPreferences 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 ListPreferenceSwitchPreference, 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

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: EditTextPreferenceListPreferenceSwitchPreferenceCompat, 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 to DataStore with SharedPreferencesMigration.
  • 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 from DataStore 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 for DataStore. For example, Double and ByteArray.
  • 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

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Using annotations in Kotlin has some nuances that are useful to know
READ MORE
blog
One of the latest trends in UI design is blurring the background content behind the foreground elements. This creates a sense of depth, transparency, and focus,…
READ MORE
blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
The suspension capability is the most essential feature upon which all other Kotlin Coroutines…
READ MORE
Menu