September 4, 2018

Переключение тем в Android приложении

Наверное каждому в этом мире охота иметь право выбора. Даже если это лишь выбор цветовой схемы приложения. Тёмная тема легче воспринимается в вечернее время, а также положительно влияет на энергосбережение (в частности на AMOLED дисплеях)

Когда-то я хотел сделать изменение темы в приложении "на лету", но не мог из-за своих малых знаний (от силы 2 месяца программирования). Всё приходит со временем, и, возможно, это тот самый момент, когда вы овладеете полезным навыком.

Хочу сразу предупредить:

Пример будет предназначен для новичков, имеющих малый (менее 1 года) опыт разработки под Андроид. В проекте не будет никакой сложной архитектуры, типо MVP, которая только лишь собьёт новичка с толку.

Подразумевается, что:

  1. Вы уже знакомы с методами жизненного цикла Activity, умеете запускать и управлять их состоянием
  2. Вы уже знакомы с классом Fragment, умеете помещать несколько фрагментов внутри одной Activity, и управлять ими

Подготовка

Перейдите в каталог ресурсов вашего проекта, найдите файл styles.xml, и добавьте туда новые темы для приложения.

Создайте класс App, который будет наследоваться от android.app.Application, и напишите следующий код:

import android.app.Application;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;

public class App extends Application {
  private static App instance = null;
  private SharedPreferences preferences;

  public App() {
    super();
  }

  @Override
  public void onCreate() {
    super.onCreate();
    instance = this;
    // Если в вашем приложении есть экран настроек, замените preferences на нужное имя файла
PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
  }

  public static App getInstance(){
    if (instance == null)
      instance = new App();
    return instance;
  }

  public SharedPreferences getPreferences(){
    if (preferences == null)
      preferences = PreferenceManager.getDefaultSharedPreferences(this);
    return preferences;
  }
}

Зарегистрируйте класс в файле AndroidManifest.xml

<application
    android:name=".App"
    ...
    ...
    .../>
...
...
...
</application>

Теперь пройдемся по методам. Мы создали экземпляр класса Application, который сам по себе является синглтоном. Переопределив метод onCreate(), присвоили значение переменной instance.

В методе getInstance() мы просто возвращаем переменную instance, которая является экземпляром класса App.

В методе getPreferences() мы возвращаем экземпляр SharedPreferences, необходимый для хранения настроек.

Создайте новый класс, который будет отвечать за смену темы. Я назову его ThemeWrapper.java. напишите какой код:

import android.app.Activity;

public abstract class ThemeWrapper {
  public enum Theme {
    LIGHT,
    DARK
  }

  public static void applyTheme(Activity ctx) {
    int theme;
    switch (Theme.values()[getThemeIndex()]) {
      case LIGHT:
        theme = R.style.AppTheme;
        break;
      case DARK:
        theme = R.style.AppTheme_Dark;
        break;
      default:
        theme = R.style.AppTheme;
        break;
    }
    ctx.setTheme(theme);
  }

  private static int getThemeIndex() {
    return Integer.parseInt(App.getInstance().getPreferences().getString("ui.theme", String.valueOf(ThemeWrapper.Theme.LIGHT.ordinal())));
  }
}

Самостоятельно импортируйте классы R и App.

Внедрение кода

Если ваше приложение использует более чем одно Activity, то лучше всего будет создать некий базовый класс, от которого всё будет наследоваться. Я назвал такой класс BaseActivity.java

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.AppCompatActivity;



public class BaseActivity extends AppCompatActivity {
  //Theme
  private final BroadcastReceiver mThemeReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
      if (SettingsActivity.class.equals(BaseActivity.this.getClass())){
        finish();
        startActivity(getIntent());
        overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
      } else recreate();
    }
  };

  public BaseActivity(){

  }

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    //Приёмник сигналов от SettingsActivity
 LocalBroadcastManager.getInstance(this).registerReceiver(mThemeReceiver, new IntentFilter("ru.svolf.action.REFRESH_THEME"));
    ThemeWrapper.applyTheme(this);
    super.onCreate(savedInstanceState);
  }

  @Override
  public void onResume() {
    super.onResume();
  }

  @Override
  protected void onDestroy() {
    LocalBroadcastManager.getInstance(this).unregisterReceiver(mThemeReceiver);
    super.onDestroy();
  }
}

Импортируйте класс ThemeWrapper. Если у вас уже есть Activity настроек, то импортируйте и её, в противном случае создайте с нуля.

/res/layout/activity_settings.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:orientation="vertical">
  <android.support.design.widget.AppBarLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:theme="@style/AppTheme.AppBarOverlay">

    <android.support.v7.widget.Toolbar
      android:id="@+id/toolbar"
      android:layout_width="match_parent"
      android:layout_height="?attr/actionBarSize"
      android:background="?attr/colorPrimary"
      app:popupTheme="?popupTheme" />
  </android.support.design.widget.AppBarLayout>
  <FrameLayout
    android:id="@+id/frame_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>
</LinearLayout>
Возможно вам придется добавить библиотеки AppCompat и Design, для корректной работы

SettingsActivity.java

import android.os.Bundle;
import android.support.v7.widget.Toolbar;
import android.view.MenuItem;

import ru.SnowVolf.devtheme.R;



public class SettingsActivity extends BaseActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_settings);
    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);
    getSupportActionBar().setDisplayHomeAsUpEnabled(true);
    getFragmentManager()
        .beginTransaction()
        .replace(R.id.frame_container, new SettingsFragment())
        .commit();
  }

  @Override
  public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()){
      case android.R.id.home:{
        finish();
      }
    }
    return super.onOptionsItemSelected(item);
  }
}

Создадим класс SettingsFragment.java с содержимым настроек

import android.content.Intent;
import android.os.Bundle;
import android.preference.ListPreference;
import android.preference.PreferenceFragment;
import android.content.SharedPreferences;
import android.support.annotation.Nullable;
import android.support.v4.content.LocalBroadcastManager;


public class SettingsFragment extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener {

  @Override
  public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    addPreferencesFromResource(R.xml.preferences);
    //Регистрация слушателя настроек
 getPreferenceScreen().getSharedPreferences().registerOnSharedPreferenceChangeListener(this);
    //Ставим текущее значение как подзаголовок настройки

setCurrentValue((ListPreference) findPreference("ui.theme"));
  }

  @Override
  public void onDestroy() {
    super.onDestroy();
    //Отключение слушателя настроек
 getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this);
  }

  private void setCurrentValue(ListPreference listPreference){
    listPreference.setSummary(listPreference.getEntry());
  }

  @Override
  public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
    switch (key) {
      case "ui.theme": {
        setCurrentValue((ListPreference) findPreference(key));
        LocalBroadcastManager.getInstance(getActivity()).sendBroadcast(new Intent("ru.svolf.action.REFRESH_THEME"));
        break;
      }
    }
  }
}

В onCreate мы регистрируем слушателя настроек, для того чтобы данные на экране обновлялись сразу же.

В методе onSharedPreferenceChanged посылаем сигнал для того, чтобы activity пересоздалась. Чтобы это было менее заметно пользователю, мы добавили метод переопределения системных анимаций в BaseActivity (overridePendingTransaction)

/res/xml/preferences.xml разметка для экрана настроек

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

  <ListPreference
    android:defaultValue="0"
    android:entries="@array/theme_names"
    android:entryValues="@array/theme_values"
    android:key="ui.theme"
    android:title="App theme" />
</PreferenceScreen>

/res/values/arrays.xml Файл с названиями настроек

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string-array name="theme_values" translatable="false">
    <item>0</item>
    <item>1</item>
  </string-array>
  <string-array name="theme_names">
    <item>Light</item>
    <item>Dark</item>
  </string-array>
</resources>
Ещё раз проверьте, всё ли Activity вы зарегистрировали в манифесте. Сделайте это, если забыли.

Всё. Теперь вы можете просмотреть на плоды своих трудов, просто скомпилировав проект. Если вы всё сделали правильно, то приложение будет работать как надо.