Simplifying SharedPreferences in Flutter with a few lines
Simplifying Preference Management with Custom Wrappers
When working with persistent data in Flutter apps, SharedPreferences
is a common choice. However, directly accessing and modifying preferences can quickly become verbose and repetitive, especially if the app grows and more preferences are added. In this post, we'll explore a new way to streamline preference management in Dart by introducing a wrapper around SharedPreferences
using a custom Preference
interface.
The Traditional Approach
Consider the following example using SharedPreferences
. Here, we want to check if the user has been shown a showcase tutorial, and if not, we display it and update the preference:
if (sharedPreferences.getBool("hasShowcased") != true) {
sharedPreferences.setBool("hasShowcased", true);
showShowcase();
}
This code is straightforward but has a few drawbacks:
- Verbosity: Each time we need to get or set a preference, we must remember to use the correct
SharedPreferences
method (getBool
,getString
, etc.), making the code verbose and prone to errors. - Consistency: If we need to modify or remove this preference elsewhere, we must duplicate the key (
"hasShowcased"
) and the logic each time. - Scalability: As the number of preferences grows, managing all these keys and types becomes cumbersome.
The Solution: Encapsulating Preferences
To address these issues, we can encapsulate the logic for handling preferences within a Preference
class that provides a cleaner, type-safe API. Here’s what the optimized approach looks like:
final hasShowcasedPref = sharedPreferences.asBool("hasShowcased");
if (hasShowcasedPref.value != true) {
hasShowcasedPref.value = true;
showShowcase();
}
This version has several benefits:
- Readability: The intent of the code is much clearer. By wrapping the preference in a
hasShowcasedPref
object, the code reads more naturally. - Type Safety: By defining preferences as types, we reduce the risk of errors caused by mistyped keys or incorrect type methods.
- Reusability: The same
Preference
object can be reused in different parts of the codebase, making it easier to manage preference keys consistently.
Another example:
Without Preference Delegation:
if (isLogin && sharedPreferences.getBool("hasLoginShowcased") != true) {
sharedPreferences.setBool("hasLoginShowcased", true);
showShowcase();
} else if (sharedPreferences.getBool("hasShowcased") != true) {
sharedPreferences.setBool("hasShowcased", true);
showShowcase();
}
With Preference Delegation:
final hasShowcasedPref = sharedPreferences.asBool(isLogin ? "hasLoginShowcased" : "hasShowcased");
if (hasShowcasedPref) {
hasShowcasedPref.value = true;
showShowcase();
}
Under the Hood: How It Works
To implement this approach, we create an abstract Preference
class and a concrete implementation, BasePreference
. The Preference
class defines a common interface for interacting with preferences, while BasePreference
handles the core logic. Here’s the code:
abstract class Preference<T> {
String get key;
T? get value;
set value(T? newValue);
}
class BasePreference<T> implements Preference<T> {
const BasePreference(
this.key,
this._getter,
this._setter,
this._remove,
);
@override
final String key;
final T? Function(String key) _getter;
final Future<bool> Function(String key, T value) _setter;
final Future<bool> Function(String key) _remove;
@override
T? get value => _getter(key);
@override
set(T? newValue) => newValue == null
? remove()
: _setter(key, newValue);
@override
Future<bool> remove() => _remove(key);
}
Preference<T>
: This interface defines the basic operations for accessing and modifying a preference. It ensures consistency across all preference types (likebool
,String
, etc.).
ref. https://gist.github.com/yongjhih/5d8f74c11c5331bf05b74baa133a9787
Explanation of Key Components
BasePreference<T>
: The generic class implementsPreference<T>
and uses function references (_getter
,_setter
,_remove
) to interact withSharedPreferences
functions. This way,BasePreference
can support any data type without requiring repeated code for each type.- Extension on
SharedPreferences
: We add an extension to make it easy to get instances ofPreference
for specific types, such asbool
,String
,int
,double
, andList<String>
:
extension SharedPreferencesX on SharedPreferences {
Preference<bool> asBool(String key) => asPreference(key, getBool, setBool);
Preference<String> asString(String key) => asPreference(key, getString, setString);
Preference<int> asInt(String key) => asPreference(key, getInt, setInt);
Preference<double> asDouble(String key) => asPreference(key, getDouble, setDouble);
Preference<List<String>> asStringList(String key) => asPreference(key, getStringList, setStringList);
Preference<T> asPreference<T>(String key,
T? Function(String key) getter,
Future<bool> Function(String key, T value) setter,
) => BasePreference(key, getter, setter, remove);
}
Using the New Preference API
With this wrapper in place, managing preferences becomes easier and more consistent. Here’s an example of setting and getting a string preference:
final nicknamePref = sharedPreferences.asString("nickname");
print("Hello, ${nicknamePref.value}");
nicknamePref.value = "Andrew";
Using PreferenceSwitch
in Your UI
Now let’s see how we can use PreferenceSwitch
in the main app. First, you need to initialize SharedPreferences
and create a Preference<bool>
using the asBool
extension:
Column(
children: [
ListTile(
title: const Text("Dark Mode Enabled"),
trailing:
PreferenceSwitch(
preference: sharedPreferences.asBool("darkModeEnabled"),
),
),
ListTile(
title: const Text("Nickname Enabled"),
trailing:
PreferenceSwitch(
preference: sharedPreferences.asBool("nicknameEnabled"),
),
),
],
)
Creating a PreferenceSwitch
Widget
To make it even easier to work with preferences in the UI, we can create a PreferenceSwitch
widget that combines a Switch
widget with a Preference<bool>
. This widget reads the initial preference value, displays it, and updates the preference automatically when toggled.
Here’s the code for PreferenceSwitch
:
class PreferenceSwitch extends HookWidget {
final Preference<bool> preference;
const PreferenceSwitch({
Key? key,
required this.preference,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final preferenceState = useState(preference.value ?? false);
return Switch(
value: state.value,
onChanged: (newValue) {
preferenceState.value = newValue;
preference.value = newValue;
},
);
}
}
Using this Preference
wrapper for SharedPreferences
results in a cleaner, more maintainable way to handle preferences in Flutter apps. By adding a PreferenceSwitch
widget, we can also streamline the UI, making it easier to build reusable and consistent settings components. This structure not only improves readability and consistency but also makes it easier to extend and refactor your code as your application grows.
Usage in PreferenceSwitch
You can use a asState
hook in PreferenceSwitch
as if it were useState
, but it will automatically persist changes to SharedPreferences
.
- The
asState
hook provides aValueNotifier<T?>
similar touseState
and triggers a rebuild whenever the value changes. - This implementation ensures that changes to the
ValueNotifier
automatically sync with the underlying preference, making it easy to use in Flutter widgets.
class PreferenceSwitch extends HookWidget {
...
@override
Widget build(BuildContext context) {
- final preferenceState = useState(preference.value ?? false);
+ final preferenceState = preference.asState();
return Switch(
value: state.value,
onChanged: (newValue) {
preferenceState.value = newValue; // This updates both UI and preference
- preference.value = newValue;
},
);
}
}
Implementation of asState
as a Custom Hook
To make Preference<T>.asState()
behave like useState()
, we need to create a custom hook that directly integrates with Flutter Hooks, as useState()
does. This will allow us to automatically trigger a widget rebuild when the preference value changes, while ensuring updates are persisted to SharedPreferences
.
We’ll implement asState
as a custom hook that utilizes useState
under the hood and syncs with the Preference<T>
. Here’s how:
- Define
asState
as an extension method onPreference<T>
. - Inside
asState
, useuseState
to hold the preference's value locally in the widget. - Return a tuple with the current value and a setter function that both updates
useState
(to trigger rebuilds) and updates the underlyingPreference<T>
.
Here’s how you can do this:
extension PreferenceStateX<T> on Preference<T> {
/// Custom hook to get and set the preference value, treating it like useState().
ValueNotifier<T?> asState() {
final state = useState<T?>(value); // Initialize state with preference value
// Sync changes back to the preference whenever state.value changes.
useEffect(() {
final listener = () {
value = state.value; // Update preference whenever state changes
};
state.addListener(listener);
// Clean up the listener when the widget is disposed
return () => state.removeListener(listener);
}, [state]);
return state;
}
}
- Initialize Local State:
useState<T?>
is initialized with the current preference value. - Listener for Synchronization:
useEffect
sets up a listener onstate
. Wheneverstate.value
changes, it updatesPreference.value
(saving it toSharedPreferences
). - Cleanup: The listener is removed when the hook is disposed to avoid memory leaks.
Benefits Recap
By encapsulating SharedPreferences
in a Preference
class, we gain:
- Type Safety: Each preference is guaranteed to be accessed with the correct type.
- Reusability: We can define the preference once and reuse it throughout the app, reducing the risk of inconsistencies.
- Readability: The preference API makes code cleaner and easier to understand.
- Flexibility: Adding new preference types (like
List<String>
) is straightforward and doesn’t require repeating the core logic.
Conclusion
Using this wrapper around SharedPreferences
results in a cleaner, more maintainable, and more scalable way to handle preferences in Flutter apps. This structure not only improves readability and consistency but also makes it easier to extend and refactor your code as your application grows.