Pages

Saturday, 15 December 2012

Modular Approach to Implementing Android Preferences

This article details the solution I engineered to implement a modular approach for handling Android preferences. The objective is to display common preferences in an Android library on the same screen as custom preferences that are specific to the game (app) that uses that library.

I came up with this solution because I am building an augmented reality game engine library containing common code that I plan to use with multiple game projects. On the options screen I want to display both preferences common to all games (and thus the code was in the library itself) and also preferences specific to each game with an efficient way of implementing it in the game project with a minimal amount of coding.

The end result is illustrated in the following image.


The preference category titles and preference widgets (the checkboxes) that are in the red squares are settings common to all games, and thus are implemented in the game library. The green squares are preferences/titles that are specific to that particular game, and thus are implemented in the game project code.

I will explain below the important files in the game and game library projects. Download links for both projects are available at the bottom.

The Game Project


First we have the the Java class implementation that extends the AbstractOptionsScreenActivity abstract class in the game library which is where most of the Java code is. By just calling the super.onCreate method all the hard work is done for us as soon as the activity is created.

Optionally, you may implement some code in the abstract addExtraCustomPreferenceResources like I have here. This example will add a new CustomPreferenceScreen object to the array list that is referenced when the list of preferences is created. The object contains the file name of an XML resource file that defines a preference screen and the title of the preference category under which all the preferences inside the resource file will be added to.
public class OptionsScreenActivityImpl extends AbstractOptionsScreenActivity {
 @Override
 public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
 }

 @Override
 protected void addExtraCustomPreferenceResources() {
  mCustomPreferenceScreensList.add(new CustomPreferenceScreen("test_preferences", "My New Category"));
 }
}

The above code tells the library that the game project has a test_preferences.xml file in the project's res/xml folder and to add all its children preferences under a new preference category titled "My New Category". The current implementation of the game library adds 4 default preference categories - Game Settings, Graphics, Audio and Reporting Problems - to the list. Any new preference categories defined in addExtraCustomPreferenceResources will be rendered at the bottom of the list. The order that the CustomPreferenceScreen objects are added to the list will reflect the order they are appended to the screen.

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" >

    <CheckBoxPreference
        android:defaultValue="false"
        android:key="test key"
        android:summary="test summary"
        android:title="test title" >
    </CheckBoxPreference>

</PreferenceScreen>

The 2 preference categories, Game Settings and Graphics, are created by the library but it is possible to define preference widgets in the XML resource files in the game project as this example does. There are files with the same name in the game library project but the game project's versions take precedence when the game runs. The game library will add the preference widgets defined in the game project's implementation of these files.

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" >

    <CheckBoxPreference
        android:defaultValue="false"
        android:key="options_screen_prefs_description_game_setting"
        android:summary="@string/options_screen_prefs_description_game_setting"
        android:title="@string/options_screen_prefs_name_game_setting" >
    </CheckBoxPreference>

</PreferenceScreen>

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" >

    <!-- My Game specific graphic setting 1 -->

    <CheckBoxPreference
        android:defaultValue="false"
        android:key="options_screen_prefs_description_game_graphics1"
        android:summary="@string/options_screen_prefs_description_game_graphics1"
        android:title="@string/options_screen_prefs_name_game_graphics_setting1" >
    </CheckBoxPreference>

    <!-- My Game specific graphic setting 2 -->

    <CheckBoxPreference
        android:defaultValue="false"
        android:key="options_screen_prefs_description_game_graphics2"
        android:summary="@string/options_screen_prefs_description_game_graphics2"
        android:title="@string/options_screen_prefs_name_game_graphics_setting2" >
    </CheckBoxPreference>

</PreferenceScreen>


The Game Library Project


The root_preferences.xml defines the top level preference screen on the options screen. Here we define the 4 default preference categories (Game Settings, Graphics, Audio and Reporting Problems). Note that the Game Settings doesn't have any common preference widgets defined in the library (but it would be okay to add some). The Graphics has the single "enable Augmented Reality" checkbox preference widget. Both categories have other widgets added that are defined in the game project. The other categories, Audio and Reporting Problems, are common to all games and are not modified by the game project at all.
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" >

    <!-- Game settings -->

    <PreferenceCategory
        android:key="pref_key_game_settings"
        android:title="@string/options_screen_prefs_category_title_game_settings" >
    </PreferenceCategory>

    <!-- Graphics settings -->

    <PreferenceCategory
        android:key="pref_key_graphics_settings"
        android:title="@string/options_screen_prefs_category_title_graphics" >

        <!-- Enable augmented reality display -->

        <CheckBoxPreference
            android:defaultValue="false"
            android:key="pref_key_graphics_settings_ar_enable"
            android:summary="@string/options_screen_prefs_description_ar"
            android:title="@string/options_screen_prefs_name_ar" >
        </CheckBoxPreference>
    </PreferenceCategory>

    <!-- Audio settings -->

    <PreferenceCategory
        android:key="pref_key_audio_settings"
        android:title="@string/options_screen_prefs_category_title_audio" >

        <!-- Enable sound effects -->

        <CheckBoxPreference
            android:defaultValue="false"
            android:key="pref_key_audio_settings_sound_effects_enable"
            android:summary="@string/options_screen_prefs_description_sound_effects"
            android:title="@string/options_screen_prefs_name_sound_effects" >
        </CheckBoxPreference>

        <!-- Enable background music -->

        <CheckBoxPreference
            android:defaultValue="false"
            android:key="pref_key_audio_settings_background_music_enable"
            android:summary="@string/options_screen_prefs_description_background_music"
            android:title="@string/options_screen_prefs_name_background_music" >
        </CheckBoxPreference>
    </PreferenceCategory>

    <!-- Reporting problems settings -->

    <PreferenceCategory
        android:key="pref_key_reporting_problems_settings"
        android:title="@string/options_screen_prefs_category_title_reporting_problems" >

        <!-- Enable automatic error reporting -->

        <CheckBoxPreference
            android:defaultValue="false"
            android:key="pref_key_reporting_problems_settings_automatic_error_reporting_enable"
            android:summary="@string/options_screen_prefs_description_automatic_error_reporting"
            android:title="@string/options_screen_prefs_name_automatic_error_reporting" >
        </CheckBoxPreference>

        <!-- Enable trace logging -->

        <CheckBoxPreference
            android:defaultValue="false"
            android:key="pref_key_reporting_problems_settings_trace_logging_enable"
            android:summary="@string/options_screen_prefs_description_trace_logging"
            android:title="@string/options_screen_prefs_name_trace_logging" >
        </CheckBoxPreference>
    </PreferenceCategory>

</PreferenceScreen>
The abstract class below is where the bulk of the Java code is. The in-code comments in conjunction with running the actual projects in debug mode will be the best way to understand the finer details of how it all works but in brief:

  • The game runs its activity which calls its abstract parent's constructor
  • That constructor sets a custom layout with a ListView which will be the root view in the preferences hierarchy.
  • An array list is initialised containing the custom preferences that are to be added to the options screen. The 2 XML resource file names for Game Settings and Graphics and their associated preference categories keys are defined here.
  • The way preferences should be handled on Android depends on what version of Android is being used; either use a PreferenceActivity for older versions or PreferenceFragment since Honeycomb (Android 3.0). This solution handles both old and new methods automatically.
  • The root preference screen is added.
  • The list of custom preference screens is iterated.
  • The defined XML resource file containing the preference screen is inflated and instantiated as a PreferenceScreen object. Because the Android API hides the method for inflating preference XML files, Java reflection is used.
  • If the custom preference screen has a corresponding category name defined, then the root preference screen is scanned for an already existing preference category with a key that matches that name. If a match is found, then all the children preference widgets in the XML resource file are appended to the existing preference category.
  • If no match is found, then a new preference category is created and appended to the end of the root preference screen list. If a corresponding category name is defined, then the title of the new preference category will be set to that.
  • The result is all preference screens/categories/widgets are merged together and rendered as one logical screen.
 
public abstract class AbstractOptionsScreenActivity extends PreferenceActivity {
 private final static String TAG = "GameLib";
 
 /**
  * Encapsulation class for a single custom preference XML resource and the optional {@link PreferenceCategory}
  * that its inner {@link Preference} widgets are located in.
  */
 protected class CustomPreferenceScreen {
  /** Name of the XML resource file that contains the custom preference screen */
  public String xmlResFilename;
  
  /**
   * If the name matches the key of an existing preference category in the root preference screen,
   * then the custom preference screen's contents are appended here. Otherwise, a new preference
   * category with the title set to the value of this name will be created and the contents added
   * there.
   */
  public String preferenceCategoryName;

  /**
   * Encapsulating class for an XML resource file that contains custom preferences implemented in the
   * game project that is using this library.
   * 
   * @param xmlResFilename The XML resource filename in the res/xml directory.
   * @param categoryName The key name of the existing preference category to add to, or the title value of
   * the new preference category to create. Can be null (and will append a new preference category with no title).
   */
  public CustomPreferenceScreen(String xmlResFilename, String categoryName) {
   this.xmlResFilename = xmlResFilename;
   this.preferenceCategoryName = categoryName;
  }
 }

 /**
  * Array list of {@link CustomPreferenceScreen}s for containing the list of custom preference screen to show on
  * the options screen.
  */
 protected ArrayList<CustomPreferenceScreen> mCustomPreferenceScreensList;

 private RootPreferencesFragment mRootPreferencesFragment;

 @Override
 public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);

  setContentView(R.layout.preferences_screen_layout);

  loadPreferences();
 }

 @Override
 public void onDestroy() {
  super.onDestroy();

  if (mRootPreferencesFragment != null) {
   // Avoid memory leaks with stale context references
   mRootPreferencesFragment.mContext = null;
  }
 }

 /**
  * Implement this method to add extra preference screen resources to the list that is referenced when
  * rendering the options screen.<br>
  * <br>
  * List is {@link #mCustomPreferenceScreensList} and element objects to insert are {@link CustomPreferenceScreen}.
  */
 protected abstract void addExtraCustomPreferenceResources();

 protected final void loadPreferences() {
  // Initialise the list of custom preference screen resources.
  // At a minimum there are game_preferences.xml and graphics_preferences.xml whose contents should
  // be implemented (i.e. the game specific preferences defined) in the game project using the library.
  mCustomPreferenceScreensList = new ArrayList<CustomPreferenceScreen>();

  // Map that the preferences defined in games_preferences.xml belong in the preference category with
  // the key pref_key_game_settings
  mCustomPreferenceScreensList.add(new CustomPreferenceScreen("game_preferences", "pref_key_game_settings"));

  // Map that the preferences defined in graphics_preferences.xml belong in the preference category with
  // the key pref_key_graphics_settings
  mCustomPreferenceScreensList.add(new CustomPreferenceScreen("graphics_preferences", "pref_key_graphics_settings"));

  // Optionally, any subclass may implement the following method to add further add entries to the list so
  // we invoke that method here to ensure they're loaded in.
  addExtraCustomPreferenceResources();

  // The way to handle preferences changed since API 11 (Honeycomb). From API 11 onwards, it is recommended to use
  // PreferenceFragment inside of a normal Activity instead of PreferenceActivity. Here we will handle both for the
  // older Android APIs and the new ones.
  // Source: http://developer.android.com/guide/topics/ui/settings.html
  if (isFragmentSupported()) {
   loadPreferencesWithPreferenceFragment();
  } else {
   loadPreferencesWithPreferenceActivity();
  }
 }

 @SuppressWarnings("deprecation")
 private void loadPreferencesWithPreferenceActivity() {

  Log.d(TAG, "Loading preferences with the old preference activity method");

  // Load the root preferences using PreferenceActivity (which this class subclasses).
  // These preferences are common to all games that use the library.
  addPreferencesFromResource(R.xml.root_preferences);

  // Render the custom preference screens and their widgets
  addCustomPreferenceScreens(this, getPreferenceScreen(), mCustomPreferenceScreensList);
 }

 @TargetApi(11)
 private void loadPreferencesWithPreferenceFragment() {
  Log.d(TAG, "Loading preferences with the new preference fragment method");

  // Instantiate the root preferences fragment
  mRootPreferencesFragment = new RootPreferencesFragment();
  mRootPreferencesFragment.mContext = this;
  mRootPreferencesFragment.mCustomPreferenceScreenResourcesList = this.mCustomPreferenceScreensList;
  getFragmentManager().beginTransaction().replace(android.R.id.content, mRootPreferencesFragment).commit();
 }

 private static void addCustomPreferenceScreens(
   Context context,
   PreferenceScreen rootPreferenceScreen,
   ArrayList<CustomPreferenceScreen> customPreferenceScreensList) {
  if (context == null || rootPreferenceScreen == null || customPreferenceScreensList == null) {
   return;
  }

  // Iterate over the list of custom preference screens
  for (CustomPreferenceScreen customPrefScreen : customPreferenceScreensList) {
   if (customPrefScreen != null && customPrefScreen.xmlResFilename != null) {
    // Reference to the preference category to place the preference widgets in
    PreferenceCategory currentPreferenceCategory = null;

    // Search for a category matching the name of the specified key
    for (int i = 0; i < rootPreferenceScreen.getPreferenceCount(); i  ) {
     // Get the current preference object in the root preference screen's hierarchy
     Preference currentRootPreferenceScreenPreference = rootPreferenceScreen.getPreference(i);

     // Check if current root preference screen preference is a preference category and if its key matches the
     // key we were specified
     if (currentRootPreferenceScreenPreference != null && currentRootPreferenceScreenPreference instanceof PreferenceCategory) {
      if (((PreferenceCategory) currentRootPreferenceScreenPreference).getKey() != null
        && ((PreferenceCategory) currentRootPreferenceScreenPreference).getKey().equals(customPrefScreen.preferenceCategoryName)) {
       // A match - this is the preference category where to add our custom preferences
       currentPreferenceCategory = (PreferenceCategory) currentRootPreferenceScreenPreference;
       break;
      }
     }
    }

    // Otherwise create a new preference category object
    if (currentPreferenceCategory == null) {
     // Instantiate a new preference category to hold the custom preference screen
     currentPreferenceCategory = new PreferenceCategory(context);
     currentPreferenceCategory.setTitle(customPrefScreen.preferenceCategoryName);
     
     // Add the new preference category to the end of the root preference screen
     rootPreferenceScreen.addPreference(currentPreferenceCategory);
    }

    // Add the contents of of the custom preference screen to the preference category
    int resId = context.getResources().getIdentifier(customPrefScreen.xmlResFilename, "xml", context.getPackageName());
    PreferenceScreen resCustomPreferenceScreen = inflatePreferenceScreenFromResource(context, resId);
    int order = currentPreferenceCategory.getPreferenceCount() - 1; // offset by the number of preferences already in the group so custom preferences are appended
    if (resCustomPreferenceScreen != null) {
     for (int j = 0 ; j < resCustomPreferenceScreen.getPreferenceCount(); j  ) {
      Preference preferenceWidget = resCustomPreferenceScreen.getPreference(j);
      preferenceWidget.setOrder(  order);
      currentPreferenceCategory.addPreference(preferenceWidget);
     }
    }
   }
  }
 }

 /**
  * Subclassed {@link PreferenceFragment} to load preferences for Android devices running 3.0 (Honeycomb API 11) and up.
  *
  */
 @TargetApi(11)
 public static class RootPreferencesFragment extends PreferenceFragment {
  protected Context mContext;
  protected ArrayList<CustomPreferenceScreen> mCustomPreferenceScreenResourcesList;

  public RootPreferencesFragment() {
  }

  @Override
  public void onCreate(final Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);

   // Load the root preferences using PreferenceFragment.
   // These preferences are common to all games that use the library.
   addPreferencesFromResource(R.xml.root_preferences);

   // Render the custom preference screens and their widgets
   addCustomPreferenceScreens(mContext, getPreferenceScreen(), mCustomPreferenceScreenResourcesList);
  }
 }

 /**
  * Inflates a {@link android.preference.PreferenceScreen PreferenceScreen} from the specified
  * resource.<br>
  * <br>
  * The resource should come from {@code R.xml}.
  * 
  * @param context The context.
  * @param resId The ID of the XML file.
  * @return The preference screen or null on failure.
  */
 protected static PreferenceScreen inflatePreferenceScreenFromResource(Context context, int resId) {
  try {
   // The Android API doesn't provide a publicly available method to inflate preference
   // screens from an XML resource into a PreferenceScreen object so we use reflection here
   // to get access to PreferenceManager's private inflateFromResource method.
   Constructor<PreferenceManager> preferenceManagerCtor = PreferenceManager.class.getDeclaredConstructor(Context.class);
   preferenceManagerCtor.setAccessible(true);
   PreferenceManager preferenceManager = preferenceManagerCtor.newInstance(context);
   Method inflateFromResourceMethod = PreferenceManager.class.getDeclaredMethod(
     "inflateFromResource", Context.class, int.class, PreferenceScreen.class);
   return (PreferenceScreen) inflateFromResourceMethod.invoke(preferenceManager, context, resId, null);
  } catch (Exception e) {
   Log.w(TAG, "Could not inflate preference screen from XML resource ID "   resId, e);
  }

  return null;
 }
 
 /**
  * Convenience method to check whether the device's Android version >= API 11 (3.0 )
  * as the {@link android.support.v4.app.Fragment} API is only available since API 11.
  * 
  * @return true if Fragment and associated classes are supported, false otherwise
  */
 public static boolean isFragmentSupported() {
  if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) {
   return true;
  }
  return false;
 }
}
Download Eclipse Game Project and Game Library Project.

No comments:

Post a Comment