Итак, решил написать небольшой пост(я не умелец в этом, поэтому — много кода, мало слов) о том, как сделать настройки как в официальном приложении Настройки в Android 4 (может и в 3.0 тоже). Наша цель:
0. Умение читать и понимать код без объяснений
1. Использование фрагментов
2. Использование header'ов
3. Разделение пунктов на категории
4. Поддержка всех разрешений экрана
5. Использовать SDK14
Что такое фрагменты? Зачем их использовать? Фрагменты — часть «один Android для планшетов и телефонов», они позволяют быстрее и элегантнее создать приложение, которое будет хорошо выглядеть и на телефоне, и на планшете.
Я предполагаю, что вы уже ознакомлены с ними, иначе — пример простейшего фрагмента с настройками из xml-файла, который будет открывать хабр по нажатию на нужный пункт меню
src/com/achep/example/TestFragment.class
Пример построения xml-файла (ресурсов) нашего главного меню настроек, которое содержит наш фрагмент (в трех экземплярах) и две категории
xml/preference_headers.xml
Как Вы видите, Пункты не отличаются по типу от Категорий, что, конечно, немного огорчает, но мы будем определять, что есть что, по атрибутам(можно использовать всевозможные: id, fragment, intent и многие другие), а не типам(как в уже привычных ресурсах для PreferenceActivity)
Этот код основан на коде официального приложения, которое я взял на GitHub и модифицировал под свои нужды. Самая главная часть, на которую я советую обратить внимание, это — HeaderAdapter, для реализации в нем пунктов приходитьсянемного хитрить действовать так же, как и Google. Мы не будем разбираться как устроены Switch-пункты в официальном приложении, поскольку их использование весьма специфично.
src/com/achep/example/Settings.class
SubSettings основан на классе Settings(отличается только кнопкой «Назад») и используется для навигации между пунктами меню в не планшетном устройстве.
src/com/achep/example/SubSettings.class
Для показа просто пункта я использую свой Layout, в котом я удалил все, что я не использовал и оставил только заголовок и иконку. Собственно с этим Layout'ом вы не сможете написать подзаголовок, а вот с этим(например) сможете
layout/preference_header_item.xml
И финальный штрих — добавляем в манифесте следующие данные
AndroidManifest.xml
В данном случае мы будем запускать наш TestFragment.
Добавляем в
src/com/achep/example/Settings.class следующие строки:
и в
AndroidManifest.xml
и все :) Естественно вместо запуска Settings.class нужно будет запускать Settings.TestFragmentActivity.class
PS: Если я что-то не рассказал, что-то не понятно — задавайте вопросы в теме.
0. Умение читать и понимать код без объяснений
1. Использование фрагментов
2. Использование header'ов
3. Разделение пунктов на категории
4. Поддержка всех разрешений экрана
5. Использовать SDK14
Фрагменты (пункты меню на главном экране)
Что такое фрагменты? Зачем их использовать? Фрагменты — часть «один Android для планшетов и телефонов», они позволяют быстрее и элегантнее создать приложение, которое будет хорошо выглядеть и на телефоне, и на планшете.
Я предполагаю, что вы уже ознакомлены с ними, иначе — пример простейшего фрагмента с настройками из xml-файла, который будет открывать хабр по нажатию на нужный пункт меню
src/com/achep/example/TestFragment.class
public class TestFragment extends PreferenceFragment implements onPreferenceClickListener {
/**
* Ключ пункта "Открыть сайт Хабрахабр"
*/
private static final String KEY_HABRAHABR_LAUNCHER = "habrahabrLauncher";
/**
* Пункт "Открыть сайт Хабрахабр"
*/
private Preference mHabrahabrLauncher;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//По аналогии с ресурсами для PreferenceActivity
addPreferencesFromResource(R.xml.test_settings);
mHabrahabrLauncher = (Preference) findPreference(KEY_HABRAHABR_LAUNCHER);
mHabrahabrLauncher.setOnPreferenceClickListener(this);
}
@Override
public boolean onPreferenceClick(Preference preference) {
if (preference == mHabrahabrLauncher) {
// Запускаем хабр в браузере
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("http://habrahabr.ru")));
} else {
// Ничего не делаем
}
return false;
}
}
Главный экран настроек: Ресурсы
Пример построения xml-файла (ресурсов) нашего главного меню настроек, которое содержит наш фрагмент (в трех экземплярах) и две категории
xml/preference_headers.xml
<?xml version="1.0" encoding="utf-8"?>
<preference-headers xmlns:android="http://schemas.android.com/apk/res/android" >
<!-- Категория --->
<header android:title="Важное" />
<!-- Пункт --->
<header
<!-- Путь к нашему фрагменту настроек --->
android:fragment="com.achep.example.TestFragment"
<!-- Иконка --->
android:icon="@drawable/ic_settings_test"
<!-- Текст --->
android:title="Пункт 1" />
<!-- Категория --->
<header android:title="Привет, хабрахабр!" />
<!-- Пункт --->
<header
android:id="@+id/header_test" // Запуска сразу этого фрагмента
android:fragment="com.achep.example.TestFragment"
android:icon="@drawable/ic_settings_test"
android:title="Пункт 2" />
<!-- Пункт --->
<header
android:fragment="com.achep.example.TestFragment"
android:icon="@drawable/ic_settings_test"
android:title="Пункт 3" />
</preference-headers>
Как Вы видите, Пункты не отличаются по типу от Категорий, что, конечно, немного огорчает, но мы будем определять, что есть что, по атрибутам(можно использовать всевозможные: id, fragment, intent и многие другие), а не типам(как в уже привычных ресурсах для PreferenceActivity)
Главный экран настроек: Java код
Этот код основан на коде официального приложения, которое я взял на GitHub и модифицировал под свои нужды. Самая главная часть, на которую я советую обратить внимание, это — HeaderAdapter, для реализации в нем пунктов приходиться
src/com/achep/example/Settings.class
public class Settings extends PreferenceActivity {
private static final String LOG_TAG = "Settings";
private static final String META_DATA_KEY_HEADER_ID = "com.achep.example.settings.TOP_LEVEL_HEADER_ID";
private static final String META_DATA_KEY_FRAGMENT_CLASS = "com.achep.example.settings.FRAGMENT_CLASS";
private static final String META_DATA_KEY_PARENT_TITLE = "com.achep.stopwatch.PARENT_FRAGMENT_TITLE";
private static final String META_DATA_KEY_PARENT_FRAGMENT_CLASS = "com.achep.example.settings.PARENT_FRAGMENT_CLASS";
private static final String SAVE_KEY_CURRENT_HEADER = "com.achep.example.settings.CURRENT_HEADER";
private static final String SAVE_KEY_PARENT_HEADER = "com.achep.example.settings.PARENT_HEADER";
private String mFragmentClass;
private int mTopLevelHeaderId;
private Header mFirstHeader;
private Header mCurrentHeader;
private Header mParentHeader;
private boolean mInLocalHeaderSwitch;
protected HashMap<Integer, Integer> mHeaderIndexMap = new HashMap<Integer, Integer>();
private List<Header> mHeaders;
@Override
protected void onCreate(Bundle savedInstanceState) {
getMetaData();
mInLocalHeaderSwitch = true;
super.onCreate(savedInstanceState);
mInLocalHeaderSwitch = false;
if (!onIsHidingHeaders() && onIsMultiPane()) {
highlightHeader(mTopLevelHeaderId);
}
// Восстанавливаем сохраненные данные, если они есть
if (savedInstanceState != null) {
mCurrentHeader = savedInstanceState
.getParcelable(SAVE_KEY_CURRENT_HEADER);
mParentHeader = savedInstanceState
.getParcelable(SAVE_KEY_PARENT_HEADER);
}
//Если текущий header был сохранен - переместимся к нему
if (savedInstanceState != null && mCurrentHeader != null) {
showBreadCrumbs(mCurrentHeader.title, null);
}
if (mParentHeader != null) {
setParentTitle(mParentHeader.title, null, new OnClickListener() {
public void onClick(View v) {
switchToParent(mParentHeader.fragment);
}
});
}
// Override up navigation for multi-pane, since we handle it in the
// fragment breadcrumbs
if (onIsMultiPane()) {
getActionBar().setDisplayHomeAsUpEnabled(false);
getActionBar().setHomeButtonEnabled(false);
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
// Save the current fragment, if it is the same as originally launched
if (mCurrentHeader != null) {
outState.putParcelable(SAVE_KEY_CURRENT_HEADER, mCurrentHeader);
}
if (mParentHeader != null) {
outState.putParcelable(SAVE_KEY_PARENT_HEADER, mParentHeader);
}
}
@Override
public void onResume() {
super.onResume();
ListAdapter listAdapter = getListAdapter();
if (listAdapter instanceof HeaderAdapter) {
((HeaderAdapter) listAdapter).resume();
}
invalidateHeaders();
}
@Override
public void onPause() {
super.onPause();
ListAdapter listAdapter = getListAdapter();
if (listAdapter instanceof HeaderAdapter) {
((HeaderAdapter) listAdapter).pause();
}
}
private void switchToHeaderLocal(Header header) {
mInLocalHeaderSwitch = true;
switchToHeader(header);
mInLocalHeaderSwitch = false;
}
@Override
public void switchToHeader(Header header) {
if (!mInLocalHeaderSwitch) {
mCurrentHeader = null;
mParentHeader = null;
}
super.switchToHeader(header);
}
/**
* Switch to parent fragment and store the grand parent's info
*
* @param className
* name of the activity wrapper for the parent fragment.
*/
private void switchToParent(String className) {
final ComponentName cn = new ComponentName(this, className);
try {
final PackageManager pm = getPackageManager();
final ActivityInfo parentInfo = pm.getActivityInfo(cn,
PackageManager.GET_META_DATA);
if (parentInfo != null && parentInfo.metaData != null) {
String fragmentClass = parentInfo.metaData
.getString(META_DATA_KEY_FRAGMENT_CLASS);
CharSequence fragmentTitle = parentInfo.loadLabel(pm);
Header parentHeader = new Header();
parentHeader.fragment = fragmentClass;
parentHeader.title = fragmentTitle;
mCurrentHeader = parentHeader;
switchToHeaderLocal(parentHeader);
highlightHeader(mTopLevelHeaderId);
mParentHeader = new Header();
mParentHeader.fragment = parentInfo.metaData
.getString(META_DATA_KEY_PARENT_FRAGMENT_CLASS);
mParentHeader.title = parentInfo.metaData
.getString(META_DATA_KEY_PARENT_TITLE);
}
} catch (NameNotFoundException nnfe) {
Log.w(LOG_TAG, "Could not find parent activity : " + className);
}
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
// If it is not launched from history, then reset to top-level
if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0
&& mFirstHeader != null
&& !onIsHidingHeaders()
&& onIsMultiPane()) {
switchToHeaderLocal(mFirstHeader);
}
}
private void highlightHeader(int id) {
if (id != 0) {
Integer index = mHeaderIndexMap.get(id);
if (index != null) {
getListView().setItemChecked(index, true);
getListView().smoothScrollToPosition(index);
}
}
}
@Override
public Intent getIntent() {
Intent superIntent = super.getIntent();
String startingFragment = getStartingFragmentClass(superIntent);
if (startingFragment != null && !onIsMultiPane()) {
Intent modIntent = new Intent(superIntent);
modIntent.putExtra(EXTRA_SHOW_FRAGMENT, startingFragment);
Bundle args = superIntent.getExtras();
if (args != null) {
args = new Bundle(args);
} else {
args = new Bundle();
}
args.putParcelable("intent", superIntent);
modIntent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS,
superIntent.getExtras());
return modIntent;
}
return superIntent;
}
/**
* Checks if the component name in the intent is different from the Settings
* class and returns the class name to load as a fragment.
*/
protected String getStartingFragmentClass(Intent intent) {
if (mFragmentClass != null)
return mFragmentClass;
String intentClass = intent.getComponent().getClassName();
if (intentClass.equals(getClass().getName()))
return null;
return intentClass;
}
/**
* Override initial header when an activity-alias is causing Settings to be
* launched for a specific fragment encoded in the android:name parameter.
*/
@Override
public Header onGetInitialHeader() {
String fragmentClass = getStartingFragmentClass(super.getIntent());
if (fragmentClass != null) {
Header header = new Header();
header.fragment = fragmentClass;
header.title = getTitle();
header.fragmentArguments = getIntent().getExtras();
mCurrentHeader = header;
return header;
}
return mFirstHeader;
}
@Override
public Intent onBuildStartFragmentIntent(String fragmentName, Bundle args,
int titleRes, int shortTitleRes) {
Intent intent = super.onBuildStartFragmentIntent(fragmentName, args,
titleRes, shortTitleRes);
intent.setClass(this, SubSettings.class);
return intent;
}
/**
* Populate the activity with the top-level headers.
*/
@Override
public void onBuildHeaders(List<Header> headers) {
loadHeadersFromResource(R.xml.preference_headers, headers);
mHeaders = headers;
}
private void getMetaData() {
try {
ActivityInfo ai = getPackageManager().getActivityInfo(
getComponentName(), PackageManager.GET_META_DATA);
if (ai == null || ai.metaData == null)
return;
mTopLevelHeaderId = ai.metaData.getInt(META_DATA_KEY_HEADER_ID);
mFragmentClass = ai.metaData
.getString(META_DATA_KEY_FRAGMENT_CLASS);
// Check if it has a parent specified and create a Header object
final int parentHeaderTitleRes = ai.metaData
.getInt(META_DATA_KEY_PARENT_TITLE);
String parentFragmentClass = ai.metaData
.getString(META_DATA_KEY_PARENT_FRAGMENT_CLASS);
if (parentFragmentClass != null) {
mParentHeader = new Header();
mParentHeader.fragment = parentFragmentClass;
if (parentHeaderTitleRes != 0) {
mParentHeader.title = getResources().getString(
parentHeaderTitleRes);
}
}
} catch (NameNotFoundException nnfe) {
// No recovery
}
}
/**
* Наша самая веселая часть :)
*/
private static class HeaderAdapter extends ArrayAdapter<Header> {
static final int HEADER_TYPE_CATEGORY = 0; //Если пункт - категория
static final int HEADER_TYPE_NORMAL = 1; // Если пункт - обычный пункт
private static final int HEADER_TYPE_COUNT = HEADER_TYPE_NORMAL + 1;
private static class HeaderViewHolder {
ImageView icon;
TextView title;
}
private LayoutInflater mInflater;
static int getHeaderType(Header header) {
// Определяем тип нашего пункта по его параметрам.
// Конечно можно использовать ID'ы для более сложных систем
return header.fragment == null ? HEADER_TYPE_CATEGORY : HEADER_TYPE_NORMAL;
}
@Override
public int getItemViewType(int position) {
Header header = getItem(position);
return getHeaderType(header);
}
@Override
public boolean areAllItemsEnabled() {
return false; // потому что категории
}
@Override
public boolean isEnabled(int position) {
return getItemViewType(position) != HEADER_TYPE_CATEGORY; // Если текущий пункт - не категория
}
@Override
public int getViewTypeCount() {
return HEADER_TYPE_COUNT;
}
@Override
public boolean hasStableIds() {
return true;
}
public HeaderAdapter(Context context, List<Header> objects) {
super(context, 0, objects);
mInflater = (LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
HeaderViewHolder holder;
Header header = getItem(position);
int headerType = getHeaderType(header);
View view = null;
if (convertView == null) {
holder = new HeaderViewHolder();
switch (headerType) {
case HEADER_TYPE_CATEGORY: //Если категория
view = new TextView(getContext(), null,
android.R.attr.listSeparatorTextViewStyle); // Выбираем стиль "Категория"
holder.title = (TextView) view;
break;
case HEADER_TYPE_NORMAL: //Если обьчных пункт
// Я использую свой layout для "обычных" пунктов
view = mInflater.inflate(R.layout.preference_header_item,
parent, false);
holder.icon = (ImageView) view
.findViewById(android.R.id.icon); // Добавляем иконку
holder.title = (TextView) view
.findViewById(android.R.id.title); // Добавляем текст
break;
}
view.setTag(holder);
} else {
view = convertView;
holder = (HeaderViewHolder) view.getTag();
}
// All view fields must be updated every time, because the view may
// be recycled
switch (headerType) {
case HEADER_TYPE_CATEGORY:
holder.title.setText(header.getTitle(getContext()
.getResources()));
break;
case HEADER_TYPE_NORMAL:
holder.icon.setImageResource(header.iconRes);
holder.title.setText(header.getTitle(getContext()
.getResources()));
break;
}
return view;
}
public void resume() {
// Для данного примера - ничего не делаем :)
}
public void pause() {
// Для данного примера - ничего не делаем :)
}
}
@Override
public boolean onPreferenceStartFragment(PreferenceFragment caller,
Preference pref) {
int titleRes = pref.getTitleRes();
startPreferencePanel(pref.getFragment(), pref.getExtras(), titleRes,
null, null, 0);
return true;
}
@Override
public void setListAdapter(ListAdapter adapter) {
if (mHeaders == null) {
mHeaders = new ArrayList<Header>();
for (int i = 0; i < adapter.getCount(); i++)
mHeaders.add((Header) adapter.getItem(i));
}
super.setListAdapter(new HeaderAdapter(this, mHeaders));
}
}
SubSettings основан на классе Settings(отличается только кнопкой «Назад») и используется для навигации между пунктами меню в не планшетном устройстве.
src/com/achep/example/SubSettings.class
public class SubSettings extends Settings {
// Собственно сама кнопка "Назад"
@Override
public boolean onNavigateUp() {
finish();
return true;
}
}
Главный экран настроек: Layout простого пункта
Для показа просто пункта я использую свой Layout, в котом я удалил все, что я не использовал и оставил только заголовок и иконку. Собственно с этим Layout'ом вы не сможете написать подзаголовок, а вот с этим(например) сможете
layout/preference_header_item.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:background="?android:attr/activatedBackgroundIndicator"
android:gravity="center_vertical"
android:minHeight="48.0dip"
android:paddingRight="?android:scrollbarSize" >
<ImageView
android:id="@android:id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginLeft="6.0dip"
android:layout_marginRight="6.0dip" />
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="6.0dip"
android:layout_marginLeft="2.0dip"
android:layout_marginRight="6.0dip"
android:layout_marginTop="6.0dip"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:singleLine="true"
android:textAppearance="?android:textAppearanceMedium" />
</LinearLayout>
Android Manifest
И финальный штрих — добавляем в манифесте следующие данные
AndroidManifest.xml
<!-- Settings -->
<activity
android:name=".Settings"
android:hardwareAccelerated="true"
android:launchMode="singleTask"
android:taskAffinity="com.achep.example" />
<activity
android:name=".SubSettings"
android:parentActivityName="Settings" />
Бонус: Запускаем сразу заданный пункт в настройках
В данном случае мы будем запускать наш TestFragment.
Добавляем в
src/com/achep/example/Settings.class следующие строки:
public static class TestFragmentActivity extends Settings { /* empty */ }
и в
AndroidManifest.xml
<activity
android:name=".Settings$TestFragmentActivity"
android:clearTaskOnLaunch="true"
android:parentActivityName="Settings" >
<meta-data
android:name="com.achep.example.settings.FRAGMENT_CLASS"
android:value="com.achep.example.TestFragment" />
<meta-data
android:name="com.achep.stopwatch.TOP_LEVEL_HEADER_ID"
android:resource="@id/header_test" />
и все :) Естественно вместо запуска Settings.class нужно будет запускать Settings.TestFragmentActivity.class
PS: Если я что-то не рассказал, что-то не понятно — задавайте вопросы в теме.