Android 主題動態切換框架:Prism

yangxi_001發表於2017-05-15

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


【拼寫】大家可能注意到了 Prism 中有些方法名稱採用的是英式拼寫習慣,比如 setColour() 中的 colour。我是英國人,我知道很多人喜歡用 color 的寫法,我尊重這種個人偏好,所以 Prism 支援兩種拼法。也就是說凡是用到 setColour(int colour) 的地方都可以替換成 setColor(int color),兩者是等價的。只不過如果使用 setColor(int color),系統內部實際會去呼叫 setColour(int colour),所以直接使用英式拼寫可以稍稍節省一些系統開銷。

【重要提示】由於程式碼版權問題,Prism 的開發計劃已無限期擱置,具體說明請參考 Prism 原始碼倉庫 中的 README 內容。之所以仍然要釋出這篇文章,是想讓大家瞭解到 Prism 現有的功能,對自己的開發有所幫助。


原文連結:

轉載本中文譯文請註明出處:LeanCloud

相關文章