Android 动态换肤扩展

在上一节中讲解了动态换肤的原理以及动态换肤的基本实现,本篇文章来讲解动态换肤扩展(Fragment、状态栏、自定义view、字体).

Fragment 扩展

Fragment 不用设置fractory2,只要activity中设置了fractory2就不用在设置就可以实现换肤,所以fragment不用任何修改.那么是为什么呢?

我们都知道在fragment是通过,onCreateView 传递一个LayoutInflater,来实现view的加载

override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_music, container, false)
    }

那么这个LayoutInflater 是从哪里来的呢? 相信大家也能猜出个大概,其实就是和加载Fragment的Activity有关,我们看一下系统的Fragment源码

    @Deprecated
    @NonNull
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    public LayoutInflater getLayoutInflater(@Nullable Bundle savedFragmentState) {
        if (mHost == null) {
            throw new IllegalStateException("onGetLayoutInflater() cannot be executed until the "
                    + "Fragment is attached to the FragmentManager.");
        }
        LayoutInflater result = mHost.onGetLayoutInflater();
        LayoutInflaterCompat.setFactory2(result, mChildFragmentManager.getLayoutInflaterFactory());
        return result;
    }

在上述代码中,Fragment通过 mHostonGetLayoutInflater 获取的,我们来看一下这个 mHost 是什么? mhost是一个接口类FragmentHostCallback. 代码如下,可以看出Fragment的很多操作都和mHost有关,包括获取上下文等操作,那么我们就可以断定这个mHost 肯定是从Activity中传递过来的

       // Host this fragment is attached to.
    FragmentHostCallback mHost;
    @Nullable
    public Context getContext() {
        return mHost == null ? null : mHost.getContext();
    }
 @Nullable
    final public FragmentActivity getActivity() {
        return mHost == null ? null : (FragmentActivity) mHost.getActivity();
    }

.....

FragmentActivity 的源码如下: FrgmentActivity 的代码中实现了FragmentHostCallback,那么Framgment的 mHost 其实就是FragmentActivity的实现接口类

 class HostCallbacks extends FragmentHostCallback<FragmentActivity> implements
            ViewModelStoreOwner,
            OnBackPressedDispatcherOwner {
        public HostCallbacks() {
            super(FragmentActivity.this /*fragmentActivity*/);
        }

.....
        @Override
        @NonNull
        public LayoutInflater onGetLayoutInflater() {
            return FragmentActivity.this.getLayoutInflater().cloneInContext(FragmentActivity.this);
        }

     ......
    }

也就是说mHost.onGetLayoutInflater();其实调用的就是如下代码

public LayoutInflater onGetLayoutInflater() {
            return FragmentActivity.this.getLayoutInflater().cloneInContext(FragmentActivity.this);
        }

  /**
     * Convenience for calling
     * {@link android.view.Window#getLayoutInflater}.
     */
    @NonNull
    public LayoutInflater getLayoutInflater() {
        return getWindow().getLayoutInflater();
    }

那么具体实现的LayoutInflater就是PhoneLayoutInfalter,代码如下:

public class PhoneLayoutInflater extends LayoutInflater {
    private static final String[] sClassPrefixList = {
        "android.widget.",
        "android.webkit.",
        "android.app."
    };

    /**
     * Instead of instantiating directly, you should retrieve an instance
     * through {@link Context#getSystemService}
     *
     * @param context The Context in which in which to find resources and other
     *                application-specific things.
     *
     * @see Context#getSystemService
     */
    public PhoneLayoutInflater(Context context) {
        super(context);
    }

    protected PhoneLayoutInflater(LayoutInflater original, Context newContext) {
        super(original, newContext);
    }

    /** Override onCreateView to instantiate names that correspond to the
        widgets known to the Widget factory. If we don't find a match,
        call through to our super class.
    */
    @Override protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
        for (String prefix : sClassPrefixList) {
            try {
                View view = createView(name, prefix, attrs);
                if (view != null) {
                    return view;
                }
            } catch (ClassNotFoundException e) {
                // In this case we want to let the base class take a crack
                // at it.
            }
        }

        return super.onCreateView(name, attrs);
    }

    public LayoutInflater cloneInContext(Context newContext) {
        return new PhoneLayoutInflater(this, newContext);
    }
}

PhoneLayoutInfalter实现流程和LayoutInfalter的流程是一样的,只不过通过cloneInContext,重新new了一个LayoutInflater 也相当于克隆了一个LayoutInflater,调用了Layoutflater的一个构造方法,同时也将factory2进行了赋值.

protected LayoutInflater(LayoutInflater original, Context newContext) {
        mContext = newContext;
        mFactory = original.mFactory;
        mFactory2 = original.mFactory2;
        mPrivateFactory = original.mPrivateFactory;
        setFilter(original.mFilter);
    }

那么我们再回到Fragment中的getLayoutInflater中,不知道大家有没有发现Fragment又重新对factory2进行了赋值,那么为什么我们还能动态换肤成功呢?

 public LayoutInflater getLayoutInflater(@Nullable Bundle savedFragmentState) {
        if (mHost == null) {
            throw new IllegalStateException("onGetLayoutInflater() cannot be executed until the "
                    + "Fragment is attached to the FragmentManager.");
        }
        LayoutInflater result = mHost.onGetLayoutInflater();
        LayoutInflaterCompat.setFactory2(result, mChildFragmentManager.getLayoutInflaterFactory());
        return result;
    }

我们来看一下setFactory2的实现如下: 很显然当第二次进行赋值时mFactory不等于空,而是new FactoryMerger进行了赋值,将两个factory进行了合并

    public void setFactory2(Factory2 factory) {
        if (mFactorySet) {
            throw new IllegalStateException("A factory has already been set on this LayoutInflater");
        }
        if (factory == null) {
            throw new NullPointerException("Given factory can not be null");
        }
        mFactorySet = true;
        if (mFactory == null) {
            mFactory = mFactory2 = factory;
        } else {
            mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
        }
    }

看如下代码,会先对第一次赋值对mF12进行加载view操作如果返回为空,就使用第二此赋值对f1进行加载view操作.调用了Factory2的onCreateView(parent, name, context, attrs)方法

private static class FactoryMerger implements Factory2 {
        private final Factory mF1, mF2;
        private final Factory2 mF12, mF22;

        FactoryMerger(Factory f1, Factory2 f12, Factory f2, Factory2 f22) {
            mF1 = f1;
            mF2 = f2;
            mF12 = f12;
            mF22 = f22;
        }

        public View onCreateView(String name, Context context, AttributeSet attrs) {
            View v = mF1.onCreateView(name, context, attrs);
            if (v != null) return v;
            return mF2.onCreateView(name, context, attrs);
        }

        public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
            View v = mF12 != null ? mF12.onCreateView(parent, name, context, attrs)
                    : mF1.onCreateView(name, context, attrs);
            if (v != null) return v;
            return mF22 != null ? mF22.onCreateView(parent, name, context, attrs)
                    : mF2.onCreateView(name, context, attrs);
        }
    }
.........
            View view;
            if (mFactory2 != null) {
                view = mFactory2.onCreateView(parent, name, context, attrs);
            } else if (mFactory != null) {
                view = mFactory.onCreateView(name, context, attrs);
            } else {
                view = null;
            }

综上,Fragment加载view会先加载我们自定义的Factory2,如果返回为空就使用Fragment设置的Factory2,所以Fragment 动态换肤不需要做任何处理.

状态栏扩展

状态栏的颜色改变,我们只需要通过代码动态修改状态栏的颜色即可,获取在style中的设置然后,在Activity的onCreate和动态更新皮肤的时候在SkinLayoutFactory进行调用如下方法即可.

 private static int[] APPCOMPAT_COLOR_PRINARY_DARK_ATIRS = {
            androidx.appcompat.R.attr.colorPrimaryDark
    };

    //高优先级
    private static int[] STATUSBAR_COLOR_ATTRS = {
            android.R.attr.statusBarColor, android.R.attr.navigationBarColor
    };

    //更新状态栏
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public static void updateStatusBar(Activity activity) {
        int[] statusBarColorIDs = getResId(activity, STATUSBAR_COLOR_ATTRS);
        if (statusBarColorIDs[0] == 0) {//如果没有配置状态栏颜色则读取colorPrimaryDark
            int statusBarColorID = getResId(activity, APPCOMPAT_COLOR_PRINARY_DARK_ATIRS)[0];
            if (statusBarColorID != 0) {
                //读取皮肤包中的颜色值
                int color = SkinResources.getInstance().getColor(statusBarColorID);
                activity.getWindow().setStatusBarColor(color);
            }
        } else {
            activity.getWindow().setStatusBarColor(SkinResources.getInstance().getColor(statusBarColorIDs[0]));
        }
        if (statusBarColorIDs[1] != 0) {
            activity.getWindow().setNavigationBarColor(SkinResources.getInstance().getColor
                    (statusBarColorIDs[1]));
        }
    }

字体扩展

如何实现对皮肤包中对字体更改呢? 我们需要定义一个自定义的属性,在attrs中自定义一个属性,然后这个属性值就是字体的文件地址一般都放在assets/font中

  <!-- 设置自定义属性 自定义字体 -->
    <attr name="skinTypeFace" format="string" />

然后在App中的style基础的theme中设置,在strings中 设置string属性,这样我们就可以在皮肤包中设置string 属性动态的修改为皮肤包资源的字体文件

  <style name="BaseTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="skinTypeFace">@string/typeface</item>
    </style>

    <style name="AppTheme" parent="BaseTheme"></style>

从资源中找到字体文件,代码如下:

private static int[] TYPRFACE_ATTR = {R.attr.skinTypeFace}; 
public static Typeface updateTypeface(Activity activity) {
        int skinTypefaceId = getResId(activity, TYPRFACE_ATTR)[0];
        return SkinResources.getInstance().getTypeface(skinTypefaceId);
    }

 public Typeface getTypeface(int resId) {
        String skinTypefacePath = getString(resId);
        if (TextUtils.isEmpty(skinTypefacePath)) {
            return Typeface.DEFAULT;
        }
        try {
            Typeface typeface;
            if (isDefalueSkin) {
                typeface = Typeface.createFromAsset(mAppResources.getAssets(), skinTypefacePath);
                return typeface;

            }
            typeface = Typeface.createFromAsset(mSkinResources.getAssets(), skinTypefacePath);
            return typeface;
        } catch (RuntimeException e) {
        }
        return Typeface.DEFAULT;
    }

我们获取到Typeface 设置到TextView 中去. 这段代码是很基础的,在SkinAttribute中我们拿到了需要采集的view,更换皮肤时会对采集的view集合进行遍历,判断是否为TextView,如果是TextView则进行设置setTypeface.这样就实现了字体的替换.

if (view instanceof TextView) {
                ((TextView) view).setTypeface(typeface);
            }

这样上述代码我们就实现了全局的字体更换.那么面对变态的需求如何实现局部的字体更换呢?

在上述中我们自定义了一个属性


     <attr name="skinTypeFace" format="string" />

然后在指定的使用不同字体的view,设置该属性

<Button
        skinTypeFace="@string/typeface2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:gravity="center"
        android:text="测试文字颜色与selector换肤"
        android:textColor="@color/selector_color_test"
        android:textSize="22sp"
        tools:ignore="MissingPrefix" />

这样我们只需要在皮肤包中的string.xml中添加typeface2,设置字体路径,然后在SkinAttribute中添加要采集的属性skinTypeFace

static {
        mAttributes.add("background");
        mAttributes.add("src");

        mAttributes.add("textColor");
        mAttributes.add("drawableLeft");
        mAttributes.add("drawableTop");
        mAttributes.add("drawableRight");
        mAttributes.add("drawableBottom");
        mAttributes.add("skinTypeFace");
    }

在使用皮肤包的时候,applySkin判断skinTypeFace属性是否存在,如果存在则获取Typeface进行设置.

case "skinTypeFace"://布局的字体更换
Typeface typeface1 = SkinResources.getInstance().getTypeface(skinPair.resId);
applySkinTypeface(typeface1);
break;

这样我们就实现了局部字体的更换.

自定义view扩展

关于自定义view,就需要实现一个接口类,然后自己去实现换肤

public interface SkinViewSupport {
    void applySkin();
}

如自定义TabLayout,通过SkinResources来获取资源ID的值进行赋值即可

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 color = SkinResources.getInstance().getColor(tabIndicatorColorResId);
            setSelectedTabIndicatorColor(color);
        }

        if (tabTextColorResId != 0) {
            ColorStateList colorStateList = SkinResources.getInstance().getColorStateList(tabTextColorResId);
            setTabTextColors(colorStateList);
        }
    }
}

同时需要在SkinAttribute中,将其添加到采集到view集合中去,确保其被添加到集合中.

else if (view instanceof TextView || view instanceof SkinViewSupport) {
            //没有属性满足 但是需要修改字体
            SkinView skinView = new SkinView(view, mSkinPars);
            skinView.applySkin(typeface);
            skinViews.add(skinView);
        }

在更换皮肤时,遍历采集的view,在字体之后添加此方法,让自定义view自己去实现换肤

 private void applySkinSupportView() {
            if (view instanceof SkinViewSupport) {
                ((SkinViewSupport) view).applySkin();
            }
        }