Pull to refresh

Настройки в стиле Holo Android

Reading time11 min
Views16K
Итак, решил написать небольшой пост(я не умелец в этом, поэтому — много кода, мало слов) о том, как сделать настройки как в официальном приложении Настройки в Android 4 (может и в 3.0 тоже). Наша цель:
0. Умение читать и понимать код без объяснений
1. Использование фрагментов
2. Использование header'ов
3. Разделение пунктов на категории
4. Поддержка всех разрешений экрана
5. Использовать SDK14

image

Фрагменты (пункты меню на главном экране)



Что такое фрагменты? Зачем их использовать? Фрагменты — часть «один 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, для реализации в нем пунктов приходиться немного хитрить действовать так же, как и Google. Мы не будем разбираться как устроены Switch-пункты в официальном приложении, поскольку их использование весьма специфично.

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: Если я что-то не рассказал, что-то не понятно — задавайте вопросы в теме.
Tags:
Hubs:
Total votes 14: ↑8 and ↓6+2
Comments0

Articles