原文链接 : Supporting multiple themes in your Android app (Part 2)
译者 : chaossss
校对者: Mr.Simple
状态 : 完成
In the first part of this post, we have created a light theme and made initial preparation to support multiple themes. In this blog post, we will continue that effort, creating another theme and allowing dynamic switching of themes during runtime.
在 上一篇博文 中,我们创建了一个明亮风格的主题,并且为实现使用多种主题作了一些前期的准备,而今天呢,我打算在这篇博文中接着上一篇博文继续为大家讲解,而我今天要讲的内容大概是以下三个部分:使 Android 应用能够使用多种主题,创建一个灰暗风格的主题,以及允许 Android 应用在运行时自由地切换不同的主题。
Ideally, if we treat theme as a configuration, we should be able to specify theme-specific resources under a ‘theme-qualifier’ resources directory, e.g. values-dark for dark theme resources and values-light for light theme resources. Unfortunately, this is not yet an option at the time of this post.
在理想的情况下,如果我们把主题的设置看作是一项配置,那么我们应该能够在类似 “theme-qualifier” 的目录下指定我们想要的特定主题,例如:values-dark 就是我们想要的灰暗风格主题;而values-light 则是明亮风格的主题。但很遗憾,在这篇博文所要讲述的实现方法里,这种方法并没有成为实现方式之一。
So how should we specify resources for multiple themes? If we look at how resources are organized in appcompat, we will have a rough idea of how the Android team organize their theme specific resources. Materialistic also employs a similar approach.
那么我们要怎么为不同的主题指定相应的资源文件呢?如果我们有了解过 appcompat 是怎么使用资源文件的话,对 Android 系统是如何管理和使用资源文件会有一个粗略的认识。毫无疑问,Materialistic 中使用的方法就是类似于 Android 系统使用的方法。
Theming
主题设置
values/styles.xml
<style name="AppTheme" parent="Theme.AppCompat.Light"><!-- original theme attributes -->...</style><style name="AppTheme.Dark" parent="Theme.AppCompat"> <item name="colorPrimary">@color/colorPrimaryInverse</item> <item name="colorPrimaryDark">@color/colorPrimaryDarkInverse</item> <item name="colorAccent">@color/colorAccentInverse</item> <item name="android:textColorPrimary">@color/textColorPrimaryInverse</item> <item name="android:textColorSecondary">@color/textColorSecondaryInverse</item> <item name="android:textColorPrimaryInverse">@color/textColorPrimary</item> <item name="android:textColorSecondaryInverse">@color/textColorSecondary</item>...</style>
values/color.xml
<!-- original color palette -->...<!-- alternative color palette --><color name="colorPrimaryInverse">...</color><color name="colorPrimaryDarkInverse">...</color><color name="colorAccentInverse">...</color>
Here we add a new dark theme called AppTheme.Dark, and for style and color consistency, we extend from appcompat’s theme Theme.AppCompat (a dark theme). Unfortunately, since our two themes extend two different base themes, we cannot share any common attributes (the same way a class in Java cannot extend two or more classes).
在上面的操作中我们创建了一个名叫 AppTheme.Dark 的灰暗风格主题,此外,为了保持 style 和 color 的一致性,我们的 AppTheme.Dark 主题衍生于 appcompat 的 Theme.AppCompat 主题(一个 Android 自带的灰暗风格主题)。然而,由于我们的两个主题(明亮风格和灰暗风格)衍生于不同的基础主题,因此这两个主题之间并不能够进行属性的共享(在JAVA中,类只能进行单继承)。
The two themes should have appropriate (different if applicable) values for base Android and appcompat theme attributes, e.g. android:textColorPrimary for dark theme should be light, and for light theme should be dark. By convention, here we suffix alternative theme colors with Inverse.
这两个主题理应有一些恰当的属性值,能同时用于设置基本的 Android 和 appcompat的主题属性,例如:在灰暗风格中,android:textColorPrimary 应该被设置为明亮的,而在明亮风格中,android:textColorPrimary则应该是灰暗的。按照常用的命名习惯,我们在这里将用相反的后缀来区分可替代的主题颜色。
Tip
Try out your alternative theme by temporary switching android:theme for application in AndroidManifest.xml to see what extra colors/style you need to create. For certain cases a color may look okay in both dark and light theme.
温馨小提示
在某些情况下,一种颜色能同时在明亮风格和灰暗风格的主题中被使用,这当然是喜闻乐见的情况,但是在大部分主题中这并不能够实现。所以我希望你在设计主题的过程中,通过在 AndroidManifest.xml 中短暂地切换你应用里正在使用的可替代主题,以此确定你的主题是否需要添加其他的 colors/style 文件来满足你的主题设计需求。
Theme-specific resources
At this point, we should have a pretty decent dark theme for our app, except for some anomalies here and there, e.g. drawables used for action bar menu items. A dark action bar expects light-color menu items, and vice versa. In order to tell Android to use different drawables for different app themes, we create custom attributes that allow specifying reference to the correct drawable, and provide different drawable references as values for these custom attributes under different themes (the same way appcompat library provides custom attributes such as colorPrimary).
特定的主题资源文件
到了现在,我相信我们都能很轻松地为我们的 App 设计出美如画的灰暗风格主题,但这里还存在一些小麻烦,例如:用于美化 action bar 菜单选项的 drawables 资源文件。灰暗风格的 action bar 需要用明亮的颜色修饰它的菜单选项,反之亦然。为了让 Android 能够在不同的App主题下区分不同的 drawables 资源文件,我们创建了能够指定正确资源文件的 自定义属性 引用,并且在不同的主题下提供了不同的 drawable 引用,将其值赋给特定的自定义属性。(温婉如妻,appcompat 库贴心地为我们准备了类似 colorPrimary 的自定义属性值)
values/attrs.xml
<attr name="themedMenuStoryDrawable" format="reference" /><attr name="themedMenuCommentDrawable" format="reference" />...
values/styles.xml
<style name="AppTheme" parent="Theme.AppCompat.Light"><!-- original theme attributes -->... <item name="themedMenuStoryDrawable">@drawable/ic_subject_white_24dp</item> <item name="themedMenuCommentDrawable">@drawable/ic_mode_comment_white_24dp</item></style><style name="AppTheme.Dark" parent="Theme.AppCompat"><!-- alternative theme attributes -->... <item name="themedMenuStoryDrawable">@drawable/ic_subject_black_24dp</item> <item name="themedMenuCommentDrawable">@drawable/ic_mode_comment_black_24dp</item></style>
menu/my_menu.xml
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <item android:id="@id/menu_comment"android:icon="?attr/themedMenuCommentDrawable" /> <item android:id="@id/menu_story"android:icon="?attr/themedMenuStoryDrawable" /> <item android:id="@id/menu_share"app:actionProviderClass="android.support.v7.widget.ShareActionProvider" /></menu>
Similar implementation can be used to specify most custom attributes you need for theme specific resource values. One hiccup to this approach is that attribute resolving in drawable resources seems to be broken before API 21. For example, if you have a drawable which is a layer-list of colors, their values must be fixed for API <21. See this commit from Google I/O 2014 app for a fix.
根据你实际的主题设计需要,类似的实现也能被用于为大多数自定义属性指定相应的资源值。但这个方法存在一个问题:根据实际的需要从 drawable 资源文件中解析相应的属性值,并应用于主题的方法在API 21之前的版本似乎都不可行。举例来说明这个问题吧:如果你有一个 layer-list 中包含了各种你所需要的 color 的 drawable 资源文件,在API 21之前的版本中,这些 color 的值都应该是固定的,而不是能够在App运行过程中不断变化的。这个问题在 Google I/O 2014 大会上有被提出,并要求给出相应的解决办法。(详情参见 Click Me!)。
An alternative approach to avoid duplicating drawable resources for different themes is to use drawable tint. This attribute is available from API 21. Dan Lew in his blog shows how to do this for all API levels. Personally I would prefer to keep my Java implementation free of view logic if possible, so I choose to have different drawable resources per theme.
此外,为了避免在不同的主题中重复使用相同的资源文件,我们可以利用 drawable 的 tint 属性解决这个需求。虽然这个属性可以在API 21之后的版本中使用。但 Dan Lew 在他的博客中为我们介绍了怎么在所有的 API 版本中使用 tint 属性。但就个人偏好来说,如果可以的话,我会更倾向于选择不受 View 逻辑影响的 Java 实现,所以我选择为每一个主题提供不同的 drawable 资源文件。
Dynamic theme switching
Now that we have two polished themes ready to be used, we need to allow users to choose which one they prefer and switch theme dynamically during runtime. This can be done by having a SharedPreferences, says pref_dark_theme to store theme preference and use its value to decide which theme to apply. Application of theme should be done for all activies, before their views are created, so onCreate() is our only option to put the logic.
动态主题切换
现在我们已经有两个可以使用的主题了,接下来我们需要做的就是让用户能够在使用 App 时能够自如地根据他们的个人偏好切换不同的主题。要实现这个功能,我们可以通过使用 SharedPreferences 来实现,通过改变 pref_dark_theme 的值去存储当前被选择的主题并决定我们的 App 将要被切换成什么主题。但从实际情况来考虑,主题切换后,App 所有 Activity 的 View 在被创建之前都应该被改变,所以我们只需要在 onCreate()方法中实现我们的逻辑。
BaseActivity.java
public abstract class BaseActivity extends ActionBarActivity { @Override protected void onCreate(Bundle savedInstanceState) { if (PreferenceManager.getDefaultSharedPreferences(this) .getBoolean("pref_dark_theme"), false)) { setTheme(R.style.AppTheme_Dark); } super.onCreate(savedInstanceState); }}
Here, since our app already has a default light theme, we only need to check if default preference has been overriden to override dark theme. The logic is put in the ‘base’ activity so it can be shared by all activities.
在这里,因为我们的 App 已经使用了默认的明亮风格主题,所以我们只需要检查默认的引用是否被重载,是否被用于重载灰暗风格的主题。为了默认的引用能够被所有 Activity共享,其中的逻辑已经在 “base” Activity中被写好了。
Note that this approach will only apply theme for activities that are not in the back stack. For those that are already in current stack, they will still exhibit previous theme, as going back will only trigger onResume(). Depends on product requirements, the implementation to handle these ‘stale’ screens can be as simple as clearing the back stack, or restarting every single activity in the back stack upon preference change. Here we simply clear back stack and restart current activity upon theme change.
值得注意的是,这个方法只能被用于改变没有处在 back stack 中的 Acitivity 的主题。而那些已经在 back stack 中的 Activity,仍然会显示为之前的主题,因为当我们结束当前 Activity,返回到上一个 Activity,只会触发 onResume() 方法,而不是我们期望的 onCreate()方法。因此,考虑到实际的产品功能设计需求,我们当然要解决这些“过时”的 Activity 了,我在这里为大家提供了两种解决办法,都挺简单的:一方面,我们可以清空我们的 back stack;另一方面,一旦 preference 被改变,我们就在 back stack 中按照顺序让所有 Acitivty 出栈后重新加载,将所有 Activity 的主题改变后再重新入栈。在这里为了简便,我们选择的实现方法是:当主题被改变,我们就简单地清空 back stack,然后重启当前的 Activity。
SettingsFragment.java
public class SettingsFragment extends PreferenceFragment { ... @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); mListener = new SharedPreferences.OnSharedPreferenceChangeListener() { @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (!key.equals("pref_dark_theme")) { return; } getActivity().finish(); final Intent intent = getActivity().getIntent(); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK); getActivity().startActivity(intent); } };}...
So that’s it. Now we have an app with two polished themes for even the most picky users! Head over to hidroh/materialistic GitHub repository to checkout complete implementation!
虽然结束得有些突然,但我们今天的讲解就到此结束啦。现在我们的 App 拥有了两个这么优雅的主题,就算是挑剔的文艺小清新也不会嫌弃我们的 App 很 low 了吧!如果你想要了解整个 Materialistic 的具体实现,或者是这个功能的源码,可以来我的 GitHub 上获取哦~