Building an Android Settings Screen (Part 3)

How to Create Custom Preferences

Fixing and Extending the android.support.v7.preference Library

In the first part of this tutorial we already built and themed our Settings Screen and in the second part we fixed the layout and the theme of the dialogs. Now is the time to extend the the v7.preference library to build a custom preference. So let’s get started.


Understanding How the Library Works

Since the v7.preference library only provides four basic preferences (five when you include the v14.preference library), you are likely to need a custom preference. But you should not start coding without knowing what you have to do. So let’s take a look at the structure of the v7.preference library to understand how it works. Because the following explains the basics of what we do later, you should read this carefully. (If you are crazy and want to discover this by your own, you can find the source code right here). I will only focus on the important things.

How the Library Is Structured

The library structure (simplified)

As you can see in the image, there are four important main classes we should know:

  • PreferenceFragmentCompat: This is the fragment for our main Settings Screen. (Notice that this is abstract class, which means we can not instantiate it directly, but we can extend it).
  • Preference: This is a basic preference which can appear on our Settings Screen. All predefined preferences inherit (indirectly) from this class.
  • PreferenceDialogFragmentCompat: This is a basic dialog for a preference. All dialogs of the preferences inherit from this class. (Notice that this is abstract class, which means we can not instantiate it directly, but we can extend it. And yes, this must be the longest class name ever.)
  • PreferenceManager: This provides the access to the SharedPreferences. The PreferenceFragmentCompat and all Preferences that belong to it, share the same PreferenceManger.

Preferences are separated in two different types: The TwoStatePreferences which can only toggle and store a boolean value and the DialogPreferences which provide a dialog, the user can interact with. (Notice that these two classes are abstract).

How the Dialogs Are Opened

The dialog classes are separated from their related DialogPreference classes. For example, we have the EditTextPreference and its related dialog EditTextDialogFragmentCompat in two separated classes. This means, that the dialog must explicitly be opened somewhere. When we read through the source of the DialogPreference (You can find it here) we can discover the following piece of code.

@Override
protected void onClick() {
getPreferenceManager().showDialog(this);
}

And in the PreferenceManager we can find the following pieces of code.

public void showDialog(Preference preference) {
if (mOnDisplayPreferenceDialogListener != null) {
mOnDisplayPreferenceDialogListener
.onDisplayPreferenceDialog(preference);
}
}
...
public interface OnDisplayPreferenceDialogListener {
void onDisplayPreferenceDialog(Preference preference);
}

It says us, that if we click on a DialogPreference, it calls a method in the PreferenceManger to show the dialog for this preference. The PreferenceManager then redirects the call to a registered Listener. The PreferenceFragmentCompat implements the interface provided by the PreferenceManager, so it can register itself as the Listener for the dialogs.

In summary, when we click on a DialogPreference, we end up in the onDisplayPreferenceDialog(Preference preference) method of the PreferenceFragmentCompat, which we need to override to open a custom dialog.


Building a Custom Preference

I have decided to create a custom TimePreference as an example here. It will open a dialog and let the user select a specific time.

When you want to create a preference that is very similar to an existing one, you can perhaps extend and modify the existing preference. For example when you want a NumberPreference, you can extend the EditTextPreference and modify it, that it only allows the user to type numbers. I will directly extend the class DialogPreference.

Warning, the following will contain a lot of code and nearly no images.

Building Our Dialog’s Layout

To create our dialogs layout, we create a new layout resource file called pref_dialog_time.xml. The only thing we need for our dialog, is a TimePicker. So we add it as the root view to our layout file. We then apply the theme modifications from the second part of this tutorial (I have highlighted them).

<?xml version="1.0" encoding="utf-8"?>
<TimePicker
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/edit"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="@dimen/alert_def_padding"
android:paddingBottom="@dimen/alert_def_padding"
android:theme="@style/AppAlertDialogContent"
/>

Don’t forget to add the id edit. If you do, your App will crash later.

Building Our Preference

Now we can create our custom preference. Because we want that our preference opens a dialog with the TimePicker, we need to create a new class called TimePreference which extend DialogPreference.

import android.support.v7.preference.DialogPreference;
public class TimePreference extends DialogPreference {
...
}

When we have done this, we can add our preference’s logic. We start with the global variables which we need in our TimePreference: The TimePicker in our dialog can give us the selected hour and the selected minute as integers. To store this values in a single SharedPreference I decided to convert the time to minutes. I also decided to store the reference to the dialog’s layout in a global variable. That makes it easier to use. Add this to your TimePreference class:

private int mTime;
private int mDialogLayoutResId = R.layout.pref_dialog_time;

Now let’s move to the constructors. We start with the one with the fewest parameters and call the next higher constructor with a default value for the missing attribute. We do this until we reach the last constructor. There we can process all the things we want to do. We can for example read attributes from the AttributeSet. Add this to your TimePreference class:

public TimePreference(Context context) {
this(context, null);
}
public TimePreference(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public TimePreference(Context context, AttributeSet attrs,
int defStyleAttr) {
this(context, attrs, defStyleAttr, defStyleAttr);
}
public TimePreference(Context context, AttributeSet attrs,
int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);

// Do custom stuff here
// ...
// read attributes etc.

}

Quick note: When you replace the 0 in the second constructor with R.attr.dialogPreferenceStyle (For a DialogPreference) or R.attr.preferenceStyle (For any other preference) you won’t face any design issues later. Thanks Ivan Soriano

Next, we need two methods. One to save the time to the SharedPreferences and one read the current value. We later call these methods from our dialog. Add this to your TimePreference class:

public int getTime() {
return mTime;
}
public void setTime(int time) {
mTime = time;
    // Save to Shared Preferences
persistInt(time);
}

Now we need to override some other methods. First we need one to read the default value (we can define one with the android:defaultValue attribute when we use our Preference in our xml/app_preferences.xml). The second method reads our stored value from the SharedPreferences and saves it to the mTime variable. Add this to your TimePreference class:

@Override
protected Object onGetDefaultValue(TypedArray a, int index) {
// Default value from attribute. Fallback value is set to 0.
return a.getInt(index, 0);
}
@Override
protected void onSetInitialValue(boolean restorePersistedValue,
Object defaultValue) {
// Read the value. Use the default value if it is not possible.
setTime(restorePersistedValue ?
getPersistedInt(mTime) : (int) defaultValue);
}

The last thing we need to do, is to set the layout resource for our dialog. We do this by overriding the getDialogLayoutResource method. Add this to your TimePreference class:

@Override
public int getDialogLayoutResource() {
return mDialogLayoutResId;
}

Building the Dialog

A small picture to remember how our result should look. Only in case you forgot it after this endless explanation of the code.

Now let us create a dialog like in the picture.

If you have read carefully (I can understand you if you haven’t), you should know that all preference dialogs inherit from one abstract class called PreferenceDialogFragmentCompat. So we create a new class called TimePreferenceFragmentCompat which extends this class.

import android.support.v7.preference.PreferenceDialogFragmentCompat;
public class TimePreferenceDialogFragmentCompat
extends PreferenceDialogFragmentCompat {
   ...
}

We don’t need a special constructor, but we need a static method that creates a new instance of our TimePreferenceFragmentCompat. To know to which preference this new dialog belongs, we add a String parameter with the key of the preference to our method and pass it (inside a Bundle) to the dialog. We will use this static method later. Add this to your TimePreferenceFragmentCompat class:

public static TimePreferenceDialogFragmentCompat newInstance(
String key) {
final TimePreferenceDialogFragmentCompat
fragment = new TimePreferenceDialogFragmentCompat();
final Bundle b = new Bundle(1);
b.putString(ARG_KEY, key);
fragment.setArguments(b);

return fragment;
}

Now we need to do something with our TimePicker. We want that it always shows the time that was stored in the SharedPreferences. We can access the TimePicker from our created layout, after it was added to the dialog. We can do this in the onBindDialogView method. The getPreference method returns the preference which opened the dialog. Add this to your TimePreferenceFragmentCompat class:

@Override
protected void onBindDialogView(View view) {
super.onBindDialogView(view);

mTimePicker = (TimePicker) view.findViewById(R.id.edit);

// Exception when there is no TimePicker
if (mTimePicker == null) {
throw new IllegalStateException("Dialog view must contain" +
" a TimePicker with id 'edit'");
}

// Get the time from the related Preference
Integer minutesAfterMidnight = null;
DialogPreference preference = getPreference();
if (preference instanceof TimePreference) {
minutesAfterMidnight =
((TimePreference) preference).getTime();
}

// Set the time to the TimePicker
if (minutesAfterMidnight != null) {
int hours = minutesAfterMidnight / 60;
int minutes = minutesAfterMidnight % 60;
boolean is24hour = DateFormat.is24HourFormat(getContext());

mTimePicker.setIs24HourView(is24hour);
mTimePicker.setCurrentHour(hours);
mTimePicker.setCurrentMinute(minutes);
}
}

Every time we open the dialog, it now displays the time which is stored in the SharedPreferences (We still have to do something before we can actually open the dialog).

The last thing for our dialog is, that it should save the selected time when we click the OK button (positive result). For this, we override the onDialogClosed method. First we calculate the minutes we want to save, and after that, we get our related preference and call the setTime method we have defined there. Add this to your TimePreferenceFragmentCompat class:

@Override
public void onDialogClosed(boolean positiveResult) {
if (positiveResult) {
// generate value to save
int hours = mTimePicker.getCurrentHour();
int minutes = mTimePicker.getCurrentMinute();
int minutesAfterMidnight = (hours * 60) + minutes;

// Get the related Preference and save the value
DialogPreference preference = getPreference();
if (preference instanceof TimePreference) {
TimePreference timePreference =
((TimePreference) preference);
// This allows the client to ignore the user value.
if (timePreference.callChangeListener(
minutesAfterMidnight)) {
// Save the value
timePreference.setTime(minutesAfterMidnight);
}
}
}
}

FINALLY, we are done with the dialog.

Let it Open the Dialog

There is only one thing left to make it work. If you have read the first part, you know that the dialog must explicitly be opened somewhere. The onDisplayPreferenceDialog method in the PreferenceFragmentCompat is this place. Now go to your SettingsFragment class (it extends PreferenceFragmentCompat) and add the following method. We first try if the Preference that wants to open a dialog is one of our custom preferences. If it is one, we create a new instance of the related dialog (and pass the preference key to it) and open it. If it is not one of our custom preferences, we call the method of the super class which handles everything for the predefined DialogPreferences.

@Override
public void onDisplayPreferenceDialog(Preference preference) {
// Try if the preference is one of our custom Preferences
DialogFragment dialogFragment = null;
if (preference instanceof TimePreference) {
// Create a new instance of TimePreferenceDialogFragment with the key of the related
// Preference
dialogFragment = TimePreferenceDialogFragmentCompat
.newInstance(preference.getKey());
}

// If it was one of our cutom Preferences, show its dialog
if (dialogFragment != null) {
dialogFragment.setTargetFragment(this, 0);
dialogFragment.show(this.getFragmentManager(),
"android.support.v7.preference" +
".PreferenceFragment.DIALOG");
}
// Could not be handled here. Try with the super method.
else {
super.onDisplayPreferenceDialog(preference);
}
}

Adding it to the Settings Screen

And now, after a lot of coding, we finally have our own preference. We can add it to our xml/app_preferences.xml like this (replace your.package and the key):

<your.package.TimePreference
android:key="key4"
android:title="Time Preference"
android:summary="Time Summary"
android:defaultValue="90" />

And when we open it, it looks like this… (If you have changed the constructors according to the note, you can skip this part)

TimePreference with layout and design issues

Wait, WHAT? There are still layout and design issues? Even after applying all the fixes from part 1 and part 2 of this tutorial? YES…

Fixing the Layout and Design

Fortunately, the solution is relatively simple, when someone tells you how it works. I had to find it out by myself, but you are lucky and I will tell you.

Go to your your styles.xml and add two new styles. The first AppPreference will change the layout of the preference on the Settings Screen to material design. The second one, AppPreference.DialogPreference, inherits from the first and defines the text for the dialog buttons.

<!-- Style for an Preference Entry -->
<style name="AppPreference">
<item name="android:layout">@layout/preference_material</item>
</style>

<!-- Style for a DialogPreference Entry -->
<style name="AppPreference.DialogPreference">
<item name="positiveButtonText">@android:string/ok</item>
<item name="negativeButtonText">@android:string/cancel</item>
</style>

After doing this, you can add the style to your custom preference in xml/app_preferences.xml. For every custom preference which extends DialogPreference you can set AppPreference.DialogPreference as the style. For all others you can use AppPreference. Everything should then automatically be fixed.

<your.package.TimePreference
android:key="key4"
android:title="Time Preference"
android:summary="Time Summary"
android:defaultValue="90"
style="@style/AppPreference.DialogPreference" />

And now, our result looks like this.

Our final custom TimePreference

Wow, this was a long and hard way to build a Settings Screen. You should now know, how to apply material design everywhere and how to build custom preferences. I hope I haven’t done mistakes in this part of the tutorial.

I recommend you to always have a look at the Android Developers site here, and the site with the source code of the v7.preference library here. When you need to build your own preference, just look how they have build the predefined ones and which methods you can use.



You can have a look at the project files on GitHub.

Thanks for reading and happy coding 💻.