Android 主题动态切换框架:Prism

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

 

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

000-GraphPad_PrismPrism 分为三个独立库:

  • 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 中找到。

posted on 2016-06-15 09:47  Sun‘刺眼的博客  阅读(2373)  评论(0编辑  收藏  举报

导航