Android 多主题切换 (theme + style) 及
需求:
最近需要实现应用内多主题的需求: 要求应用内预置 10 个左右的主题配色方案, 用户可按需切换.
刚一拿到需求, 觉得这简单, 用 Android 的 theme + style 就可以搞定了. 没过多久就遇到了 attr 无法被 selector, drawable 等 xml 资源引用的大坑.
主题色切换的方案中文网络上一搜一大堆, 但没有哪位博主好心的提起这里还有这么深一个坑的...
这里先把解决方案简要叙述一下.
Android 预置多主题解决方案:
首先定义主题配色相关属性, 我将之单独写在 values/style_themes_attrs.xml 里.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="color_bkg_main" format="color" />
<attr name="color_action_bar" format="color" />
<attr name="color_action_bar_text" format="color" />
<attr name="color_primary" format="color" />
<attr name="color_primary_pressed" format="color" />
<attr name="color_primary_disabled" format="color" />
<attr name="color_text" format="color" />
<attr name="color_text_pressed" format="color" />
<attr name="color_text_disabled" format="color" />
<attr name="color_text_sub" format="color" />
<attr name="color_text_hint" format="color" />
<attr name="color_divider" format="color" />
</resources>
这些属性是应用全局的, 为便于引用, 不应该写在 <declare-styleable>
标签里.
然后定义各个主题配色的具体颜色值 (即给以上属性赋值), 我将之单独写在 values/style_themes.xml 里.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="theme_default" parent="BaseAppTheme">
<item name="color_bkg_main">#f1f1f1</item>
<item name="color_action_bar">#ee9c18</item>
<item name="color_action_bar_text">#fff</item>
<item name="color_primary">#ee9c18</item>
<item name="color_primary_pressed">#80ee9c18</item>
<item name="color_primary_disabled">#666</item>
<item name="color_text">#202020</item>
<item name="color_text_pressed">#80202020</item>
<item name="color_text_disabled">#666</item>
<item name="color_text_sub">#717171</item>
<item name="color_text_hint">#b6b6b6</item>
<item name="color_divider">#e2e2e2</item>
</style>
<style name="theme_sky" parent="theme_default">
<item name="color_action_bar">#02a8f3</item>
<item name="color_primary">#02a8f3</item>
<item name="color_primary_pressed">#8002a8f3</item>
</style>
<style name="theme_grass" parent="theme_default">
<item name="color_action_bar">#63d64a</item>
<item name="color_primary">#63d64a</item>
<item name="color_primary_pressed">#8063d64a</item>
</style>
</resources>
theme_default 继承的 BaseAppTheme 是接到此需求前就已经在 AndroidManifest.xml 中赋值给 App 的 theme. 这个不重要, 你也可以继承系统自带的一些主题, 也可以不继承任何主题, 与实现需求关系不大, 怎么方便怎么来就成.
下面两个主题 theme_sky 和 theme_grass 继承自 theme_default , 这样做是为了不至于重复给颜色属性赋值, 比如文字颜色在这三个主题中是一样的, 继承了就可以避免再写一遍.
注意: 要想在 App 全局使用主题属性, 就必须保证每个 style 内的各个属性都是全的. 比如 theme_grass 里如果没有 color_primary 这个属性, 那么有代码引用这个属性时将发生异常. 注意是运行时异常哦~, 编译时不会报错的. 因此稳妥的做法还是写一个 base style , 然后其他 style 继承之, 这样起码不会崩溃.
最后将主题颜色属性用于各个View.
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/color_bkg_main"
android:orientation="vertical" >
......
</LinearLayout>
或者省略 attr/ 也是可以的:
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?color_text"
android:text="Text Normal" />
最最后, 就是动态切换主题的代码了.
package lx.af.demo.activity.test;
import android.app.Activity;
import android.content.res.Resources;
import android.os.Bundle;
import android.support.annotation.AttrRes;
import android.support.annotation.ColorInt;
import android.util.TypedValue;
import android.view.View;
import lx.af.demo.R;
/**
* author: lx
* date: 17-2-24
*/
public class ThemeChangeActivity extends Activity implements View.OnClickListener {
private static int sCurrentTheme = R.style.theme_default;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 这句必须放在 setContentView() 之前, 否则不生效.
// 一般的做法是把这句话放在你的 BaseActivity 里面.
setTheme(sCurrentTheme);
setContentView(R.layout.activity_change_theme);
findViewById(R.id.btn_switch_theme_default).setOnClickListener(this);
findViewById(R.id.btn_switch_theme_sky).setOnClickListener(this);
findViewById(R.id.btn_switch_theme_grass).setOnClickListener(this);
// 演示如何用代码获取 attr 定义的主题相关的颜色
View primaryColorPanel = findViewById(R.id.primary_color_panel);
primaryColorPanel.setBackgroundColor(getCurrentPrimaryColor());
}
public void onClick(View view) {
switch (view.getId()) {
case R.id.btn_switch_theme_sky:
changeTheme(R.style.theme_sky);
break;
case R.id.btn_switch_theme_grass:
changeTheme(R.style.theme_grass);
break;
case R.id.btn_switch_theme_default:
changeTheme(R.style.theme_default);
break;
}
}
private void changeTheme(int theme) {
// 改变主题时应该把当前主题设置保存进 SharedPreferences 里去.
// 比如给这三个主题编号 101, 102, 103, 然后保存该编号, 供下次启动时设置为对应主题.
// 这里省略了这部分逻辑, 只留主题相关逻辑.
sCurrentTheme = theme;
// 调用 Activity.recreate() 方法即可从 Activity.onCreate() 开始重新加载界面.
// 该方法不会启动界面过场动画, 但重启时会有一下闪烁.
recreate();
}
// 直接获取主题的主色颜色值
public int getCurrentPrimaryColor() {
return getColorByAttributeId(R.attr.color_primary);
}
// 使用代码获取主题属性颜色值的方法
@ColorInt
private int getColorByAttributeId(@AttrRes int attrIdForColor){
TypedValue typedValue = new TypedValue();
Resources.Theme theme = getTheme();
theme.resolveAttribute(attrIdForColor, typedValue, true);
return typedValue.data;
}
}
这个 Activity 对应的布局文件如下:
<?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"
android:background="?attr/color_bkg_main"
android:orientation="vertical" >
<TextView
android:id="@+id/btn_switch_theme_sky"
android:layout_width="match_parent"
android:layout_height="45dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp"
android:background="?attr/color_primary"
android:gravity="center"
android:textColor="#fff"
android:text="change theme sky" />
<TextView
android:id="@+id/btn_switch_theme_grass"
android:layout_width="match_parent"
android:layout_height="45dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp"
android:background="?attr/color_primary"
android:gravity="center"
android:textColor="#fff"
android:text="change theme grass" />
<TextView
android:id="@+id/btn_switch_theme_default"
android:layout_width="match_parent"
android:layout_height="45dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp"
android:background="?attr/color_primary"
android:gravity="center"
android:textColor="#fff"
android:text="change theme default" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp"
android:padding="25dp"
android:background="?attr/color_primary"
android:orientation="vertical" >
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?attr/color_text"
android:text="Text Normal" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?attr/color_text_pressed"
android:text="Text pressed" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?attr/color_text_sub"
android:text="Text sub" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?attr/color_text_hint"
android:text="Text hint" />
</LinearLayout>
<FrameLayout
android:id="@+id/primary_color_panel"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp" />
</LinearLayout>
这样就解决了多主题的问题. 新加入一个主题也非常简单, 只要继承 theme_default, 复写一些颜色值就可以了.
下面开始掉坑.
坑!
此坑很凌乱, 各种资源对象在各个版本表现都不一样. 但成坑原因其实基本一致. 大体就是, xml 资源中 (比如 drawable) 如果引用了 attr 定义的颜色, 再引用该 xml 资源时都有可能有问题. 我们暂且称此类资源为 attr-xml 资源.
注意: 因为手头最低版本的设备为 Android 4.1.2 (API level 16), 最高为 Android 7.0 (API level 24), 超出这个范围就没有实测了.
AppCompat
在 Android 6.0 (API level 23) 以下设备上, 如果 Activity 不是继承自 v23.0 以上的 AppCompat 包中的 AppCompatActivity
的话, 使用 attr-xml 资源可能出现各种奇怪问题.
比如 ColorStateList 的 xml 资源在最终显示时会被渲染为红色.
因为解决办法很简单, 所以不对由此产生的各种问题再做具体讨论了.
解决办法:
举例, 比如有以下 ColorStateList 资源 res/color/color_selector_primary.xml
:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:color="?attr/color_primary_pressed"/>
<item android:state_enabled="false" android:color="?attr/color_primary_disabled"/>
<item android:color="?attr/color_primary"/>
</selector>
被 TextView 的 android:textColor=@color/color_selector_primary
引用就会渲染为红色字体.
方法1: Activity 继承 AppCompatActivity 就可以了 (v7兼容库版本大于 v23.0).
compile 'com.android.support:appcompat-v7:25.2.0'
public class ThemeChangeActivity extends AppCompatActivity { ... }
方法2: 改为使用相关的兼容库控件, 比如 TextView 改为 AppCompatTextView:
<android.support.v7.widget.AppCompatTextView
android:id="@+id/btn_switch_theme_grass"
android:layout_width="match_parent"
android:layout_height="45dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp"
android:gravity="center"
android:textColor="@color/color_selector_primary"
android:text="change theme grass" />
很显然, 第一种方法更简单. 除了 dialog 等少数地方不能用这个方法解决, 其他地方用继承 AppCompatActivity 的方法解决最方便. 第二种方法还得逐个改.
下文中的讨论都是在 Activity 已经继承了 AppCompatActivity 的基础上进行的.
background
这个才是真坑...
上面讲为了 android:textColor
属性设置 selector (ColorStateList), 但其实我们更常用的是为 android:background
属性设置 selector. 而 background 只能接受 drawable 类型的 selector.
举例, 有如下 selector 类型的 drawable res/drawable/selector_primary.xml
:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="?attr/color_primary_pressed" />
</shape>
</item>
<item>
<shape>
<solid android:color="?attr/color_primary" />
</shape>
</item>
</selector>
在 Android 5.0 (API level 21) 以下机器上, drawable xml 资源中引用 attr , 如果在 layout 布局中引用这样的 drawable 资源, 则会引发崩溃. 以下为截取的崩溃 trace 中的片段:
03-14 11:21:30.092 4920 4920 E AndroidRuntime: Caused by: java.lang.UnsupportedOperationException: Can't convert to color: type=0x2
03-14 11:21:30.092 4920 4920 E AndroidRuntime: at android.content.res.TypedArray.getColor(TypedArray.java:326)
03-14 11:21:30.092 4920 4920 E AndroidRuntime: at android.graphics.drawable.GradientDrawable.inflate(GradientDrawable.java:951)
03-14 11:21:30.092 4920 4920 E AndroidRuntime: at android.graphics.drawable.Drawable.createFromXmlInner(Drawable.java:895)
03-14 11:21:30.092 4920 4920 E AndroidRuntime: at android.graphics.drawable.StateListDrawable.inflate(StateListDrawable.java:183)
03-14 11:21:30.092 4920 4920 E AndroidRuntime: at android.graphics.drawable.Drawable.createFromXmlInner(Drawable.java:895)
03-14 11:21:30.092 4920 4920 E AndroidRuntime: at android.graphics.drawable.Drawable.createFromXml(Drawable.java:828)
03-14 11:21:30.092 4920 4920 E AndroidRuntime: at android.content.res.Resources.loadDrawable(Resources.java:1933)
03-14 11:21:30.092 4920 4920 E AndroidRuntime: ... 31 more
博主目前暂未找到解决该问题的办法. 如有好的方法, 还请指点.
因此只能尝试绕过, 方式是在代码中手动组装 ColorStateList, Drawable 等可用做 background 背景的资源.
ColorStateList 可以这么组装:
private static ColorStateList createColorStateList(Context context) {
return new ColorStateList(
new int[][]{
new int[]{android.R.attr.state_pressed}, // pressed state.
StateSet.WILD_CARD, // other state.
},
new int[]{
getThemeAttrColor(context, R.attr.color_primary_pressed), // pressed state.
getThemeAttrColor(context, R.attr.color_primary), // other state.
});
}
drawable 可以这么组装:
public static Drawable createDrawableSelector(Context context) {
StateListDrawable stateDrawable = new StateListDrawable();
GradientDrawable normalDrawable = new GradientDrawable();
GradientDrawable pressedDrawable = new GradientDrawable();
GradientDrawable disabledDrawable = new GradientDrawable();
int[][] states = new int[4][];
states[0] = new int[]{android.R.attr.state_enabled, android.R.attr.state_pressed};
states[1] = new int[]{android.R.attr.state_enabled, android.R.attr.state_focused};
states[3] = new int[]{-android.R.attr.state_enabled}; // disabled state
states[2] = new int[]{android.R.attr.state_enabled};
// 为各种状态下的 drawable 设置 attr 颜色值
normalDrawable.setColor(getColorByAttrId(context, R.attr.color_primary));
pressedDrawable.setColor(getColorByAttrId(context, R.attr.color_primary_pressed));
disabledDrawable.setColor(getColorByAttrId(context, R.attr.color_primary_disabled));
// 为各种状态下的 drawable 设置圆角等属性. 仅举一例, 不详述.
normalDrawable.setCornerRadius(5);
pressedDrawable.setCornerRadius(5);
disabledDrawable.setCornerRadius(5);
stateDrawable.addState(states[0], pressedDrawable);
stateDrawable.addState(states[1], pressedDrawable);
stateDrawable.addState(states[3], disabledDrawable);
stateDrawable.addState(states[2], normalDrawable);
return stateDrawable;
}
@ColorInt
public static int getColorByAttrId(Context context, @AttrRes int attrIdForColor) {
TypedValue typedValue = new TypedValue();
Resources.Theme theme = context.getTheme();
theme.resolveAttribute(attrIdForColor, typedValue, true);
return typedValue.data;
}
把这种由代码生成的 drawable 通过 View.setBackground(Drawable background)
方法设置, 效果即可生效.
可以据此封装一些常用控件出来, 比如 TextView, 以简化相关工作.
但 workaround 的解决方案, 无论怎么简化, 都是比较恶心的.
文末 大神的文章 中提到, 使用 6.0 的矢量图 (vector drawable, 可通过 AppCompat 包向下兼容) 可以不受此问题影响, 可以解决 drawable 引用 attr 颜色问题. 这个没有实测. 因为即使这种方法可用, 博主也没有时间将所有 drawable xml 全部改为矢量图 (会被 UI 打死的).
小伙伴们有兴趣也可以试下这种方法.
原因:
不关心起因的同学可略过此节.
简单来说, 这个问题是 API < 21 的系统中, Resources.getColor() 方法没有接收 theme 参数导致的.
在 Android 5.0 以下, 我们在代码中获取颜色值, 只能用这个方法:
Resoueces.getColor(@ColorRes int id)
上面那条语句在 5.0 以上的SDK, android studio (lint) 会给我们一条警告, 告诫我们方法已 Deprecated, 建议使用下面的方法:
Resources.getColor(@ColorRes int id, @Nullable Theme theme)
ContextCompat 类中也提供了一个兼容的方法:
ContextCompat.getColor(Context context, @ColorRes int id)
这个方法最终就是在调用类似下面的罗辑 (之所以说 "类似", 是因为各版本的兼容包略有不同):
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return context.getResources().getColor(id, context.getTheme());
} else {
return context.getResources().getColor(id);
}
而我们定义的 attr 颜色值, 是直接和主题 theme 相关的. 因此在早于 5.0 的版本上, theme 参数没有被逐层传递下去, 相关控件就肯定取不到对应的颜色值. drawable 的问题是类似的.
又一个坑
这是一个小坑, 或者说, 这是一个我们编程时应避开的错误. 这里一并提出, 以免有小伙伴踩到坑.
本文讨论的 attr 资源, 因为都是和主题直接相关的, 因此一定要注意, 不同的 Context 获取到的资源会有不同!
比如有时候我们为了方便, 在应用全局保存了一个 Application 的实例, 这样就可以用静态方法取到颜色等资源. 比如有以下简单的帮助类:
public final class ResourceUtils {
private static Application sApp;
private static Resources sRes;
private ResourceUtils() {
}
public static void init(Application app) {
sApp = app;
sRes = app.getResources();
}
public static String getString(int resId) {
return sRes.getString(resId);
}
public static int getColor(int resId) {
return sRes.getColor(resId);
}
}
上面这个类可以在 Application.onCreate() 里面初始化, 然后就可以愉快的以静态方法获取资源了.
但用这种方法, 是在用 ApplicationContext 获取资源, 其行为和用 Activity 的 Context 获取资源会有不同, 在资源和主题 theme 相关联时, 其取到的资源也会不同. 具体请学习 ContextWrapper 相关知识 (博主还没来得及细研究, 就不展开写了, 以免误导大家...) .
因此 取和主题相关的资源时, 尽量用当前 Activity 的 Context 就是了.
参考:
google code issue
stackoverflow question
Styling Colors & Drawables w/ Theme Attributes