Android 主题动态切换框架:Prism

Prism(棱镜) 是一个全新的 Android 动态主题切换框架,虽然是头一次发布,但它所具备的基础功能已经足够强大了!本文介绍了 Prism 的各种用法,希望对你会有所帮助,你也可以对它进行扩展,来满足开发需求。

先说一下 Prism 的诞生背景。其实我没打算一上来就写个框架出来,当时在给 Styling Android 博客写一些使用 ViewPager 来实现 UI 动态着色的系列文章,文中用到的代码被我重构成适合讲解用的组件,然后我发现这些代码可以整理成一个简洁的 API,于是乎便有了做 Prism 框架的想法。我把 Prism 拿给我比较认可的几个人看,他们都觉得不错,这样我就一点点把它做成了库。经过反复使用,我觉得这个 API 在保持架构简洁的同时已经具备了很多的功能,就决定把它发布出来了跟大家分享。

Prism 分为三个独立库:

  • prism 是 Prism 的核心库

  • prism-viewpager 实现了 ViewPager 与核心库的对接

  • prism-palette 实现了 Palette 调色板与核心库的对接

将它们拆分开的原因是核心库 prism 没有外部依赖,身量轻巧,很容易添加到项目中去,而 prism-viewpager 和 prism-palette 要依赖于外部相关的支持库。如果项目不需要这两个扩展库,就没有其他依赖了;假如应用程序用到了 ViewPager,那该项目就包含了 ViewPager 所依赖的支持库,这时再引入 prism-viewpager 库,其所带来的系统开销大可忽略不计。

Prism 已发布到 jCenter 和 Maven Central 上,如果你的项目已使用了其中一个做为依赖仓库,那只要在 build.gradle 的 dependencies 选项下添加 Prism 库就好。以下是添加了 prism 和 prism-viewpager 两个库的代码(最后两行):

apply plugin: 'com.android.application'
android {
    compileSdkVersion 22
    buildToolsVersion "23.0.0 rc3"
    defaultConfig {
        applicationId "com.stylingandroid.prism.sample.palette"
        minSdkVersion 7
        targetSdkVersion 22
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}
dependencies {
    compile 'com.android.support:appcompat-v7:22.2.0'
    compile 'com.android.support:design:22.2.0'
    compile 'com.android.support:support-v4:22.2.0'
    compile 'com.stylingandroid.prism:prism:1.0.1'
    compile 'com.stylingandroid.prism:prism-viewpager:1.0.1'
}

目前已发布的版本是 1.0.1,最新版本的链接是https://bintray.com/stylingandroid/maven/prism/_latestVersion

添加好必要的依赖就可以使用 Prism 了。

Prism 基本上由三种对象类型构成:SetterFilter 和 Trigger

Setter 用来设置 UI 对象的颜色,一般是 View 但也可以是其他元素,后面会讲到。它的基本用法是将 setColour(int colour)(或 setColor(int color))映射到 View 封装的某个方法上。例如,内置的 ViewBackgroundSetter 会映射到 setBackgroundCOLOR(int color) 上。有时 Setter 在不同版本的 Android 上会产生不同的效果,例如 StatusBarSetter 在 Android Lollipop (5.0) 之前的系统上不起作用,因为 Lollipop 之前的版本不支持改变 StatusBar 的颜色。不过 Prism 会随机应变,不会引起程序崩溃,请放心使用,一切交由 Setter 搞定。

Prism 内置有如下几个基本的 Setter:

  • FabSetter(FloatingActionButton fab)
    为 Android Design Support Library 中的 FloatingActionButton(简写 FAB)设置背景色。

  • StatusBarSetter(Window window)
    设置指定窗体的状态栏颜色,注意它的操作对象并不是 View。

  • TextSetter(TextView textView)
    设置 TextView 中的文本颜色。

  • ViewBackgroundSetter(View view)
    设置 View 的背景颜色。

当然,你也可以创建新的 Setter 给自定义 View 中的不同组件设置颜色,或者给同一个 View 创建多个 Setter 来设置不同的属性,同时对不同组件进行着色。只要把自定义的 Setter 添加到 Prism 中即可生效。

Filter 可以对颜色进行转化处理。一般向 Prism 传入的是一个颜色值,有时我们可能需要把该颜色的不同色度应用到不同的 UI 组件上,这时要用 Filter 将颜色进行一下转换再输出。内置的基本 Filter 有:

  • IdentifyFilter()
    返回与输入相同的颜色。

  • ShadeFilter(float amount)
    将输入颜色与黑色混合进行加深处理。amount 为 0 到 1 之间的浮点数,代表黑色的混合比率。当 amount 为 0 时,输出颜色就是输入颜色;为 1 时,则输出纯黑色。

  • TintFilter(float amount)
    将输入颜色与白色混合进行加亮处理。amount 为 0 到 1 之间的浮点数,代表白色的混合比率。当 amount 为 0 时,输出颜色就是输入颜色;为 1 时,则输出纯白色。

Trigger 是颜色变化时所触发的事件。通常它会调用 Prism 实例上的 setColour(int colour),将颜色变化的消息传递给在该实例上注册过的所有 Setter 方法。

因为 Trigger 需要额外的依赖库,所以 Prism 核心库没有将它包含进去,但在 ViewPager 和 Palette 的扩展库中都有提供。

接下来我们要将 Prism 这三个组件整合起来,其实每个 Prism 实例的作用就是如此。每个实例可以有多个 Trigger 或者一个都没有,同样也可以有一个或多个 Setter。每个 Setter 可以绑定一个 Filter,Filter 把 Trigger 发过来的颜色转换后再交还给 Setter。

Prism 还提供了一些智能的工厂方法,它们会为传入的数据自动创建 Setter 方法,比如向Prism.Builder.background() 传入 FloatingActionButton,Prism 会自动创建出 FabColourSetter。

每个 Prism 实例会使用 builder 模式来构建和整合组件,然后与 Trigger 绑定,对触发事件做出响应。下面来看一下如何创建一个 Prism 实例:

    // MainActivity.java
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        TextView textView = (TextView) findViewById(R.id.text_view);
        AppBarLayout appBar = (AppBarLayout) findViewById(R.id.app_bar);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
        setSupportActionBar(toolbar);
        // --- 创建 Prism 实例 ---------------------
        Filter tint = new TintFilter(TINT_FACTOR_50_PERCENT);
        prism = Prism.Builder.newInstance()
                .background(appBar)
                .background(getWindow())
                .text(textView)
                .background(fab, tint)
                .build();
        // ----------------------------------------
        fab.setOnClickListener(this);
        setColour(currentColour);
    }
    @Override
    protected void onDestroy() {
        if (prism != null) {
            prism.destroy();
        }
        super.onDestroy();
    }

上面的代码大部分都是基本的 Android 开发操作,不需要特别的解释。重点看一下创建 Prism 实例的部分——先创建一个将输入颜色加亮 50% 的 Filter(TintFilter),然后创建 Prism.Builder 实例,并添加 AppBar 实例(这会为 AppBar 创建一个 Setter 来设置背景色)、Window(为 StatusBarColour 创建 Setter 来设置状态栏颜色)、TextView(使用 text(TextView) 来设置文字颜色),以及 FloatingActionButton(设置 FAB 背景色并应用第一步中的 TintFilter)。最后用build() 来完成 Prism 实例的构建。

现在所有组件都被串联了起来,此时只要调用该实例上的 setColour(int colour) 就可以同时改变这些组件的颜色:

prism.setColour(0xFF0000);

代码最后明确使用了 onDestroy() 来清除 Prism 实例。其实严格来说这一步并不是必须要有,因为等到 Activity 被清除后,系统不会保留对 Prism 实例的引用,垃圾回收器会将 Prism 实例处理掉。不过如果后面真不会再用的话,及时做下手工清理也无妨。

Prism 的基本用法就是这样,只要在 onCreate() 中增加六行代码,就能同时改变各组件的颜色(下面使用了 FloatingActionButton 来触发颜色切换)。

把 Setter 和 Filter 配合起来使用省去了大量的样板代码,让事情简单好多,实际上它们完成的工作并不复杂,但如果搭配 Trigger 使用,情况就不一样了。

首先将 prism-viewpager 做为依赖添加到项目中来,对应的 build.gradle 内容如下:

...
dependencies {
    compile 'com.android.support:appcompat-v7:22.2.0'
    compile 'com.android.support:design:22.2.0'
    compile 'com.stylingandroid.prism:prism:1.0.1'
    compile 'com.stylingandroid.prism:prism-viewpager:1.0.1'
}

Trigger 是 Prism 实例最前方的关卡,它来触发主题颜色的改变。我们先来看一下 ViewPagerTrigger 如何根据用户操作来触发 ViewPager 改变颜色。ViewPager 的 Adaptor 要为每个页面位置提供颜色信息,这需要通过 ColourProvider 接口来完成(或 ColorProvider,如果不介意使用这种拼写方式所带来的少许性能损失的话 1):

// ColourProvider.java
public interface ColourProvider {
    @ColorInt int getColour(int position);
    int getCount();
}
// ColorProvider.java
public interface ColorProvider {
    @ColorInt int getColor(int position);
    int getCount();
}

如果你用过 PagerTitleStrip 或 Design Library 中的 TabLayout,那对给每个页面位置提供一个标题的做法就不陌生了。ColourProvider 接口就是这个作用,只不过它把标题的字符串换成了 RGB 颜色值。Adapter 已内置了 getCount() 方法,所以在继承 Adapter 时不用重新定义这个方法,可以按下面的示例来实现自己的 Adaptor:

// RainbowPagerAdapter.java
public class RainbowPagerAdapter extends FragmentPagerAdapter implements ColourProvider {
    private static final Rainbow\[\] COLOURS = {
            Rainbow.Red, Rainbow.Orange, Rainbow.Yellow, Rainbow.Green,
            Rainbow.Blue, Rainbow.Indigo, Rainbow.Violet
    };
    private final Context context;
    public RainbowPagerAdapter(Context context, FragmentManager fragmentManager) {
        super(fragmentManager);
        this.context = context;
    }
    @Override
    public Fragment getItem(int position) {
        Rainbow colour = COLOURS\[position\];
        return ColourFragment.newInstance(context, getPageTitle(position), colour.getColour());
    }
    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        FragmentManager manager = ((Fragment) object).getFragmentManager();
        FragmentTransaction trans = manager.beginTransaction();
        trans.remove((Fragment) object);
        trans.commit();
        super.destroyItem(container, position, object);
    }
    @Override
    public int getCount() {
        return COLOURS.length;
    }
    @Override
    public CharSequence getPageTitle(int position) {
        return COLOURS\[position\].name();
    }
    @Override
    public int getColour(int position) {
        return COLOURS\[position\].getColour();
    }
    private enum Rainbow {
        Red(Color.rgb(0xFF, 0x00, 0x00)),
        Orange(Color.rgb(0xFF, 0x7F, 0x00)),
        Yellow(Color.rgb(0xCF, 0xCF, 0x00)),
        Green(Color.rgb(0x00, 0xAF, 0x00)),
        Blue(Color.rgb(0x00, 0x00, 0xFF)),
        Indigo(Color.rgb(0x4B, 0x00, 0x82)),
        Violet(Color.rgb(0x7F, 0x00, 0xFF));
        private final int colour;
        Rainbow(int colour) {
            this.colour = colour;
        }
        public int getColour() {
            return colour;
        }
    }
}

我们得到了一个实现了 ColourProvider 接口的 Adaptor,现在可以把它跟 ViewPagerTrigger 一起使用了:

// MainActivity.java
public class MainActivity extends AppCompatActivity {
    private static final float TINT_FACTOR_50_PERCENT = 0.5f;
    private DrawerLayout drawerLayout;
    private View navHeader;
    private AppBarLayout appBar;
    private Toolbar toolbar;
    private TabLayout tabLayout;
    private ViewPager viewPager;
    private FloatingActionButton fab;
    private Prism prism = null;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout);
        navHeader = findViewById(R.id.nav_header);
        appBar = (AppBarLayout) findViewById(R.id.app_bar);
        toolbar = (Toolbar) findViewById(R.id.toolbar);
        tabLayout = (TabLayout) findViewById(R.id.tab_layout);
        viewPager = (ViewPager) findViewById(R.id.viewpager);
        fab = (FloatingActionButton) findViewById(R.id.fab);
        setupToolbar();
        setupViewPager();
    }
    @Override
    protected void onDestroy() {
        if (prism != null) {
            prism.destroy();
        }
        super.onDestroy();
    }
    private void setupToolbar() {
        setSupportActionBar(toolbar);
        ActionBar actionBar = getSupportActionBar();
        if (actionBar != null) {
            actionBar.setHomeAsUpIndicator(R.drawable.ic_menu);
            actionBar.setDisplayHomeAsUpEnabled(true);
            actionBar.setTitle(R.string.app_title);
        }
    }
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case android.R.id.home:
                drawerLayout.openDrawer(GravityCompat.START);
                return true;
            default:
                return super.onOptionsItemSelected(item);
        }
    }
    private void setupViewPager() {
        RainbowPagerAdapter adapter = new RainbowPagerAdapter(this, getSupportFragmentManager());
        viewPager.setAdapter(adapter);
        Filter tint = new TintFilter(TINT_FACTOR_50_PERCENT);
        Trigger trigger = ViewPagerTrigger.newInstance(viewPager, adapter);
        prism = Prism.Builder.newInstance()
                .add(trigger)
                .background(appBar)
                .background(getWindow())
                .background(navHeader)
                .background(fab, tint)
                .colour(viewPager, tint)
                .build();
        tabLayout.setupWithViewPager(viewPager);
        viewPager.setCurrentItem(0);
    }
}

在 setupViewPager() 中,我们先创建了一个 RainbowPagerAdapter 实例,并把它应用到 ViewPager 上,然后又创建了一个加亮 FAB 背景色的 TintFilter, 以及与 ViewPager 和 Adaptor 相关联的 Trigger。

接着以同样的方式再创建一个 Prism 实例,这次我们为 Prism 绑定了更多的组件,并添加了刚才做好的 Trigger。你可能注意到 ViewPager 实例被设置了颜色,这会改变 ViewPager 滑动到边界时产生的发光效果的颜色(因为不同版本的系统会用不同的方式来处理发光效果,但 Prism 内部会处理好这些差异)。

然后把 TabLayout 和 ViewPager 进行绑定(TabLayout 要求这样做,但 Prism 并不需要这样),最后把 ViewPager 的初始页面设为第一页。好了大功告成,现在主题色会随着标签页的切换而改变,请看 Demo:

002 Scrolling

细心的人可能会发现其间的颜色过渡看起来并不生硬,颜色是随着用户的拖拽而逐渐产生变化:

003 Swiping

还有一些更微妙的细节。如果用户选择了间隔很远的标签页面,正常情况会过渡显示从开始到结束标签之间的每种颜色,从视觉上说会略显唐突和不自然,而 ViewPagerTrigger 只选择开始和结束标签的两种颜色来做平滑过渡(也就是黄色 YELLOW 和紫色 VIOLET,跳过 GREEN、BLUE 和 INDIGO):

004 Tapping

这是 ViewPager 滑动到边界时的动画效果:

005 Over-Scroll

最后我们来说一下 prism-palette 的用法。先将它做为依赖添加到项目中来,对应的 build.gradle 内容如下:

...
dependencies {
    compile 'com.android.support:appcompat-v7:22.2.0'
    compile 'com.android.support:design:22.2.0'
    compile 'com.stylingandroid.prism:prism:1.0.0'
    compile 'com.stylingandroid.prism:prism-palette:1.0.0'
}

PaletteTrigger 使用起来非常简单,只要创建一个 PaletteTrigger 实例,再把它添加到 Prism.Builder 上:

paletteTrigger = new PaletteTrigger();
prism = Prism.Builder.newInstance()
        .add(paletteTrigger)
        .
        .
        .
        .build();

接下来,我们可以通过调用 PaletteTrigger 的 setBitmap(Bitmap bitmap) 方法来触发颜色变化。这会创建一个新的 Palette 实例,等到 Palette 从图像中提取完色样后就去触发 Prism。

要想正确地为相关联的 UI 组件着色,我们需要了解 Palette 的工作原理。

Palette 可以从一张图片中提取出最多 6 种不同的色样:

  • 鲜艳

  • 鲜艳浓

  • 鲜艳淡

  • 柔色

  • 柔色浓

  • 柔色淡

每种色样又可以分离出 3 种色值:

  • 原色

  • 适用于以原色为背景色的标题文本的色值

  • 适用于以原色为背景色的正文的色值

这样从 Palette 中我们可以获取最多 18 种不同的颜色。

PrismTrigger 提供了许多工厂方法,以 Filter 的形式返回不同的色样,通过使用 modifier 让 Filter 决定要不要使用原色、标题颜色和正文颜色。实际上这是利用 Filter 机制为每一个与 Prism 关联起来的 UI 组件找到合适的颜色。

例如要给标题使用「鲜艳浓」的颜色,只要将有效的工厂方法链式连接起来组成所需的 Filter:

Filter darkVibrantTitle = paletteTrigger.getDarkVibrantFilter(paletteTrigger.getTextFilter());

如果不设置 Filter 那么 Palette 会默认使用「鲜艳」的原色色值,但建议按需要设置好 Filter。目前,如果 Palette 没找到指定色样,就会应用透明效果,即把被着色的 UI 组件完全隐藏起来。这种处理方法并不理想,我们会在以后版本中做出改进。

至此 PaletteTrigger 跟 Prism 完全绑定好了:

View vibrant = findViewById(R.id.swatch_vibrant);
View vibrantLight = findViewById(R.id.swatch_vibrant_light);
View vibrantDark = findViewById(R.id.swatch_vibrant_dark);
View muted = findViewById(R.id.swatch_muted);
View mutedLight = findViewById(R.id.swatch_muted_light);
View mutedDark = findViewById(R.id.swatch_muted_dark);
titleText = (TextView) findViewById(R.id.title);
bodyText = (TextView) findViewById(R.id.body);
paletteTrigger = new PaletteTrigger();
prism = Prism.Builder.newInstance()
    .add(paletteTrigger)
    .background(vibrant, paletteTrigger.getVibrantFilter(paletteTrigger.getColour()))
    .background(vibrantLight, paletteTrigger.getLightVibrantFilter(paletteTrigger.getColour()))
    .background(vibrantDark, paletteTrigger.getDarkMutedFilter(paletteTrigger.getColour()))
    .background(muted, paletteTrigger.getMutedFilter(paletteTrigger.getColour()))
    .background(mutedLight, paletteTrigger.getLightMutedFilter(paletteTrigger.getColour()))
    .background(mutedDark, paletteTrigger.getDarkMutedFilter(paletteTrigger.getColour()))
    .background(titleText, paletteTrigger.getVibrantFilter(paletteTrigger.getColour()))
    .text(titleText, paletteTrigger.getVibrantFilter(paletteTrigger.getTitleTextColour()))
    .background(bodyText, paletteTrigger.getLightMutedFilter(paletteTrigger.getColour()))
    .text(bodyText, paletteTrigger.getLightMutedFilter(paletteTrigger.getBodyTextColour()))
    .add(this)
    .build();

6 个 View 对象各自采用了上述 6 种色样的一种,2 个 TextView 中标题使用了「鲜艳」,正文了使用「柔色浅」。

你可能还注意到我们把 Activity 注册成一个 Setter,这是为了在 Palette 完成色样提取后收到回调,因为处理较大图像时速度可能会慢。这样只有等色样提取完成后 ImageView 中的图像才会被更新,用户体验会稍稍好一点,图像更新和 UI 颜色刷新同步进行。请看 Demo:

006 Prism Palette

在上面的示例中我们实际并没绑定 UI,只是演示一下怎样提取各种色样以及如何应用。但根据前面讲过的内容,相信加入绑定也不是难事。

这些就是 Prism 的基本用法。如果 Prism 开发还会继续,我们会带来更多的内容。文中的所有例子可以从 Github- Prism 源码 中的 sample 中找到。


【拼写】大家可能注意到了 Prism 中有些方法名称采用的是英式拼写习惯,比如 setColour() 中的 colour。我是英国人,我知道很多人喜欢用 color 的写法,我尊重这种个人偏好,所以 Prism 支持两种拼法。也就是说凡是用到 setColour(int colour) 的地方都可以替换成 setColor(int color),两者是等价的。只不过如果使用 setColor(int color),系统内部实际会去调用setColour(int colour),所以直接使用英式拼写可以稍稍节省一些系统开销。

【重要提示】由于代码版权问题,Prism 的开发计划已无限期搁置,具体说明请参考 Prism 源码仓库中的 README 内容。之所以仍然要发布这篇文章,是想让大家了解到 Prism 现有的功能,对自己的开发有所帮助。


原文链接:

转载自:https://blog.leancloud.cn/3612/

来自:UI实验室