在上一次https://www.cnblogs.com/webor2006/p/12218582.html已经完成了动态换肤的效果了,但是其实还有一些细节待完善,所以这次一一来把它们都解决掉。
换肤细节完善:
先来涨个姿势:
再往下进行完善之前,先来搞明白个东东,就是关于Activity的换肤其实很好理解,因为咱们在Activity创建时设置了咱们自己的工厂了,如下:
但是!!!对于MainActivity的三个Fragment很明显没有给它设置布局工厂嘛,那为啥它们也能够正确的得到换肤呢?先来回忆一下效果:
下面来分析一下它背后的原理,以其中的第一个MusicFragment进行分析既可,其它它里面有一个这样的方法:
当然实际使用时一般是不需要咱们来手动重写的,上面重写的目的是为了道出换肤的原理,所以咱们跟进去瞅一下它的源码:
其中mHost大致瞅一下,对于主流程的分析不太重要:
回到主流程继续往下:
好,到这里只要明白对于Fragment也有它自己的布局工厂既可。好继续往下分析,此时就需要分析布局加载器中的设置工厂的内部实现细节了:
接下来则就来分析一下这个合并的细节,我们要的答案既将揭晓:
对于上面工厂的合并用图来表示一下:
状态栏(StatuBar)及导航栏(NavigationBar)换肤:
对于状态栏及导航栏在手机界面的具体位置先来用图来表示一下,其实这个对于使用Android的人来说都比较清楚了,啰嗦一下:
咱们先看一下目前存在的问题:
目前咱们的导航栏还是白色的,为了统一风格咱们先来将它也弄成红色的,如下:
看下此时的效果:
好,接下来处理这个换肤问题,预期如果换肤的话应该将导航栏和标题栏变成一个黑色系,那处理的位置在哪呢?很显然也应该在下载完皮肤包之后那个观察者通知回调里面,如下:
然后这里在这个主题工具类中来处理,因为是要改变主题色:
由于构造中增加了一个参数,则调用方也需要修改一下:
所以接下来核心就放到了这个工具类中:
具体的实现就直接贴出来的,关键是通过Window来进行设置,如下:
public class SkinThemeUtils {
private static int[] APPCOMPAT_COLOR_PRIMARY_DARK_ATTRS = {
androidx.appcompat.R.attr.colorPrimaryDark
};
private static int[] STATUSBAR_COLOR_ATTRS = {android.R.attr.statusBarColor, android.R.attr
.navigationBarColor};
public static int[] getResId(Context context, int[] attrs) {
int[] ints = new int[attrs.length];
TypedArray typedArray = context.obtainStyledAttributes(attrs);
for (int i = 0; i < typedArray.length(); i++) {
ints[i] = typedArray.getResourceId(i, 0);
}
typedArray.recycle();
return ints;
}
//替换状态栏
public static void updataStatusBarColor(Activity activity) {
//5.0 以上才能修改
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return;
}
//获取statusBarColor与navigationBarColor 颜色值
int[] statusBarId = getResId(activity, STATUSBAR_COLOR_ATTRS);
if (statusBarId[0] != 0) {
//如果statusBarColor配置颜色值,就换肤
activity.getWindow().setStatusBarColor(SkinResources.getInstance().getColor(statusBarId[0]));
} else {
//获取兼容包中的colorPrimaryDark,兼容版本
int resId = getResId(activity, APPCOMPAT_COLOR_PRIMARY_DARK_ATTRS)[0];
if (resId != 0) {
activity.getWindow().setStatusBarColor(SkinResources.getInstance().getColor(resId));
}
}
if (statusBarId[1] != 0) {
activity.getWindow().setNavigationBarColor(SkinResources.getInstance().getColor(statusBarId[1]));
}
}
}
其中需要注意的是:我这DEMO用的是andoridx中的版本,所以兼容包都是用的androidx:
但是!如果不想用androidx,也可以改为support-v4、support-v7之类的,比如:
关于AndroidX库的由于可以参考顶顶大名的郭大神的这篇博客https://blog.csdn.net/guolin_blog/article/details/97142065, 好,接下来咱们应用看一下效果:
嗯,但是!!貌似MainActivity的标题栏的颜色木有变:
感觉比较突兀,这里将其隐藏掉:
但是现在还有一个问题,就是如果app退出,样式又还原了,如下:
这里比较好解决,就是在Activity创建时则主动是更新一个状态栏的皮肤既可,如下:
再运行:
字体换肤:
皮肤包增加字体文件:
接下来看另外一种换肤的场景,就是字体,对于目前已经实现的换肤都是直接替换对应的资源文件,而对于字体貌似没有直接能替换的,那如何来实现呢?先导入两个字体文件到皮肤包中:
由于木有像color这样直接可以系统定义它,所以可以将这个路径定义在一个strings.xml当中:
然后此时再重新生成一个皮肤包并替换到手机的sdcard上既可。
实现字体的换肤逻辑:
实现思路:
那具体如何来实现呢?下面先来说一下大概的思路:
①、自定义属性,用来配置字体文件地址换肤的时候进行读取,如下:
②、全局字体的换肤:也就是替换app的全局字体,而非某个页面的字体。
③、局部字体的换肤:也就是某些页面的某个控件需要进行换肤,如下:
好,接下来开始实现,先来定义一个属性文件:
全局字体换肤实现:
接下来咱们先来实现全局的字体换肤,所以先到主题那块增加一个字体的样式属性,这里有个小技巧出来了,看一下:
这个肯定得要跟我们在皮肤包中定义的字体串是一样的才能达到动态根据皮肤包来换字体,咱们皮肤包中定义了两个字体的串,如下:
但是呢很明显在咱们的项目中木有定义这个typeface的字串嘛,所以此时报红了:
那怎么样?很简单,直接在咱们app中定义一个空串既可,里面不需要指定值:
此时这个报错就解决了:
对于这个空串有啥意义在具体实现就可以知道了,接下来则开始实现咱们的字体换肤,其应用的入口跟状态栏换肤是一样的,如下:
具体就是怎么来动态根据皮肤包来获取字体了,其思路也是跟标题栏的换肤是一样的,比较容易理解就直接贴代码了:
而具体字体的获取实现如下:
获取了字体之后,接下来则需要应用它了,此时就需要这样办:
好了,接下来则需要应用这个全局字体到TextView控件上了,如下:
修改一下SkinView的代码如下:
static class SkinView {
View view;
List<SkinPain> skinPains;
public SkinView(View view, List<SkinPain> skinPains) {
this.view = view;
this.skinPains = skinPains;
}
public void applySkin(Typeface typeface) {
applyTypeface(typeface);
for (SkinPain skinPair : skinPains) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
Object background = SkinResources.getInstance().getBackground(
skinPair.resId);
//Color
if (background instanceof Integer) {
view.setBackgroundColor((Integer) background);
} else {
ViewCompat.setBackground(view, (Drawable) background);
}
//摸摸唱
break;
case "src":
background = SkinResources.getInstance().getBackground(skinPair
.resId);
if (background instanceof Integer) {
((ImageView) view).setImageDrawable(new ColorDrawable((Integer)
background));
} else {
((ImageView) view).setImageDrawable((Drawable) background);
}
break;
case "textColor":
((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
(skinPair.resId));
break;
case "drawableLeft":
left = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableTop":
top = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableRight":
right = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableBottom":
bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
default:
break;
}
if (null != left || null != right || null != top || null != bottom) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
bottom);
}
}
}
//字体换肤
private void applyTypeface(Typeface typeface) {
if (view instanceof TextView) {
((TextView) view).setTypeface(typeface);
}
}
}
另外更新皮肤还得在下载皮肤包成功之后的观察者回调中进行应用,类似于状态栏的换肤,如下:
所以整个的SkinAttribute的代码如下:
public class SkinAttribute {
private static final List<String> ATTRIBUTES = new ArrayList<>();
private List<SkinView> skinViews = new ArrayList<>();
private Typeface typeface;
static {
ATTRIBUTES.add("background");
ATTRIBUTES.add("src");
ATTRIBUTES.add("textColor");
ATTRIBUTES.add("drawableLeft");
ATTRIBUTES.add("drawableTop");
ATTRIBUTES.add("drawableRight");
ATTRIBUTES.add("drawableBottom");
ATTRIBUTES.add("skinTypeface");
}
public SkinAttribute(Typeface skinTypeface) {
typeface = skinTypeface;
}
public void setTypeface(Typeface skinTypeface) {
this.typeface = skinTypeface;
}
public void load(View view, AttributeSet attrs) {
List<SkinPain> skinPains = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//获取属性名字
String attributeName = attrs.getAttributeName(i);
if (ATTRIBUTES.contains(attributeName)) {
//获取属性对应的值
String attributeValue = attrs.getAttributeValue(i);
if (attributeValue.startsWith("#")) {
//android:textColor="#ffffff",像这种写死的色值肯定是不希望进行动态换肤的
continue;
}
int resId;
//判断前缀字符串 是否是"?"
if (attributeValue.startsWith("?")) {
//系统属性值,比如:android:textColor="?colorAccent",则属性ID的名称是从属性值下标1的位置开始,注意:它在R中是一个int类型的
int attrId = Integer.parseInt(attributeValue.substring(1));
resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
} else {
//android:textColor="@color/cardview_dark_background",是这种场景
resId = Integer.parseInt(attributeValue.substring(1));
}
if (resId != 0) {
SkinPain skinPain = new SkinPain(attributeName, resId);
skinPains.add(skinPain);
}
}
}
if (!skinPains.isEmpty() || view instanceof TextView) {
SkinView skinView = new SkinView(view, skinPains);
skinView.applySkin(typeface);
skinViews.add(skinView);
}
}
static class SkinView {
View view;
List<SkinPain> skinPains;
public SkinView(View view, List<SkinPain> skinPains) {
this.view = view;
this.skinPains = skinPains;
}
public void applySkin(Typeface typeface) {
applyTypeface(typeface);
for (SkinPain skinPair : skinPains) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
Object background = SkinResources.getInstance().getBackground(
skinPair.resId);
//Color
if (background instanceof Integer) {
view.setBackgroundColor((Integer) background);
} else {
ViewCompat.setBackground(view, (Drawable) background);
}
//摸摸唱
break;
case "src":
background = SkinResources.getInstance().getBackground(skinPair
.resId);
if (background instanceof Integer) {
((ImageView) view).setImageDrawable(new ColorDrawable((Integer)
background));
} else {
((ImageView) view).setImageDrawable((Drawable) background);
}
break;
case "textColor":
((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList
(skinPair.resId));
break;
case "drawableLeft":
left = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableTop":
top = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableRight":
right = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableBottom":
bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
default:
break;
}
if (null != left || null != right || null != top || null != bottom) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
bottom);
}
}
}
//字体换肤
private void applyTypeface(Typeface typeface) {
if (view instanceof TextView) {
((TextView) view).setTypeface(typeface);
}
}
}
static class SkinPain {
String attributeName;
int resId;
public SkinPain(String attributeName, int resId) {
this.attributeName = attributeName;
this.resId = resId;
}
}
/**
* 换皮肤
*/
public void applySkin() {
for (SkinView mSkinView : skinViews) {
mSkinView.applySkin(typeface);
}
}
}
好,整个实现已经写完了,接下来看一下效果:
嗯,完美实现了全局的字体更换了,还是很6的。
局部字体换肤实现:
接下来咱们定义一个局部控件的字体,不想用全局的字体了,如下:
接下来则需要在过滤属性处增加相应的逻辑,比较简单:
应用一下:
成功被局部应用上了,以上就是对于字体换肤的整个实现,不是太难,但是呢如果光自己想不一定能想到这种方式。
自定义View换肤:
在我们的DEMO中其实用到了两个自定义的View:
其中MyTabLayout指的是首页的这块位置:
而CircleView指的是SkinActivity的这个位置:
原来是由于我们给它加了一个黑色的背景:
咱们去掉它就成了,修改如下:
再运行:
那对于自定义View如果想换肤该怎么弄呢?接下来咱们来看一下:
MyTabLayout换肤:
对于首页其实这个控件换肤是有问题的,先看一下目前的效果:
所以这就是接下来要来解决的,需要额外处理一下才能完美支持,如何处理呢?先来回顾一下咱们现有控件的换肤,是通过View的工厂来对控件一个个进行属性过滤然后再做的换肤处理,但是!!自定义View的自定义属性不是一个统一,不可能用这种提前知道属性名的方式来进行换肤了,所以咱们得要换一种思路,如果能将要换肤的自定义控件有一个统一的行为抽象,那不到时代码处理时只要判断该View是否具有这种行为然后再决定是否走换肤处理呢?是的,定义一个抽象的接口既可,然后所有想动态换肤的自定义控件来实现它既可,如下:
然后实现它:
具体的实现如下,比较简单:
public class MyTabLayout extends TabLayout implements SkinViewSupport {
int tabIndicatorColorResId;
int tabTextColorResId;
public MyTabLayout(Context context) {
this(context, null, 0);
}
public MyTabLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MyTabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabLayout,
defStyleAttr, 0);
tabIndicatorColorResId = a.getResourceId(R.styleable.TabLayout_tabIndicatorColor, 0);
tabTextColorResId = a.getResourceId(R.styleable.TabLayout_tabTextColor, 0);
a.recycle();
}
@Override
public void applySkin() {
if (tabIndicatorColorResId != 0) {
int tabIndicatorColor = SkinResources.getInstance().getColor(tabIndicatorColorResId);
setSelectedTabIndicatorColor(tabIndicatorColor);
}
if (tabTextColorResId != 0) {
ColorStateList tabTextColor = SkinResources.getInstance().getColorStateList
(tabTextColorResId);
setTabTextColors(tabTextColor);
}
}
}
好,那在哪调用这个应用皮肤的方法呢?其实跟应用字体换肤的位置一样,很简单:
那,下面来应用一下,看效果,发现木有效果呀,其实这里忽略添加了一个条件的,如下:
再来看下效果:
完美!!!
CircleView换肤:
依葫芦画瓢呗,先实现个接口,实现里面换肤的逻辑,这里就简单换个圆圈的颜色既可:
这样就可以啦,框架搭好,so easy~~接下来看下效果:
内置换肤:
啥叫内置换肤呢?其实没啥,就是皮肤包放到了应用的assets目录里面了,之前我们加载皮肤不是放到sdcard上的么?所以比较简单,先将咱们的皮肤包放到assets目录中:
然后再点击换肤的时候,由之前的sdcard来加载改为由assets目录,这里模拟服务器下载的的情况,先封装一个皮肤实体:
Skin.java:
public class Skin {
/**
* 文件校验md5值
*/
public String md5 = "";
/**
* 下载地址
*/
public String url = "xxxx";
/**
* 皮肤名
*/
public String name = "";
/**
* 下载完成后缓存地址
*/
public String path = "";
public File file;
public Skin(String md5, String name, String url) {
this.md5 = md5;
this.name = name;
this.url = url;
}
public File getSkinFile(File theme) {
if (null == file) {
file = new File(theme, name);
}
path = file.getAbsolutePath();
return file;
}
}
SkinUtils.java:
public class SkinUtils {
/**
* 获取一个文件的md5值(可处理大文件)
*/
public static String getSkinMD5(File file) {
FileInputStream fis = null;
BigInteger bi = null;
try {
MessageDigest MD5 = MessageDigest.getInstance("MD5");
fis = new FileInputStream(file);
byte[] buffer = new byte[10240];
int length;
while ((length = fis.read(buffer)) != -1) {
MD5.update(buffer, 0, length);
}
byte[] digest = MD5.digest();
bi = new BigInteger(1, digest);
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
try {
if (fis != null) {
fis.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return bi.toString(16);
}
}
然后修改SkinActivity的代码:
public class SkinActivity extends Activity {
/**
* 从服务器拉取的皮肤表
*/
List<Skin> skins = new ArrayList<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_skin);
loadData();
}
private void loadData() {
skins.add(new Skin("dccf685150ad3f9e3239f5ae7ec8b2ec", "app_skin-debug.apk", "app_skin-debug.apk"));
}
public void change(View view) {
// String path = Environment.getExternalStorageDirectory() + File.separator + "app_skin-debug.apk";
// SkinManager.getInstance().loadSkin(path);
Skin skin = skins.get(0);
selectSkin(skin);
//换肤
SkinManager.getInstance().loadSkin(skin.path);
}
public void restore(View view) {
SkinManager.getInstance().loadSkin(null);
}
private void selectSkin(Skin skin) {
//应用包名文件目录
File theme = new File(getFilesDir(), "theme");
if (theme.exists() && theme.isFile()) {
theme.delete();
}
theme.mkdirs();
File skinFile = skin.getSkinFile(theme);
if (skinFile.exists()) {
Log.e("SkinActivity", "皮肤已存在,开始换肤");
return;
}
Log.e("SkinActivity", "皮肤不存在,开始下载");
FileOutputStream fos = null;
InputStream is = null;
//临时文件
File tempSkin = new File(skinFile.getParentFile(), skin.name + ".temp");
try {
fos = new FileOutputStream(tempSkin);
//假设下载皮肤包
is = getAssets().open(skin.url);
byte[] bytes = new byte[10240];
int len;
while ((len = is.read(bytes)) != -1) {
fos.write(bytes, 0, len);
}
//下载成功,将皮肤包信息insert已下载数据库
Log.e("SkinActivity", "皮肤包下载完成开始校验");
//皮肤包的md5校验 防止下载文件损坏(但是会减慢速度。从数据库查询已下载皮肤表数据库中保留md5字段)
if (TextUtils.equals(SkinUtils.getSkinMD5(tempSkin), skin.md5)) {
Log.e("SkinActivity", "校验成功,修改文件名。");
tempSkin.renameTo(skinFile);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
tempSkin.delete();
if (null != fos) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (null != is) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* 夜间模式
*/
public void night(View view) {
//TODO
Toast.makeText(this, "夜间模式", Toast.LENGTH_SHORT).show();
}
/**
* 日间模式
*/
public void day(View view) {
//TODO
Toast.makeText(this, "日间模式", Toast.LENGTH_SHORT).show();
}
}
其运行效果是一样的,对于上面的加载代码应该放到子线程中去做,关于这个细节就不处理了,重点是来观注整个换肤的原理,至此关于整个APP换肤的所有细节都处理完了,接下来则来看一下如何来实现日夜模式的切换。
日夜模式:
思路:
关于日夜模式这块就比上面实现的换肤简单多了,其实就是类似于多语言的适配一样,其核心流程如下:
1、先在res资源文件中增加对应夜间模式的版本,如下:
2、然后在Application中全局进行替换,如下:
其中AppCompatDelegate有如下几个模式可以设置:
1、MODE_NIGHT_YES:直接指定夜间模式。
2、MODE_NIGHT_NO:直接指定日间模式。
3、MODE_NIGHT_FOLLOW_SYSTEM:根据系统设置决定是否设置夜间模式。
4、MODE_NIGHT_AUTO:根据当前时间自动切换模式。
实现:
直接先建一个values-night文件夹,里面定义要在夜间显示的样式既可,不多说了,直接贴出来:
colors.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--toolBar-->
<color name="colorPrimary">#000000</color>
<color name="colorSkinText">#000000</color>
<!--状态栏(style同时设置底部栏颜色)-->
<color name="colorPrimaryDark">#000000</color>
<!-- 文字正常主色 -->
<color name="colorAccent">#000000</color>
<color name="tabSelectedTextColor">#000000</color>
</resources>
styles.xml:
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorAccent</item>
<item name="colorPrimaryDark">@color/colorAccent</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="skinTypeface">@string/typeface</item>
</style>
<style name="SplashTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowBackground">@drawable/p_login_bg</item>
</style>
</resources>
全改为了黑色了,好,接下来撸码,比较简单就不多解释了,首先需要将上面写的换肤的初始化给注释掉,不然跟这个日夜模式会冲突,如下:
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// SkinManager.init(this);
//根据app上次退出的状态来判断是否需要设置夜间模式,提前在SharedPreference中存了一个是
// 否是夜间模式的boolean值
boolean isNightMode = NightModeConfig.getInstance().getNightMode(getApplicationContext());
if (isNightMode) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
} else {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
}
}
}
其中用SharedPreferences来记录当前的日夜状态:
public class NightModeConfig {
private SharedPreferences mSharedPreference;
private static final String NIGHT_MODE = "Night_Mode";
public static final String IS_NIGHT_MODE = "Is_Night_Mode";
private boolean mIsNightMode;
private SharedPreferences.Editor mEditor;
private static NightModeConfig sModeConfig;
public static NightModeConfig getInstance() {
return sModeConfig != null ? sModeConfig : new NightModeConfig();
}
public boolean getNightMode(Context context) {
if (mSharedPreference == null) {
mSharedPreference = context.getSharedPreferences(NIGHT_MODE, context.MODE_PRIVATE);
}
mIsNightMode = mSharedPreference.getBoolean(IS_NIGHT_MODE, false);
return mIsNightMode;
}
public void setNightMode(Context context, boolean isNightMode) {
if (mSharedPreference == null) {
mSharedPreference = context.getSharedPreferences(NIGHT_MODE, context.MODE_PRIVATE);
}
mEditor = mSharedPreference.edit();
mEditor.putBoolean(IS_NIGHT_MODE, isNightMode);
mEditor.commit();
}
}
好,接下来则来实现日夜按钮切换的点击事件:
好,整个日夜换肤的代码就写完了,但是此时如果运行会发现木有效果,这是因为不能用普通的Activity,而需用AppCompatActivity,如下:
然后在我们切换的时候需要让当前的Activity重新创建一下才行,如下:
而recreate()是系统提供的方法,简单看一下细节:
秒懂了不,好,接下来可以运行看一下效果了:
貌似在回MainActivity时的日夜间风格木有刷新,其实也是同样的道理,要生效必须得让当前Activity重新创建一下,所以咱们稍加处理一下既可,如下:
最后再运行看一下:
嗯~~完美实现!!!